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:
parent
2617dd6bad
commit
0dfa7cbae1
2 changed files with 832 additions and 0 deletions
407
driftlessaf/internal/risk/risk.go
Normal file
407
driftlessaf/internal/risk/risk.go
Normal 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
|
||||
}
|
||||
425
driftlessaf/internal/risk/risk_test.go
Normal file
425
driftlessaf/internal/risk/risk_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue