1
0
Fork 0
mirror of https://github.com/imjasonh/terraform-playground synced 2026-07-20 21:39:24 +00:00

Add internal/risk package with comprehensive tests

Co-authored-by: imjasonh <210737+imjasonh@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2026-02-18 17:04:53 +00:00
parent 2617dd6bad
commit 0dfa7cbae1
2 changed files with 832 additions and 0 deletions

View file

@ -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
}

View file

@ -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)
}
})
}