|  | 
|  | 1 | +package utils | 
|  | 2 | + | 
|  | 3 | +import ( | 
|  | 4 | +	"bytes" | 
|  | 5 | +	"context" | 
|  | 6 | +	"errors" | 
|  | 7 | +	"fmt" | 
|  | 8 | +	"log" | 
|  | 9 | +	"os" | 
|  | 10 | +	"os/exec" | 
|  | 11 | +	"path/filepath" | 
|  | 12 | +	"regexp" | 
|  | 13 | +	"strings" | 
|  | 14 | +	"time" | 
|  | 15 | +) | 
|  | 16 | + | 
|  | 17 | +// Options defines filters and behavior for change detection. | 
|  | 18 | +type Options struct { | 
|  | 19 | +	RepoRoot           string | 
|  | 20 | +	Includes           []string | 
|  | 21 | +	IncludeIsRegex     bool | 
|  | 22 | +	SkipIfOnlyDocsYAML bool | 
|  | 23 | +	BaseEnvVar         string | 
|  | 24 | +	HeadEnvVar         string | 
|  | 25 | +	ChangedFilesEnvVar string | 
|  | 26 | +} | 
|  | 27 | + | 
|  | 28 | +// changedFiles holds a normalized list of changed file paths. | 
|  | 29 | +type changedFiles struct { | 
|  | 30 | +	files []string | 
|  | 31 | +} | 
|  | 32 | + | 
|  | 33 | +// ShouldRun determines whether the current E2E suite should run, returning a boolean, | 
|  | 34 | +// a human-readable reason, and an error if one occurred. | 
|  | 35 | +func ShouldRun(opts Options) (bool, string, error) { | 
|  | 36 | +	validateAndNormalizeOpts(&opts) | 
|  | 37 | +	// Check CI environment first. | 
|  | 38 | +	if raw := strings.TrimSpace(os.Getenv(opts.ChangedFilesEnvVar)); raw != "" { | 
|  | 39 | +		return decide(parseChangedFiles(raw), opts) | 
|  | 40 | +	} | 
|  | 41 | + | 
|  | 42 | +	base := os.Getenv(opts.BaseEnvVar) | 
|  | 43 | +	head := os.Getenv(opts.HeadEnvVar) | 
|  | 44 | +	if head == "" { | 
|  | 45 | +		head = "HEAD" | 
|  | 46 | +	} | 
|  | 47 | + | 
|  | 48 | +	cwd, headDiffErr := os.Getwd() | 
|  | 49 | +	if headDiffErr != nil { | 
|  | 50 | +		log.Fatalf("failed to get current working directory: %v", headDiffErr) | 
|  | 51 | +	} | 
|  | 52 | +	// restore original directory at the end | 
|  | 53 | +	defer func(originalDir string) { | 
|  | 54 | +		if chdirErr := os.Chdir(originalDir); chdirErr != nil { | 
|  | 55 | +			log.Printf("WARNING: failed to restore working directory to %q: %v", originalDir, chdirErr) | 
|  | 56 | +		} | 
|  | 57 | +	}(cwd) | 
|  | 58 | + | 
|  | 59 | +	// Confirm RepoRoot exists. | 
|  | 60 | +	if info, statErr := os.Stat(opts.RepoRoot); statErr != nil { | 
|  | 61 | +		return true, "repo root path invalid or inaccessible", fmt.Errorf("stat repo root: %w", statErr) | 
|  | 62 | +	} else if !info.IsDir() { | 
|  | 63 | +		return true, "repo root path is not a directory", errors.New("repo root not a directory") | 
|  | 64 | +	} | 
|  | 65 | + | 
|  | 66 | +	// Resolve base commit SHA if not set. | 
|  | 67 | +	if base == "" { | 
|  | 68 | +		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | 
|  | 69 | +		defer cancel() | 
|  | 70 | + | 
|  | 71 | +		if fetchErr := gitFetchOriginMaster(ctx, opts.RepoRoot); fetchErr != nil { | 
|  | 72 | +			// log warning, but don't fail; fallback handled below | 
|  | 73 | +			logWarning(fmt.Sprintf("git fetch origin/master failed: %v", fetchErr)) | 
|  | 74 | +		} | 
|  | 75 | + | 
|  | 76 | +		b, resolveBaseErr := gitResolveBaseRef(ctx, opts.RepoRoot, head) | 
|  | 77 | +		if resolveBaseErr == nil && b != "" { | 
|  | 78 | +			base = b | 
|  | 79 | +		} else { | 
|  | 80 | +			base = head + "~1" // fallback | 
|  | 81 | +		} | 
|  | 82 | +	} | 
|  | 83 | + | 
|  | 84 | +	// Diff changed files between base and head. | 
|  | 85 | +	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | 
|  | 86 | +	defer cancel() | 
|  | 87 | + | 
|  | 88 | +	out, baseDiffErr := gitDiffNames(ctx, opts.RepoRoot, base, head) | 
|  | 89 | +	if baseDiffErr != nil { | 
|  | 90 | +		// fallback to diff head~1. head | 
|  | 91 | +		out, headDiffErr = gitDiffNames(ctx, opts.RepoRoot, head+"~1", head) | 
|  | 92 | +		if headDiffErr != nil { | 
|  | 93 | +			return true, "diff failed; default to run", fmt.Errorf("git diff failed: %w", headDiffErr) | 
|  | 94 | +		} | 
|  | 95 | +	} | 
|  | 96 | + | 
|  | 97 | +	return decide(parseChangedFiles(string(out)), opts) | 
|  | 98 | +} | 
|  | 99 | + | 
|  | 100 | +func validateAndNormalizeOpts(opts *Options) { | 
|  | 101 | +	if opts.RepoRoot == "" { | 
|  | 102 | +		opts.RepoRoot = "." | 
|  | 103 | +	} | 
|  | 104 | +	if opts.BaseEnvVar == "" { | 
|  | 105 | +		opts.BaseEnvVar = "PULL_BASE_SHA" | 
|  | 106 | +	} | 
|  | 107 | +	if opts.HeadEnvVar == "" { | 
|  | 108 | +		opts.HeadEnvVar = "PULL_PULL_SHA" | 
|  | 109 | +	} | 
|  | 110 | +	if opts.ChangedFilesEnvVar == "" { | 
|  | 111 | +		opts.ChangedFilesEnvVar = "KUBEBUILDER_CHANGED_FILES" | 
|  | 112 | +	} | 
|  | 113 | +} | 
|  | 114 | + | 
|  | 115 | +func logWarning(msg string) { | 
|  | 116 | +	_, err := fmt.Fprintf(os.Stderr, "WARNING: %s\n", msg) | 
|  | 117 | +	if err != nil { | 
|  | 118 | +		return | 
|  | 119 | +	} | 
|  | 120 | +} | 
|  | 121 | + | 
|  | 122 | +// parseChangedFiles splits raw changed file data into normalized paths. | 
|  | 123 | +func parseChangedFiles(raw string) changedFiles { | 
|  | 124 | +	lines := strings.Split(strings.TrimSpace(raw), "\n") | 
|  | 125 | +	files := make([]string, 0, len(lines)) | 
|  | 126 | +	for _, line := range lines { | 
|  | 127 | +		line = strings.TrimSpace(line) | 
|  | 128 | +		if line != "" { | 
|  | 129 | +			files = append(files, filepath.ToSlash(line)) | 
|  | 130 | +		} | 
|  | 131 | +	} | 
|  | 132 | +	return changedFiles{files: files} | 
|  | 133 | +} | 
|  | 134 | + | 
|  | 135 | +// decide determines if the suite should run based on changed files and options. | 
|  | 136 | +func decide(ch changedFiles, opts Options) (bool, string, error) { | 
|  | 137 | +	if len(ch.files) == 0 { | 
|  | 138 | +		return true, "no changes detected; running tests", nil | 
|  | 139 | +	} | 
|  | 140 | + | 
|  | 141 | +	if opts.SkipIfOnlyDocsYAML && onlyDocsOrYAML(ch.files) { | 
|  | 142 | +		return false, "only documentation or YAML files changed; skipping tests", nil | 
|  | 143 | +	} | 
|  | 144 | + | 
|  | 145 | +	if len(opts.Includes) == 0 { | 
|  | 146 | +		return true, "no include filters specified; running tests", nil | 
|  | 147 | +	} | 
|  | 148 | + | 
|  | 149 | +	if opts.IncludeIsRegex { | 
|  | 150 | +		pattern := "^(" + strings.Join(opts.Includes, "|") + ")" | 
|  | 151 | +		re, err := regexp.Compile(pattern) | 
|  | 152 | +		if err != nil { | 
|  | 153 | +			return false, "invalid include regex pattern", fmt.Errorf("compile regex %q: %w", pattern, err) | 
|  | 154 | +		} | 
|  | 155 | + | 
|  | 156 | +		for _, file := range ch.files { | 
|  | 157 | +			if re.MatchString(file) { | 
|  | 158 | +				return true, "matched include regex pattern: " + re.String(), nil | 
|  | 159 | +			} | 
|  | 160 | +		} | 
|  | 161 | +		return false, "no files matched include regex patterns", nil | 
|  | 162 | +	} | 
|  | 163 | + | 
|  | 164 | +	for _, file := range ch.files { | 
|  | 165 | +		for _, include := range opts.Includes { | 
|  | 166 | +			if strings.HasPrefix(file, filepath.ToSlash(include)) { | 
|  | 167 | +				return true, "matched include prefix: " + include, nil | 
|  | 168 | +			} | 
|  | 169 | +		} | 
|  | 170 | +	} | 
|  | 171 | + | 
|  | 172 | +	return false, "no files matched include prefixes", nil | 
|  | 173 | +} | 
|  | 174 | + | 
|  | 175 | +func onlyDocsOrYAML(files []string) bool { | 
|  | 176 | +	pattern := `(?i)(^docs/|\.md$|\.markdown$|^\.github/|` + | 
|  | 177 | +		`(OWNERS|OWNERS_ALIASES|SECURITY_CONTACTS|LICENSE)(\.md)?$|\.ya?ml$)` | 
|  | 178 | +	re := regexp.MustCompile(pattern) | 
|  | 179 | +	for _, file := range files { | 
|  | 180 | +		if !re.MatchString(file) { | 
|  | 181 | +			return false | 
|  | 182 | +		} | 
|  | 183 | +	} | 
|  | 184 | +	return true | 
|  | 185 | +} | 
|  | 186 | + | 
|  | 187 | +// gitFetchOriginMaster runs `git fetch origin master --quiet`. | 
|  | 188 | +func gitFetchOriginMaster(ctx context.Context, repoRoot string) error { | 
|  | 189 | +	cmd := exec.CommandContext(ctx, "git", "fetch", "origin", "master", "--quiet") | 
|  | 190 | +	cmd.Dir = repoRoot | 
|  | 191 | +	if originFetchErr := cmd.Run(); originFetchErr != nil { | 
|  | 192 | +		return fmt.Errorf("git fetch origin master failed: %w", originFetchErr) | 
|  | 193 | +	} | 
|  | 194 | +	return nil | 
|  | 195 | +} | 
|  | 196 | + | 
|  | 197 | +// gitResolveBaseRef returns the merge-base commit SHA of head and origin/master. | 
|  | 198 | +func gitResolveBaseRef(ctx context.Context, repoRoot, head string) (string, error) { | 
|  | 199 | +	cmd := exec.CommandContext(ctx, "git", "rev-parse", "--verify", "--quiet", "origin/master") | 
|  | 200 | +	cmd.Dir = repoRoot | 
|  | 201 | +	out, err := cmd.CombinedOutput() | 
|  | 202 | +	if err != nil || len(bytes.TrimSpace(out)) == 0 { | 
|  | 203 | +		return "", errors.New("origin/master ref not found") | 
|  | 204 | +	} | 
|  | 205 | + | 
|  | 206 | +	mergeBaseCmd := exec.CommandContext(ctx, "git", "merge-base", head, "origin/master") | 
|  | 207 | +	mergeBaseCmd.Dir = repoRoot | 
|  | 208 | +	mbOut, err := mergeBaseCmd.Output() | 
|  | 209 | +	if err != nil { | 
|  | 210 | +		return "", fmt.Errorf("git merge-base failed: %w", err) | 
|  | 211 | +	} | 
|  | 212 | + | 
|  | 213 | +	return strings.TrimSpace(string(mbOut)), nil | 
|  | 214 | +} | 
|  | 215 | + | 
|  | 216 | +// gitDiffNames returns the list of changed files between base and head commits. | 
|  | 217 | +func gitDiffNames(ctx context.Context, repoRoot, base, head string) ([]byte, error) { | 
|  | 218 | +	cmd := exec.CommandContext(ctx, "git", "diff", "--name-only", base, head) | 
|  | 219 | +	cmd.Dir = repoRoot | 
|  | 220 | +	out, outErr := cmd.Output() | 
|  | 221 | +	if outErr != nil { | 
|  | 222 | +		return nil, fmt.Errorf("git diff failed: %w", outErr) | 
|  | 223 | +	} | 
|  | 224 | +	return out, nil | 
|  | 225 | +} | 
0 commit comments