experiments/conductor/cmd/runner/utilities.go (798 lines of code) (raw):

// Copyright 2025 Google LLC // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package runner import ( "bytes" "context" "errors" "fmt" "io" "log" "os" "os/exec" "path/filepath" "reflect" "runtime" "sort" "strings" "time" "gopkg.in/yaml.v3" ) const ( GenerativeCommandRetryBackoff = 60 * time.Second ) var commandMap = map[int64]string{ cmdEnableGCPAPIs: "enablegcpapis", cmdCreateScriptYaml: "createscriptyaml", cmdCaptureHttpLog: "capturehttplog", cmdGenerateMockGo: "generatemockgo", cmdAddServiceRoundTrip: "addserviceroundtrip", cmdAddProtoMakefile: "addprotomakefile", cmdCaptureMockOutput: "capturemockoutput", cmdRunAndFixMockTests: "runandfixmocktests", cmdGenerateTypes: "generatetypes", cmdGenerateCRD: "generatecrd", cmdGenerateFuzzer: "generatefuzzer", cmdBuildProto: "buildproto", cmdAdjustTypes: "adjusttypes", cmdGenerateMapper: "generatemapper", cmdRunAndFixFuzzTests: "runandfixfuzztests", cmdRunAndFixAPIChecks: "runandfixapichecks", cmdControllerClient: "controllerclient", cmdGenerateController: "generatecontroller", cmdBuildAndFixController: "buildandfixcontroller", cmdCreateIdentity: "createidentity", cmdControllerCreateTest: "controllercreatetest", cmdCaptureGoldenRealGCPOutput: "capturegoldenrealgcpoutput", cmdRunAndFixGoldenRealGCPOutput: "runandfixgoldenrealgcpoutput", cmdCaptureGoldenMockOutput: "capturegoldenmockoutput", cmdRunAndFixGoldenMockOutput: "runandfixgoldenmockoutput", } type exitBash func() func startBash() (io.WriteCloser, io.ReadCloser, exitBash, error) { cmd := exec.Command("bash") stdin, err := cmd.StdinPipe() if err != nil { log.Fatal(err) } stdout, err := cmd.StdoutPipe() if err != nil { log.Fatal(err) } stderr, err := cmd.StderrPipe() if err != nil { log.Fatal(err) } go func() { errBuffer := make([]byte, 1000) _, err = stderr.Read(errBuffer) if err != nil { log.Fatal(err) } log.Printf("BASH ERR %s", string(errBuffer)) }() err = cmd.Start() if err != nil { log.Fatal(err) } exit := func() { log.Printf("COMMAND: exit") if _, err = stdin.Write([]byte("exit\n")); err != nil { log.Fatal(err) } err := cmd.Wait() if err != nil { log.Fatal(err) } log.Printf("BASH DONE") } return stdin, stdout, exit, err } func cdRepoBranchDirBash(opts *RunnerOptions, subdir string, stdin io.WriteCloser, stdout io.ReadCloser) string { dir := opts.branchRepoDir if subdir != "" { dir = filepath.Join(dir, subdir) } log.Printf("COMMAND: cd %s and echo", dir) if _, err := stdin.Write([]byte(fmt.Sprintf("cd %s && echo done\n", dir))); err != nil { log.Fatal(err) } outBuffer := make([]byte, 1000) done := false var msg string for !done { length, err := stdout.Read(outBuffer) if err != nil { log.Fatal(err) } msg += string(outBuffer[:length]) done = strings.HasSuffix(msg, "done\n") } log.Printf("CD OUT %s", msg) return msg } func checkoutBranch(ctx context.Context, branch Branch, workDir string) { checkout := exec.CommandContext(ctx, "git", "checkout", branch.Local) checkout.Dir = workDir results, err := execCommand(checkout) if err != nil { log.Fatal(err) } log.Printf("BRANCH CHECKOUT: %v\n", formatCommandOutput(results.Stdout)) } type ExecResults struct { Stdout string Stderr string ExitCode int } func execCommand(cmd *exec.Cmd) (ExecResults, error) { var stdout strings.Builder var stderr strings.Builder cmd.Stdout = &stdout cmd.Stderr = &stderr log.Printf("COMMAND: %s", cmd.String()) err := cmd.Run() if err != nil { // var exitErr *exec.ExitError // if errors.As(err, &exitErr) { // return ExecResults{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: exitErr.ExitCode()}, nil // } log.Printf("error running command %v: %v", cmd.String(), err) log.Printf("stdout: %s", stdout.String()) log.Printf("stderr: %s", stderr.String()) return ExecResults{}, err } return ExecResults{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: cmd.ProcessState.ExitCode()}, nil } func writeTemplateToFile(branch Branch, filePath string, template string) { tmp := strings.ReplaceAll(template, "<TICK>", "`") tmp = strings.ReplaceAll(tmp, "<GCLOUD_COMMAND>", branch.Command) tmp = strings.ReplaceAll(tmp, "<GROUP>", branch.Group) tmp = strings.ReplaceAll(tmp, "<SERVICE>", branch.Group) tmp = strings.ReplaceAll(tmp, "<HTTP_HOST>", branch.HostName) tmp = strings.ReplaceAll(tmp, "<PROTO_SERVICE>", branch.ProtoSvc) tmp = strings.ReplaceAll(tmp, "<PROTO_MESSAGE>", branch.ProtoMsg) tmp = strings.ReplaceAll(tmp, "<PROTO_PACKAGE>", branch.Package) tmp = strings.ReplaceAll(tmp, "<CRD_GROUP>", fmt.Sprintf("%s.cnrm.cloud.google.com", branch.Group)) tmp = strings.ReplaceAll(tmp, "<CRD_VERSION>", "v1alpha1") tmp = strings.ReplaceAll(tmp, "<CRD_KIND>", branch.Kind) tmp = strings.ReplaceAll(tmp, "<PROTO_RESOURCE>", branch.Proto) contents := strings.ReplaceAll(tmp, "<RESOURCE>", strings.ToLower(branch.Resource)) log.Printf("TEMPLATE %s %s", filePath, contents) if _, err := os.Stat(filePath); !errors.Is(err, os.ErrNotExist) { log.Printf("COMMAND: cleaning up old %s", filePath) err = os.Remove(filePath) if err != nil { log.Printf("Attempt to clean up %s failed with %v", filePath, err) } } log.Printf("COMMAND: writing new %s", filePath) if err := os.WriteFile(filePath, []byte(contents), 0644); err != nil { log.Fatal(err) } } func gitAdd(ctx context.Context, workDir string, files ...string) error { args := []string{"git", "add"} for _, file := range files { args = append(args, file) } gitadd := exec.CommandContext(ctx, args[0], args[1:]...) gitadd.Dir = workDir results, err := execCommand(gitadd) if err != nil { return fmt.Errorf("git add failed: %w", err) } log.Printf("BRANCH ADD: %v\n", formatCommandOutput(results.Stdout)) return nil } func gitCommit(ctx context.Context, workDir string, msg string) error { authorName := "kcc-conductor-bot" authorEmail := "kcc-conductor-bot@google.com" authorFlag := fmt.Sprintf("%s <%s>", authorName, authorEmail) log.Printf("COMMAND: git commit -m %q --author=%q", msg, authorFlag) gitcommit := exec.CommandContext(ctx, "git", "commit", "-m", msg, "--author", authorFlag) gitcommit.Dir = workDir results, err := execCommand(gitcommit) if err != nil { return fmt.Errorf("git commit failed: %w", err) } log.Printf("BRANCH COMMIT: %v\n", formatCommandOutput(results.Stdout)) return nil } func gitStatusCheck(workDir string, filePath string) (bool, error) { log.Printf("COMMAND: git status -- %s", filePath) args := []string{"status", "-s", filePath} gitstatus := exec.Command("git", args...) gitstatus.Dir = workDir var out bytes.Buffer gitstatus.Stdout = &out if err := gitstatus.Run(); err != nil { log.Printf("Git status on file %s/%s error: %q\n", workDir, filePath, err) return false, err } log.Printf("Git status on file %s/%s: %s", workDir, filePath, out.String()) return len(strings.TrimSpace(out.String())) > 0, nil } func gitFileHasChange(workDir string, filePath string) bool { log.Printf("COMMAND: git diff -- %s", filePath) args := []string{"diff", "--", filePath} gitdiff := exec.Command("git", args...) gitdiff.Dir = workDir var out bytes.Buffer gitdiff.Stdout = &out if err := gitdiff.Run(); err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) && exitErr.ExitCode() == 1 { // Exit code 1 means no changes, not an error return false } log.Printf("Git diff on file %s/%s error: %q\n", workDir, filePath, err) return false } return len(strings.TrimSpace(out.String())) > 0 } func gitRevert(ctx context.Context, workDir string, filePath string) error { log.Printf("COMMAND: git checkout -- %s", filePath) args := []string{"checkout", "--", filePath} gitcheckout := exec.CommandContext(ctx, "git", args...) gitcheckout.Dir = workDir results, err := execCommand(gitcheckout) if err != nil { log.Printf("Git checkout on file %s/%s error: %v", workDir, filePath, err) return err } if results.Stdout != "" { log.Printf("Git checkout output: %s", formatCommandOutput(results.Stdout)) } return nil } type closer func() func setLoggingWriter(opts *RunnerOptions, branch Branch) closer { // Initially force out to stdout in case we hit an error we don't // want to pollute a different runs logs with our logs. // TODO: Return a log object so we can run in parrellel. log.SetOutput(os.Stdout) if opts.loggingDir == "" { log.Println("Logging dir not set") return noOp } logDir := filepath.Join(opts.loggingDir, branch.Name) log.Printf("Logging dir: %s", logDir) if _, err := os.Stat(logDir); os.IsNotExist(err) { err = os.MkdirAll(logDir, 0755) if err != nil { log.Printf("Error creating logging dir %s, :%v", logDir, err) return noOp } } var out *os.File var err error logFileName := "output.log" commandNumber := opts.command commandMessage, ok := commandMap[commandNumber] if ok { logFileName = fmt.Sprintf("%v_%v.log", commandNumber, commandMessage) } logFile := filepath.Join(logDir, logFileName) if out, err = os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0755); err != nil { log.Printf("Error opening logging file %s, :%v", logFile, err) return noOp } log.Printf("Logging to %s", logFile) if opts.verbose { // In verbose mode, write to both stderr and the log file log.SetOutput(io.MultiWriter(os.Stderr, out)) } else { // In non-verbose mode, write only to the log file log.SetOutput(out) } return func() { // Initially force out to stdout in case we hit an error we don't // want to pollute a different runs logs with our logs. log.SetOutput(os.Stdout) if err := out.Close(); err != nil { log.Printf("Failed to close logging file %s, :%v", logFile, err) } } } func noOp() { } func printCommandOutput(output string) { // Replace escaped newlines and tabs with their actual characters formatted := strings.ReplaceAll(output, "\\n", "\n") formatted = strings.ReplaceAll(formatted, "\\t", "\t") // Split the string into lines and format each line lines := strings.Split(formatted, "\n") for _, line := range lines { log.Printf(" > %s\n", line) } } func formatCommandOutput(output string) string { // Replace escaped newlines and tabs with their actual characters formatted := strings.ReplaceAll(output, "\\n", "\n") formatted = strings.ReplaceAll(formatted, "\\t", "\t") return formatted } // CommandConfig holds the configuration for executing a command type CommandConfig struct { Name string // Name of the command for logging Cmd string // The command to run Args []string // Command arguments WorkDir string // Working directory Stdin io.Reader // Optional stdin Env map[string]string // Optional environment variables Timeout time.Duration // Timeout duration (default 5m) MaxAttempts int // Maximum number of attempts allowed for this command to run RetryBackoff time.Duration // Time to wait between retries (default 1s) } // Helper function to execute a command with timing and logging func executeCommand(opts *RunnerOptions, cfg CommandConfig) (ExecResults, error) { if cfg.Timeout == 0 { if opts != nil && opts.timeout != 0 { cfg.Timeout = opts.timeout } else { cfg.Timeout = 5 * time.Minute } } if cfg.RetryBackoff == 0 { cfg.RetryBackoff = time.Second } maxRetries := opts.defaultRetries // If MaxRetries is set in config, cap it at that if cfg.MaxAttempts > 0 && cfg.MaxAttempts < maxRetries { maxRetries = cfg.MaxAttempts } var lastErr error var output, errOutput string var exitCode int retryCount := 1 for { log.Printf("Starting command step: %s (attempt %d/%d)", cfg.Name, retryCount, maxRetries+1) log.Printf("[%s] working directory: %s", cfg.Name, cfg.WorkDir) log.Printf("[%s] command: %s %s", cfg.Name, cfg.Cmd, strings.Join(cfg.Args, " ")) if cfg.Stdin != nil { log.Printf("[%s] stdin: %s", cfg.Name, cfg.Stdin) } if len(cfg.Env) > 0 { log.Printf("[%s] environment:", cfg.Name) for k, v := range cfg.Env { log.Printf(" %s=%s", k, v) } } ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout) cmd := exec.CommandContext(ctx, cfg.Cmd, cfg.Args...) cmd.Dir = cfg.WorkDir var outBuf, errBuf strings.Builder cmd.Stdout = &outBuf cmd.Stderr = &errBuf if opts.verbose { cmd.Stdout = io.MultiWriter(os.Stdout, cmd.Stdout) cmd.Stderr = io.MultiWriter(os.Stderr, cmd.Stderr) } if cfg.Stdin != nil { cmd.Stdin = cfg.Stdin } // Set up environment variables if len(cfg.Env) > 0 { cmd.Env = os.Environ() // Start with current environment for k, v := range cfg.Env { cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v)) } } start := time.Now() err := cmd.Run() stop := time.Now() diff := stop.Sub(start) cancel() output = outBuf.String() errOutput = errBuf.String() exitCode = cmd.ProcessState.ExitCode() if err == nil { log.Printf("[%s] SUCCESS (%v)", cfg.Name, diff) printCommandOutput(output) if errBuf.Len() > 0 { log.Printf("[%s] stderr output:", cfg.Name) printCommandOutput(errOutput) } return ExecResults{Stdout: output, Stderr: errOutput, ExitCode: exitCode}, nil } lastErr = err log.Printf("[%s] ERROR (%v): %v", cfg.Name, diff, err) printCommandOutput(output) if errBuf.Len() > 0 { log.Printf("[%s] stderr output:", cfg.Name) printCommandOutput(errOutput) } retryCount++ if maxRetries >= 0 && retryCount > maxRetries { log.Printf("[%s] Exceeded maximum retries (%d), giving up", cfg.Name, maxRetries) break } log.Printf("[%s] Retrying in %v...", cfg.Name, cfg.RetryBackoff) time.Sleep(cfg.RetryBackoff) } return ExecResults{Stdout: output, Stderr: errOutput, ExitCode: exitCode}, fmt.Errorf("command failed after %d attempts: %w", maxRetries, lastErr) } func runLinters(opts *RunnerOptions) error { log.Printf("Running linters") cfg := CommandConfig{ Name: "Go Fmt", Cmd: "go", Args: []string{"fmt", "./..."}, WorkDir: opts.branchRepoDir, MaxAttempts: 1, } op, err := executeCommand(opts, cfg) if err != nil { return fmt.Errorf("Error running go fmt: %w", err) } if op.ExitCode != 0 { return fmt.Errorf("linting failed with exit code %d", op.ExitCode) } return nil } func checkMakeReadyPR(opts *RunnerOptions) error { log.Printf("Running make ready-pr verification") cfg := CommandConfig{ Name: "Make Ready-PR", Cmd: "make", Args: []string{"ready-pr"}, WorkDir: opts.branchRepoDir, MaxAttempts: 1, } if _, err := executeCommand(opts, cfg); err != nil { return fmt.Errorf("make ready-pr failed: %w", err) } return nil } func stageChanges(ctx context.Context, opts *RunnerOptions, paths []string, commitMsg string) (bool, error) { // First check for changes in specified paths changesCommitted := false var changedFiles []string for _, path := range paths { log.Printf("Checking for changes in %s", path) changed, err := gitStatusCheck(opts.branchRepoDir, path) if err != nil { return changesCommitted, fmt.Errorf("git status failed for %s: %w", path, err) } if changed { changedFiles = append(changedFiles, path) } } // If we have changes in specified paths, add and commit them if len(changedFiles) > 0 { for _, file := range changedFiles { if err := gitAdd(ctx, opts.branchRepoDir, file); err != nil { log.Printf("[ERROR GIT ADD]failed to stage %s: %v", file, err) // best effort to continue } } if err := gitCommit(ctx, opts.branchRepoDir, commitMsg); err != nil { log.Printf("failed to commit changes: %v", err) } changesCommitted = true } // Check for any other uncommitted changes changed, err := gitStatusCheck(opts.branchRepoDir, ".") if err != nil { return changesCommitted, fmt.Errorf("git status failed: %w", err) } if changed { log.Printf("WARNING: Found unexpected changes. Trying to stage and commit them") if err := gitAdd(ctx, opts.branchRepoDir, "."); err != nil { return changesCommitted, fmt.Errorf("failed to stage extra files: %w", err) } if err := gitCommit(ctx, opts.branchRepoDir, fmt.Sprintf("%s. WARN: extra files found, committing them. ", commitMsg)); err != nil { return changesCommitted, fmt.Errorf("failed to commit extra files: %w", err) } changesCommitted = true log.Printf("WARNING: Committed unexpected changes") } return changesCommitted, nil } type BranchProcessorFn func(ctx context.Context, opts *RunnerOptions, branch Branch, execResults *ExecResults) ([]string, *ExecResults, error) type BranchProcessor struct { CommitMsgTemplate string Fn BranchProcessorFn AttemptsOnNoChange int // Number of attempts to run the processor if no changes are detected VerifyFn BranchProcessorFn VerifyAttempts int AttemptsOnChanges int // Number of attempts to run the processor if changes are detected } func (b *BranchProcessor) CommitMsg(branch Branch) string { s := b.CommitMsgTemplate s = strings.ReplaceAll(s, "{{command}}", branch.Command) s = strings.ReplaceAll(s, "{{group}}", branch.Group) s = strings.ReplaceAll(s, "{{resource}}", branch.Resource) s = strings.ReplaceAll(s, "{{kind}}", branch.Kind) return s } // runBranchFnWithRetries runs the processor multiple times until changes are detected and commits them func runBranchFnWithRetriesAndCommit(ctx context.Context, opts *RunnerOptions, branch Branch, description string, processor BranchProcessor, commitMsg string, inExecResults *ExecResults) (bool, *ExecResults, error) { // Try running processor up to N times until we see changes in all affected paths var execResults *ExecResults changesCommitted := false if commitMsg == "" { commitMsg = processor.CommitMsg(branch) } maxAttempts := processor.AttemptsOnNoChange if maxAttempts == 0 { maxAttempts = 3 } attempt := 0 var affectedPaths []string lintFailure := false goCmdsFailure := false for attempt < maxAttempts { var err error attempt++ // Process the branch log.Printf("Running BranchFn for branch %s: %s, processor: %s (attempt: %d/%d)", branch.Name, description, commitMsg, attempt, maxAttempts) affectedPaths, execResults, err = processor.Fn(ctx, opts, branch, inExecResults) if err != nil { log.Printf("failed to process branch %s on attempt %d: %v", branch.Name, attempt, err) continue } // Check if any files changed someChanged := false for _, path := range affectedPaths { changed, err := gitStatusCheck(opts.branchRepoDir, path) if err != nil { log.Printf("failed to check status of %s: %v", path, err) continue } if changed { someChanged = true break } } // If commitMsg is not set, we don't need to commit the changes // we assume there are no changes to commit if commitMsg == "" { return changesCommitted, execResults, nil } lintFailure = false goCmdsFailure = false // Run go mod tidy if err := runGoCmds(opts, affectedPaths); err != nil { log.Printf("go cmds failed for branch %s: %v", branch.Name, err) goCmdsFailure = true } // Run basic linting if err := runLinters(opts); err != nil { log.Printf("linting failed for branch %s: %v", branch.Name, err) lintFailure = true } // Exit loop if we saw changes or hit max attempts if someChanged || attempt >= maxAttempts { break } log.Printf("No changes detected in attempt %d, retrying...", attempt) time.Sleep(10 * time.Second) } //if err := checkMakeReadyPR(opts); err != nil { // log.Printf("verification failed for branch %s: %v", branch.Name, err) //} // Stage and commit changes if lintFailure { commitMsg = fmt.Sprintf("%s. WARN: linting failed.", commitMsg) } if goCmdsFailure { commitMsg = fmt.Sprintf("%s. WARN: go cmds failed.", commitMsg) } var err error changesCommitted, err = stageChanges(ctx, opts, affectedPaths, commitMsg) if err != nil { return changesCommitted, execResults, fmt.Errorf("failed to commit changes for branch %s: %w", branch.Name, err) } return changesCommitted, execResults, nil } // processBranch handles the processing of a single branch func processBranch(ctx context.Context, opts *RunnerOptions, branch Branch, description string, processor BranchProcessor) error { log.Printf("Processing branch %s: %s, processor: %s", branch.Name, description, processor.CommitMsg(branch)) // Skip if branch should be skipped if branch.Skip { log.Printf("Skipping branch %s: marked as Skip", branch.Name) return nil } close := setLoggingWriter(opts, branch) defer close() checkoutBranch(ctx, branch, opts.branchRepoDir) if processor.VerifyFn == nil || processor.VerifyAttempts == 0 { attempts := processor.AttemptsOnChanges if attempts == 0 { attempts = 1 } // Run atmost attempts times if we see changes. This is for cases like make ready-pr // where we want to run it multiple times if we see changes. for i := 0; i < attempts; i++ { changesCommitted, _, err := runBranchFnWithRetriesAndCommit(ctx, opts, branch, description, processor, "", nil) if err != nil { return err } if !changesCommitted { break } if attempts > 1 { log.Printf("Changes committed. Running again: %d/%d", i+1, attempts) } } return nil } // If processor has a VerifyFn and max attempts, run verification loop for i := 0; i < processor.VerifyAttempts; i++ { var err error paths, execResults, err := processor.VerifyFn(ctx, opts, branch, nil) if execResults == nil { log.Printf("execResults is nil for branch %s, commit message: %s", branch.Name, processor.CommitMsg(branch)) continue } if execResults.ExitCode == 0 { log.Printf("Verification attempt %d succeeded for branch %s, commit message: %s", i+1, branch.Name, processor.CommitMsg(branch)) break } if err != nil { log.Printf("Verification attempt %d Error: %v for branch %s, commit message: %s", i+1, err, branch.Name, processor.CommitMsg(branch)) } else { log.Printf("Verification attempt %d failed for branch %s, commit message: %s", i+1, branch.Name, processor.CommitMsg(branch)) } if len(paths) != 0 { // Revert the changes for the failed verification for _, path := range paths { if err := gitRevert(ctx, opts.branchRepoDir, path); err != nil { log.Printf("Failed to revert changes for branch %s: %v", branch.Name, err) continue } } } log.Printf("Verification attempt %d failed for branch %s, commit message: %s", i+1, branch.Name, processor.CommitMsg(branch)) commitMsg := fmt.Sprintf("Autofix attempt %d. %s", i+1, processor.CommitMsg(branch)) _, _, err = runBranchFnWithRetriesAndCommit(ctx, opts, branch, description, processor, commitMsg, execResults) if err != nil { log.Printf("Failed to run branch %s: %v", branch.Name, err) continue } } return nil } // processBranches applies the given processors to each branch func processBranches(ctx context.Context, opts *RunnerOptions, branches []Branch, description string, processors []BranchProcessor) { // If processors filter is provided, filter the processors if opts.processors != "" { selectedProcessors := strings.Split(opts.processors, ",") // Create a map for faster lookup processorSet := make(map[string]bool) for _, p := range selectedProcessors { processorSet[strings.TrimSpace(p)] = true } // Filter processors var filteredProcessors []BranchProcessor for _, processor := range processors { // Get the function name using reflection fnName := getFunctionName(processor.Fn) if processorSet[fnName] { filteredProcessors = append(filteredProcessors, processor) log.Printf("Including processor: %s", fnName) } else { log.Printf("Skipping processor: %s (not in --processors list)", fnName) } } // If no processors matched, log a warning if len(filteredProcessors) == 0 { log.Printf("WARNING: No processors matched the filter: %s", opts.processors) log.Printf("Available processors: %s", getAllProcessorNames(processors)) return } processors = filteredProcessors } for _, branch := range branches { for _, processor := range processors { err := processBranch(ctx, opts, branch, description, processor) if err != nil { log.Printf("Error processing branch %s: %v", branch.Name, err) break } } } } // getFunctionName returns the name of a function func getFunctionName(fn BranchProcessorFn) string { // Get the function value using reflection fnValue := reflect.ValueOf(fn) // Get the function pointer fnPointer := fnValue.Pointer() // Get the full function name including package path fullName := runtime.FuncForPC(fnPointer).Name() // Extract just the function name without package path parts := strings.Split(fullName, ".") return parts[len(parts)-1] } // getAllProcessorNames returns a comma-separated list of all processor function names func getAllProcessorNames(processors []BranchProcessor) string { var names []string for _, processor := range processors { names = append(names, getFunctionName(processor.Fn)) } return strings.Join(names, ", ") } func runGoCmds(opts *RunnerOptions, affectedPaths []string) error { log.Printf("Running go mod tidy") errStrings := []string{} // Filter for paths containing Go files goFilePaths := filterGoFilePaths(opts.branchRepoDir, affectedPaths) // Only run goimports if we found Go files if len(goFilePaths) > 0 { for _, path := range goFilePaths { cfg := CommandConfig{ Name: "Go Imports", Cmd: "goimports", Args: []string{"-w", path}, WorkDir: opts.branchRepoDir, MaxAttempts: 1, } op, err := executeCommand(opts, cfg) if err != nil { errStrings = append(errStrings, fmt.Sprintf("Error running goimports for %s: %v", path, err)) } if op.ExitCode != 0 { errStrings = append(errStrings, fmt.Sprintf("goimports failed with exit code %d for %s", op.ExitCode, path)) } } } else { log.Printf("No Go files found in affected paths, skipping goimports") } // Always run go mod tidy regardless of file types cfg := CommandConfig{ Name: "Go Mod Tidy", Cmd: "go", Args: []string{"mod", "tidy"}, WorkDir: opts.branchRepoDir, MaxAttempts: 1, } op, err := executeCommand(opts, cfg) if err != nil { errStrings = append(errStrings, fmt.Sprintf("Error running go mod tidy: %v", err)) } if op.ExitCode != 0 { errStrings = append(errStrings, fmt.Sprintf("go mod tidy failed with exit code %d", op.ExitCode)) } if len(errStrings) > 0 { log.Printf("Error running go cmds: %s", strings.Join(errStrings, "\n")) return fmt.Errorf("Error running go cmds: %s", strings.Join(errStrings, "\n")) } return nil } // hasGoFiles checks if the given path (file or directory) contains any Go files // If path is a file, it checks if it's a Go file // If path is a directory, it walks the directory to find any Go files func hasGoFiles(basePath, relativePath string) bool { fullPath := filepath.Join(basePath, relativePath) info, err := os.Stat(fullPath) if err != nil { log.Printf("Warning: Could not stat path %s: %v", relativePath, err) return false } // If it's a file, check if it's a Go file if !info.IsDir() { return strings.HasSuffix(strings.ToLower(relativePath), ".go") } // If it's a directory, walk it to find Go files foundGoFile := false err = filepath.Walk(fullPath, func(walkPath string, info os.FileInfo, err error) error { if err != nil { return err } if !info.IsDir() && strings.HasSuffix(strings.ToLower(walkPath), ".go") { foundGoFile = true return filepath.SkipAll // Stop walking once we find a Go file } return nil }) if err != nil { log.Printf("Warning: Error walking directory %s: %v", relativePath, err) return false } return foundGoFile } // filterGoFilePaths returns a slice containing only paths that have Go files // Handles both absolute and relative paths func filterGoFilePaths(repoRootPath string, inputPaths []string) []string { var goFilePaths []string for _, path := range inputPaths { // Check if this is an absolute path within our repository var checkPath string if filepath.IsAbs(path) && strings.HasPrefix(path, repoRootPath) { // It's an absolute path within our repo, convert to relative for checking relPath, err := filepath.Rel(repoRootPath, path) if err != nil { log.Printf("Warning: Could not convert absolute path %s to relative: %v", path, err) continue } checkPath = relPath } else if filepath.IsAbs(path) { // It's an absolute path outside our repo, skip it log.Printf("Warning: Path %s is outside repository, skipping", path) continue } else { // It's already a relative path checkPath = path } // Now check if this path contains Go files if hasGoFiles(repoRootPath, checkPath) { goFilePaths = append(goFilePaths, path) // Keep the original path format } } return goFilePaths } const COPYRIGHT_HEADER string = `# Copyright 2025 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. ` type branchAscending []Branch func (v branchAscending) Len() int { return len(v) } func (v branchAscending) Swap(i, j int) { v[i], v[j] = v[j], v[i] } func (v branchAscending) Less(i, j int) bool { if v[i].Group != v[j].Group { return v[i].Group < v[j].Group } if v[i].Kind != v[j].Kind { return v[i].Kind < v[j].Kind } return v[i].Name < v[j].Name } func writeBranchesStableOrder(branches Branches, fileName string) { sort.Sort(branchAscending(branches.Branches)) data := []byte(COPYRIGHT_HEADER) yamlData, err := yaml.Marshal(branches) if err != nil { log.Fatal(err) } data = append(data, yamlData...) err = os.WriteFile(fileName, data, 0644) if err != nil { log.Fatal(err) } }