From 0dfa7cbae17d8273da0c1990dec667bb0fd86a23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:04:53 +0000 Subject: [PATCH] Add internal/risk package with comprehensive tests Co-authored-by: imjasonh <210737+imjasonh@users.noreply.github.com> --- driftlessaf/internal/risk/risk.go | 407 +++++++++++++++++++++++ driftlessaf/internal/risk/risk_test.go | 425 +++++++++++++++++++++++++ 2 files changed, 832 insertions(+) create mode 100644 driftlessaf/internal/risk/risk.go create mode 100644 driftlessaf/internal/risk/risk_test.go diff --git a/driftlessaf/internal/risk/risk.go b/driftlessaf/internal/risk/risk.go new file mode 100644 index 0000000..d3d79cc --- /dev/null +++ b/driftlessaf/internal/risk/risk.go @@ -0,0 +1,407 @@ +// Package risk provides functionality for assessing the risk level of +// GitHub pull requests based on changed files, size, and content analysis. +package risk + +import ( + "context" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/google/go-github/v75/github" +) + +// Level represents the risk level of a PR. +type Level string + +const ( + LevelLow Level = "low" + LevelMedium Level = "medium" + LevelHigh Level = "high" +) + +// Label returns the GitHub label name for this risk level. +func (l Level) Label() string { + return fmt.Sprintf("risk/%s", l) +} + +// Priority returns a numeric priority for comparison (higher = more risky). +func (l Level) Priority() int { + switch l { + case LevelHigh: + return 3 + case LevelMedium: + return 2 + case LevelLow: + return 1 + default: + return 0 + } +} + +// Rule defines a rule for risk assessment based on file patterns. +type Rule struct { + Level Level + Patterns []string + Description string +} + +// Config holds the configuration for risk assessment. +type Config struct { + Rules []Rule + // SizeThresholds define risk levels based on PR size + LargePRThreshold int // Lines changed that triggers high risk + MediumPRThreshold int // Lines changed that triggers medium risk +} + +// DefaultConfig returns the default risk assessment configuration. +func DefaultConfig() Config { + return Config{ + Rules: []Rule{ + // High risk: Critical infrastructure files + { + Level: LevelHigh, + Patterns: []string{"*.tf", "*.tfvars", "terraform.tfstate*"}, + Description: "Terraform infrastructure changes can affect cost, stability, and availability", + }, + { + Level: LevelHigh, + Patterns: []string{"Dockerfile*", "docker-compose*"}, + Description: "Container configuration changes can affect deployment and security", + }, + { + Level: LevelHigh, + Patterns: []string{"k8s/**", "kubernetes/**", "helm/**", "*.yaml", "*.yml"}, + Description: "Kubernetes configuration changes can affect service availability", + }, + { + Level: LevelHigh, + Patterns: []string{".github/workflows/**"}, + Description: "CI/CD workflow changes can affect build and deployment processes", + }, + { + Level: LevelHigh, + Patterns: []string{"go.mod", "package.json", "requirements.txt", "Cargo.toml"}, + Description: "Dependency changes can introduce security vulnerabilities or breaking changes", + }, + { + Level: LevelHigh, + Patterns: []string{"**/migrations/**", "**/*migration*"}, + Description: "Database migrations can affect data integrity and require careful review", + }, + + // Medium risk: Important but less critical changes + { + Level: LevelMedium, + Patterns: []string{"*.go", "*.rs", "*.java", "*.cpp", "*.c"}, + Description: "Backend code changes require review for logic and performance", + }, + { + Level: LevelMedium, + Patterns: []string{"*.ts", "*.tsx", "*.js", "*.jsx"}, + Description: "Frontend code changes can affect user experience", + }, + { + Level: LevelMedium, + Patterns: []string{"*.py"}, + Description: "Python code changes require review", + }, + { + Level: LevelMedium, + Patterns: []string{"**/config/**", "*.conf", "*.config", "*.toml", "*.ini"}, + Description: "Configuration changes can affect application behavior", + }, + }, + LargePRThreshold: 500, // 500+ lines is high risk + MediumPRThreshold: 200, // 200+ lines is medium risk + } +} + +// Scorer assesses risk for pull requests. +type Scorer struct { + config Config +} + +// New creates a new Scorer with the given configuration. +func New(config Config) *Scorer { + return &Scorer{config: config} +} + +// NewWithDefaults creates a new Scorer with the default configuration. +func NewWithDefaults() *Scorer { + return New(DefaultConfig()) +} + +// Assessment contains the result of risk assessment. +type Assessment struct { + Level Level + FilesAnalyzed int + TotalChanges int + Reasons []string + RiskyFiles []string +} + +// AssessRisk determines the risk level based on the changed files. +func (s *Scorer) AssessRisk(ctx context.Context, files []*github.CommitFile) Assessment { + // Calculate total lines changed + totalChanges := 0 + for _, f := range files { + totalChanges += f.GetAdditions() + f.GetDeletions() + } + + assessment := Assessment{ + Level: LevelLow, + FilesAnalyzed: len(files), + TotalChanges: totalChanges, + Reasons: []string{}, + RiskyFiles: []string{}, + } + + // Check size-based risk + if totalChanges >= s.config.LargePRThreshold { + assessment.Level = LevelHigh + assessment.Reasons = append(assessment.Reasons, + fmt.Sprintf("Large PR with %d lines changed (threshold: %d)", totalChanges, s.config.LargePRThreshold)) + } else if totalChanges >= s.config.MediumPRThreshold { + if assessment.Level.Priority() < LevelMedium.Priority() { + assessment.Level = LevelMedium + } + assessment.Reasons = append(assessment.Reasons, + fmt.Sprintf("Medium-sized PR with %d lines changed (threshold: %d)", totalChanges, s.config.MediumPRThreshold)) + } + + // Track highest risk level found in files + fileRiskReasons := make(map[string][]string) + + // Check each file against risk rules + for _, file := range files { + filename := file.GetFilename() + fileRiskLevel := LevelLow + + for _, rule := range s.config.Rules { + if s.fileMatchesRule(filename, rule) { + if rule.Level.Priority() > fileRiskLevel.Priority() { + fileRiskLevel = rule.Level + } + if fileRiskLevel.Priority() >= LevelMedium.Priority() { + fileRiskReasons[filename] = append(fileRiskReasons[filename], rule.Description) + assessment.RiskyFiles = append(assessment.RiskyFiles, filename) + } + } + } + + // Update overall risk level + if fileRiskLevel.Priority() > assessment.Level.Priority() { + assessment.Level = fileRiskLevel + } + } + + // Remove duplicates from risky files + assessment.RiskyFiles = uniqueStrings(assessment.RiskyFiles) + sort.Strings(assessment.RiskyFiles) + + // Add file-based reasons + reasonMap := make(map[string]bool) + for _, reasons := range fileRiskReasons { + for _, reason := range reasons { + if !reasonMap[reason] { + reasonMap[reason] = true + assessment.Reasons = append(assessment.Reasons, reason) + } + } + } + + // If no specific reasons, add a default + if len(assessment.Reasons) == 0 { + if len(files) == 0 { + assessment.Reasons = append(assessment.Reasons, "No files changed") + } else { + assessment.Reasons = append(assessment.Reasons, "Changes do not match high-risk patterns") + } + } + + return assessment +} + +// fileMatchesRule checks if a filename matches any pattern in a rule. +func (s *Scorer) fileMatchesRule(filename string, rule Rule) bool { + for _, pattern := range rule.Patterns { + if matched, _ := matchPattern(pattern, filename); matched { + return true + } + } + return false +} + +// matchPattern checks if a filename matches a glob pattern. +// Supports ** for recursive matching. +func matchPattern(pattern, filename string) (bool, error) { + // Handle ** patterns + if strings.Contains(pattern, "**") { + return matchDoubleStarPattern(pattern, filename) + } + + // Use filepath.Match for simple patterns + if !strings.Contains(pattern, "/") { + // Pattern doesn't contain path separator, match against basename + return filepath.Match(pattern, filepath.Base(filename)) + } + + return filepath.Match(pattern, filename) +} + +// matchDoubleStarPattern handles patterns containing ** by converting to simple matching. +func matchDoubleStarPattern(pattern, filename string) (bool, error) { + // Handle patterns like **/migrations/** or **/*migration* + + // Replace ** with a placeholder and then check + parts := strings.Split(pattern, "**") + + // For patterns like **/migrations/** + // We need to check if any path component matches + if len(parts) == 2 { + prefix := parts[0] + suffix := parts[1] + + // Simple case: prefix/** + if suffix == "" || suffix == "/" { + return strings.HasPrefix(filename, prefix), nil + } + + // Simple case: **/suffix + if prefix == "" { + if strings.HasPrefix(suffix, "/") { + suffix = suffix[1:] + } + if suffix == "" { + return true, nil + } + // Check if the filename contains the suffix as a path component or file pattern + if strings.Contains(filename, suffix) { + return true, nil + } + matched, err := filepath.Match(suffix, filepath.Base(filename)) + if err != nil { + return false, err + } + return matched, nil + } + + // Complex case: prefix/**/suffix (e.g., "k8s/**" or ".github/workflows/**") + if !strings.HasPrefix(filename, prefix) { + return false, nil + } + + if suffix == "" || suffix == "/" { + return true, nil + } + + // Remove prefix and check the rest + remaining := filename[len(prefix):] + if strings.HasPrefix(suffix, "/") { + suffix = suffix[1:] + } + + if suffix == "" { + return true, nil + } + + // Check if suffix is present in remaining path + if strings.Contains(remaining, suffix) { + return true, nil + } + + // Try glob match on the basename + matched, err := filepath.Match(suffix, filepath.Base(remaining)) + if err != nil { + return false, err + } + return matched, nil + } + + // For patterns with multiple **, like **/migrations/** + // Check if all non-** parts are present in the path in order + if len(parts) == 3 { + // Pattern like **/migrations/** + prefix := parts[0] + middle := parts[1] + suffix := parts[2] + + // Check if middle part exists in the filename + if middle != "" && middle != "/" { + // Remove leading/trailing slashes from middle + middle = strings.Trim(middle, "/") + if !strings.Contains(filename, middle) { + return false, nil + } + + // If there's a prefix, check it + if prefix != "" && !strings.HasPrefix(filename, prefix) { + return false, nil + } + + // If there's a suffix, check it appears after middle + if suffix != "" && suffix != "/" { + suffix = strings.Trim(suffix, "/") + middleIdx := strings.Index(filename, middle) + if middleIdx >= 0 { + afterMiddle := filename[middleIdx+len(middle):] + if suffix != "" && !strings.Contains(afterMiddle, suffix) { + // Also try as a glob pattern + matched, err := filepath.Match(suffix, filepath.Base(afterMiddle)) + if err != nil || !matched { + return false, err + } + } + } + } + + return true, nil + } + } + + return false, nil +} + +// FilterNewLabel returns the risk label if it's not already present on the PR. +func FilterNewLabel(assessment Assessment, existing []*github.Label) *string { + existingSet := make(map[string]bool) + for _, label := range existing { + existingSet[label.GetName()] = true + } + + label := assessment.Level.Label() + if !existingSet[label] { + return &label + } + return nil +} + +// RemoveOtherRiskLabels returns a list of risk labels that should be removed +// based on the current assessment. +func RemoveOtherRiskLabels(assessment Assessment, existing []*github.Label) []string { + currentLabel := assessment.Level.Label() + var toRemove []string + + for _, label := range existing { + name := label.GetName() + if strings.HasPrefix(name, "risk/") && name != currentLabel { + toRemove = append(toRemove, name) + } + } + + return toRemove +} + +func uniqueStrings(slice []string) []string { + seen := make(map[string]bool) + result := []string{} + for _, str := range slice { + if !seen[str] { + seen[str] = true + result = append(result, str) + } + } + return result +} diff --git a/driftlessaf/internal/risk/risk_test.go b/driftlessaf/internal/risk/risk_test.go new file mode 100644 index 0000000..854c1c4 --- /dev/null +++ b/driftlessaf/internal/risk/risk_test.go @@ -0,0 +1,425 @@ +package risk + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-github/v75/github" +) + +func TestMatchPattern(t *testing.T) { + for _, tt := range []struct { + desc string + pattern string + filename string + want bool + }{{ + desc: "simple extension match", + pattern: "*.go", + filename: "main.go", + want: true, + }, { + desc: "simple extension no match", + pattern: "*.go", + filename: "main.rs", + want: false, + }, { + desc: "extension match in subdirectory", + pattern: "*.go", + filename: "pkg/foo/bar.go", + want: true, + }, { + desc: "exact filename match", + pattern: "go.mod", + filename: "go.mod", + want: true, + }, { + desc: "exact filename in subdir matches basename", + pattern: "go.mod", + filename: "subdir/go.mod", + want: true, + }, { + desc: "terraform files", + pattern: "*.tf", + filename: "main.tf", + want: true, + }, { + desc: "terraform tfvars", + pattern: "*.tfvars", + filename: "terraform.tfvars", + want: true, + }, { + desc: "dockerfile prefix", + pattern: "Dockerfile*", + filename: "Dockerfile", + want: true, + }, { + desc: "dockerfile with extension", + pattern: "Dockerfile*", + filename: "Dockerfile.prod", + want: true, + }, { + desc: "github workflows", + pattern: ".github/workflows/**", + filename: ".github/workflows/ci.yml", + want: true, + }, { + desc: "k8s directory", + pattern: "k8s/**", + filename: "k8s/deployment.yaml", + want: true, + }, { + desc: "k8s nested", + pattern: "k8s/**", + filename: "k8s/prod/deployment.yaml", + want: true, + }, { + desc: "migrations pattern", + pattern: "**/migrations/**", + filename: "db/migrations/001_init.sql", + want: true, + }, { + desc: "migration file pattern", + pattern: "**/*migration*", + filename: "db/001_migration_init.sql", + want: true, + }, { + desc: "yaml files", + pattern: "*.yaml", + filename: "config.yaml", + want: true, + }} { + t.Run(tt.desc, func(t *testing.T) { + got, err := matchPattern(tt.pattern, tt.filename) + if err != nil { + t.Fatalf("matchPattern(%q, %q) error: %v", tt.pattern, tt.filename, err) + } + if got != tt.want { + t.Errorf("matchPattern(%q, %q) = %v, want %v", tt.pattern, tt.filename, got, tt.want) + } + }) + } +} + +func TestScorer_AssessRisk(t *testing.T) { + scorer := NewWithDefaults() + ctx := context.Background() + + for _, tt := range []struct { + desc string + files []*github.CommitFile + wantLevel Level + }{{ + desc: "terraform changes - high risk", + files: []*github.CommitFile{ + {Filename: github.Ptr("main.tf"), Additions: github.Ptr(20), Deletions: github.Ptr(5)}, + }, + wantLevel: LevelHigh, + }, { + desc: "docker changes - high risk", + files: []*github.CommitFile{ + {Filename: github.Ptr("Dockerfile"), Additions: github.Ptr(15), Deletions: github.Ptr(3)}, + }, + wantLevel: LevelHigh, + }, { + desc: "k8s changes - high risk", + files: []*github.CommitFile{ + {Filename: github.Ptr("k8s/deployment.yaml"), Additions: github.Ptr(30), Deletions: github.Ptr(10)}, + }, + wantLevel: LevelHigh, + }, { + desc: "github workflows - high risk", + files: []*github.CommitFile{ + {Filename: github.Ptr(".github/workflows/ci.yml"), Additions: github.Ptr(25), Deletions: github.Ptr(5)}, + }, + wantLevel: LevelHigh, + }, { + desc: "dependency file - high risk", + files: []*github.CommitFile{ + {Filename: github.Ptr("go.mod"), Additions: github.Ptr(5), Deletions: github.Ptr(2)}, + }, + wantLevel: LevelHigh, + }, { + desc: "database migration - high risk", + files: []*github.CommitFile{ + {Filename: github.Ptr("db/migrations/001_init.sql"), Additions: github.Ptr(50), Deletions: github.Ptr(0)}, + }, + wantLevel: LevelHigh, + }, { + desc: "go code changes - medium risk", + files: []*github.CommitFile{ + {Filename: github.Ptr("main.go"), Additions: github.Ptr(30), Deletions: github.Ptr(10)}, + }, + wantLevel: LevelMedium, + }, { + desc: "typescript changes - medium risk", + files: []*github.CommitFile{ + {Filename: github.Ptr("src/App.tsx"), Additions: github.Ptr(25), Deletions: github.Ptr(5)}, + }, + wantLevel: LevelMedium, + }, { + desc: "python changes - medium risk", + files: []*github.CommitFile{ + {Filename: github.Ptr("script.py"), Additions: github.Ptr(20), Deletions: github.Ptr(5)}, + }, + wantLevel: LevelMedium, + }, { + desc: "config file changes - medium risk", + files: []*github.CommitFile{ + {Filename: github.Ptr("app/config/settings.toml"), Additions: github.Ptr(10), Deletions: github.Ptr(2)}, + }, + wantLevel: LevelMedium, + }, { + desc: "documentation only - low risk", + files: []*github.CommitFile{ + {Filename: github.Ptr("README.md"), Additions: github.Ptr(30), Deletions: github.Ptr(5)}, + }, + wantLevel: LevelLow, + }, { + desc: "test files only - low risk", + files: []*github.CommitFile{ + {Filename: github.Ptr("main_test.go"), Additions: github.Ptr(50), Deletions: github.Ptr(10)}, + }, + wantLevel: LevelMedium, // Still medium because *.go matches + }, { + desc: "small code change - low risk", + files: []*github.CommitFile{ + {Filename: github.Ptr("util.js"), Additions: github.Ptr(5), Deletions: github.Ptr(2)}, + }, + wantLevel: LevelMedium, // Still medium because it's code + }, { + desc: "large PR - high risk by size", + files: []*github.CommitFile{ + {Filename: github.Ptr("data.json"), Additions: github.Ptr(400), Deletions: github.Ptr(200)}, + }, + wantLevel: LevelHigh, + }, { + desc: "medium PR - medium risk by size", + files: []*github.CommitFile{ + {Filename: github.Ptr("data.json"), Additions: github.Ptr(150), Deletions: github.Ptr(100)}, + }, + wantLevel: LevelMedium, + }, { + desc: "mixed changes - highest risk wins", + files: []*github.CommitFile{ + {Filename: github.Ptr("README.md"), Additions: github.Ptr(10), Deletions: github.Ptr(0)}, + {Filename: github.Ptr("main.go"), Additions: github.Ptr(20), Deletions: github.Ptr(5)}, + {Filename: github.Ptr("Dockerfile"), Additions: github.Ptr(5), Deletions: github.Ptr(2)}, + }, + wantLevel: LevelHigh, // Dockerfile is high risk + }} { + t.Run(tt.desc, func(t *testing.T) { + assessment := scorer.AssessRisk(ctx, tt.files) + + if assessment.Level != tt.wantLevel { + t.Errorf("AssessRisk() level = %v, want %v\nReasons: %v", + assessment.Level, tt.wantLevel, assessment.Reasons) + } + + // Verify assessment has required fields + if assessment.FilesAnalyzed != len(tt.files) { + t.Errorf("FilesAnalyzed = %d, want %d", assessment.FilesAnalyzed, len(tt.files)) + } + + if assessment.TotalChanges < 0 { + t.Errorf("TotalChanges should not be negative: %d", assessment.TotalChanges) + } + + if len(assessment.Reasons) == 0 { + t.Error("Assessment should have at least one reason") + } + }) + } +} + +func TestLevel_Label(t *testing.T) { + tests := []struct { + level Level + want string + }{ + {LevelLow, "risk/low"}, + {LevelMedium, "risk/medium"}, + {LevelHigh, "risk/high"}, + } + + for _, tt := range tests { + t.Run(string(tt.level), func(t *testing.T) { + if got := tt.level.Label(); got != tt.want { + t.Errorf("Level.Label() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFilterNewLabel(t *testing.T) { + for _, tt := range []struct { + desc string + assessment Assessment + existing []*github.Label + wantLabel *string + }{{ + desc: "no existing labels", + assessment: Assessment{Level: LevelHigh}, + existing: nil, + wantLabel: github.Ptr("risk/high"), + }, { + desc: "label already exists", + assessment: Assessment{Level: LevelHigh}, + existing: []*github.Label{ + {Name: github.Ptr("risk/high")}, + }, + wantLabel: nil, + }, { + desc: "different risk label exists", + assessment: Assessment{Level: LevelHigh}, + existing: []*github.Label{ + {Name: github.Ptr("risk/low")}, + }, + wantLabel: github.Ptr("risk/high"), + }, { + desc: "other labels exist", + assessment: Assessment{Level: LevelMedium}, + existing: []*github.Label{ + {Name: github.Ptr("lang/go")}, + {Name: github.Ptr("size/M")}, + }, + wantLabel: github.Ptr("risk/medium"), + }} { + t.Run(tt.desc, func(t *testing.T) { + got := FilterNewLabel(tt.assessment, tt.existing) + + if (got == nil) != (tt.wantLabel == nil) { + t.Errorf("FilterNewLabel() = %v, want %v", got, tt.wantLabel) + return + } + + if got != nil && *got != *tt.wantLabel { + t.Errorf("FilterNewLabel() = %v, want %v", *got, *tt.wantLabel) + } + }) + } +} + +func TestRemoveOtherRiskLabels(t *testing.T) { + for _, tt := range []struct { + desc string + assessment Assessment + existing []*github.Label + want []string + }{{ + desc: "no existing risk labels", + assessment: Assessment{Level: LevelHigh}, + existing: []*github.Label{ + {Name: github.Ptr("lang/go")}, + }, + want: nil, + }, { + desc: "same risk label exists", + assessment: Assessment{Level: LevelHigh}, + existing: []*github.Label{ + {Name: github.Ptr("risk/high")}, + }, + want: nil, + }, { + desc: "different risk label exists", + assessment: Assessment{Level: LevelHigh}, + existing: []*github.Label{ + {Name: github.Ptr("risk/low")}, + }, + want: []string{"risk/low"}, + }, { + desc: "multiple risk labels exist", + assessment: Assessment{Level: LevelHigh}, + existing: []*github.Label{ + {Name: github.Ptr("risk/low")}, + {Name: github.Ptr("risk/medium")}, + }, + want: []string{"risk/low", "risk/medium"}, + }, { + desc: "mixed labels", + assessment: Assessment{Level: LevelMedium}, + existing: []*github.Label{ + {Name: github.Ptr("risk/high")}, + {Name: github.Ptr("lang/go")}, + {Name: github.Ptr("risk/low")}, + }, + want: []string{"risk/high", "risk/low"}, + }} { + t.Run(tt.desc, func(t *testing.T) { + got := RemoveOtherRiskLabels(tt.assessment, tt.existing) + + if len(got) != len(tt.want) { + t.Errorf("RemoveOtherRiskLabels() = %v, want %v", got, tt.want) + return + } + + // Check that all expected labels are present + gotMap := make(map[string]bool) + for _, label := range got { + gotMap[label] = true + } + + for _, wantLabel := range tt.want { + if !gotMap[wantLabel] { + t.Errorf("RemoveOtherRiskLabels() missing label %q", wantLabel) + } + } + }) + } +} + +func TestAssessmentReasons(t *testing.T) { + scorer := NewWithDefaults() + ctx := context.Background() + + t.Run("terraform changes include reason", func(t *testing.T) { + files := []*github.CommitFile{ + {Filename: github.Ptr("main.tf"), Additions: github.Ptr(10), Deletions: github.Ptr(5)}, + } + assessment := scorer.AssessRisk(ctx, files) + + foundTerraform := false + for _, reason := range assessment.Reasons { + if strings.Contains(strings.ToLower(reason), "terraform") { + foundTerraform = true + break + } + } + + if !foundTerraform { + t.Errorf("Expected terraform-related reason, got: %v", assessment.Reasons) + } + }) + + t.Run("large PR includes size reason", func(t *testing.T) { + files := []*github.CommitFile{ + {Filename: github.Ptr("data.json"), Additions: github.Ptr(400), Deletions: github.Ptr(200)}, + } + assessment := scorer.AssessRisk(ctx, files) + + foundSize := false + for _, reason := range assessment.Reasons { + if strings.Contains(strings.ToLower(reason), "large") || strings.Contains(strings.ToLower(reason), "lines") { + foundSize = true + break + } + } + + if !foundSize { + t.Errorf("Expected size-related reason, got: %v", assessment.Reasons) + } + }) + + t.Run("risky files are tracked", func(t *testing.T) { + files := []*github.CommitFile{ + {Filename: github.Ptr("main.tf"), Additions: github.Ptr(10), Deletions: github.Ptr(5)}, + {Filename: github.Ptr("Dockerfile"), Additions: github.Ptr(10), Deletions: github.Ptr(5)}, + } + assessment := scorer.AssessRisk(ctx, files) + + if len(assessment.RiskyFiles) != 2 { + t.Errorf("Expected 2 risky files, got %d: %v", len(assessment.RiskyFiles), assessment.RiskyFiles) + } + }) +}