From 653a9fbafa1353c5ad4663928e19db8f2af2870a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:53:39 +0000 Subject: [PATCH 1/4] Initial plan From 7f21ff7e0f55b914cd302ce8566fed1673d22bf2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:46:17 +0000 Subject: [PATCH 2/4] Refactor: extract helpers to reduce function-length lint findings in pkg/workflow and pkg/cli Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/logs_command.go | 643 ++++----- pkg/cli/run_workflow_execution.go | 890 +++++++------ pkg/workflow/engine.go | 822 ++++++------ .../frontmatter_on_section_cleanup.go | 1164 ++++++++--------- pkg/workflow/maintenance_workflow_yaml.go | 955 +------------- .../maintenance_workflow_yaml_jobs.go | 717 ++++++++++ pkg/workflow/mcp_config_custom.go | 971 ++++++-------- pkg/workflow/repo_memory.go | 817 +++++------- pkg/workflow/side_repo_maintenance.go | 592 ++++----- 9 files changed, 3462 insertions(+), 4109 deletions(-) create mode 100644 pkg/workflow/maintenance_workflow_yaml_jobs.go diff --git a/pkg/cli/logs_command.go b/pkg/cli/logs_command.go index 6b6143d228c..cc6b2b34c93 100644 --- a/pkg/cli/logs_command.go +++ b/pkg/cli/logs_command.go @@ -26,14 +26,97 @@ import ( var logsCommandLog = logger.New("cli:logs_command") +type logsCommandValues struct { + workflowName string + cacheBefore string + LogsDownloadOptions +} + +const logsCommandExampleTemplate = ` # Basic usage + %[1]s logs # Download logs for all workflows + %[1]s logs weekly-research # Download logs for specific workflow + %[1]s logs weekly-research.md # Download logs (alternative format) + %[1]s logs -c 10 # Download last 10 matching runs + + # Date filtering + %[1]s logs --start-date 2024-01-01 # Download up to 10 runs after date + %[1]s logs --end-date 2024-01-31 # Download up to 10 runs before date + %[1]s logs --start-date -1w # Download up to 10 runs from last week + %[1]s logs --start-date -1w -c 5 # Download up to 5 runs from last week + %[1]s logs --end-date -1d # Download up to 10 runs before yesterday + %[1]s logs --start-date -1mo # Download up to 10 runs from last month + + # Content filtering + %[1]s logs --engine claude # Filter logs by claude engine + %[1]s logs --engine codex # Filter logs by codex engine + %[1]s logs --engine copilot # Filter logs by copilot engine + %[1]s logs --firewall # Filter logs with firewall enabled + %[1]s logs --no-firewall # Filter logs without firewall + %[1]s logs --safe-output missing-tool # Filter logs with missing-tool messages + %[1]s logs --safe-output missing-data # Filter logs with missing-data messages + %[1]s logs --safe-output create-issue # Filter logs with create-issue messages + %[1]s logs --safe-output noop # Filter logs with noop messages + %[1]s logs --safe-output report-incomplete # Filter logs with report-incomplete messages + %[1]s logs --ref main # Filter logs by branch or tag + %[1]s logs --ref feature-xyz # Filter logs by feature branch + %[1]s logs --filtered-integrity # Filter logs containing items that were filtered by gateway integrity checks + %[1]s logs --evals # Filter logs from workflows with evals results + %[1]s logs --exclude-staged # Exclude staged workflow runs from results + + # Run ID range filtering + %[1]s logs --after-run-id 1000 # Filter runs after run ID 1000 + %[1]s logs --before-run-id 2000 # Filter runs before run ID 2000 + %[1]s logs --after-run-id 1000 --before-run-id 2000 # Filter runs in range + + # Artifact selection (default: usage only - the compact conclusion artifact) + %[1]s logs --artifacts all # Download all artifacts (agent logs, firewall, etc.) + %[1]s logs --artifacts agent # Download only agent logs + %[1]s logs --artifacts agent,firewall # Download agent and firewall artifacts + %[1]s logs --artifacts mcp # Download only MCP gateway logs + + # Output options (default output is compact format optimized for agents) + %[1]s logs -o ./my-logs # Custom output directory + %[1]s logs --tool-graph # Generate Mermaid tool sequence graph + %[1]s logs --parse # Parse logs and generate Markdown reports + %[1]s logs -v # Verbose compact output (extra columns + sections) + %[1]s logs --json # JSON format (compact by default, use -v for full) + %[1]s logs --json -v # Full JSON with audit metadata + %[1]s logs --format tsv # Tab-separated (minimal, raw data) + %[1]s logs --format console # Decorated console tables (human-friendly) + %[1]s logs --format markdown # Cross-run security audit report (Markdown) + %[1]s logs --format pretty # Cross-run security audit report (console) + %[1]s logs weekly-research --format markdown --last 10 # Cross-run report for last 10 runs + %[1]s logs --train # Train log pattern weights from last 10 runs + %[1]s logs my-workflow --train -c 50 # Train log pattern weights from up to 50 runs of a specific workflow + + # Cross-repository + %[1]s logs weekly-research --repo owner/repo # Download logs from specific repository + + # Cache maintenance + %[1]s logs --cache-before -1w # Evict local cache older than 1 week before downloading runs + %[1]s logs --cache-before -30d # Evict local cache older than 30 days before downloading runs + %[1]s logs --cache-before -1mo # Evict local cache older than 1 month before downloading runs + %[1]s logs --cache-before 2024-01-01 # Evict local cache older than 2024-01-01 before downloading runs` + // NewLogsCommand creates the logs command func NewLogsCommand() *cobra.Command { validArtifactSets := strings.Join(ValidArtifactSetNames(), ", ") - logsCmd := &cobra.Command{ - Use: "logs [workflow]", - Short: "Download and analyze agentic workflow logs and artifacts", - Long: fmt.Sprintf(`Download and analyze agentic workflow logs and artifacts from GitHub Actions. + Use: "logs [workflow]", + Short: "Download and analyze agentic workflow logs and artifacts", + Long: buildLogsCommandLongDescription(validArtifactSets), + Example: buildLogsCommandExample(), + RunE: func(cmd *cobra.Command, args []string) error { + return runLogsCommand(cmd, args) + }, + } + addLogsCommandFlags(logsCmd, validArtifactSets) + registerLogsCommandCompletions(logsCmd) + return logsCmd +} + +func buildLogsCommandLongDescription(validArtifactSets string) string { + return fmt.Sprintf(`Download and analyze agentic workflow logs and artifacts from GitHub Actions. This command fetches workflow runs, downloads their artifacts, and extracts them into organized folders named by run ID. It also provides an overview table with aggregate @@ -54,318 +137,236 @@ Downloaded artifacts include (when using --artifacts all): - aw-{branch}.patch: Git patch of changes for each branch (one file per PR/push) - workflow-logs/: GitHub Actions workflow run logs (job logs organized in subdirectory) - summary.json: Complete metrics and run data for all downloaded runs -`, validArtifactSets) + "\n\n" + WorkflowIDExplanation, - Example: ` # Basic usage - ` + string(constants.CLIExtensionPrefix) + ` logs # Download logs for all workflows - ` + string(constants.CLIExtensionPrefix) + ` logs weekly-research # Download logs for specific workflow - ` + string(constants.CLIExtensionPrefix) + ` logs weekly-research.md # Download logs (alternative format) - ` + string(constants.CLIExtensionPrefix) + ` logs -c 10 # Download last 10 matching runs +`, validArtifactSets) + "\n\n" + WorkflowIDExplanation +} - # Date filtering - ` + string(constants.CLIExtensionPrefix) + ` logs --start-date 2024-01-01 # Download up to 10 runs after date - ` + string(constants.CLIExtensionPrefix) + ` logs --end-date 2024-01-31 # Download up to 10 runs before date - ` + string(constants.CLIExtensionPrefix) + ` logs --start-date -1w # Download up to 10 runs from last week - ` + string(constants.CLIExtensionPrefix) + ` logs --start-date -1w -c 5 # Download up to 5 runs from last week - ` + string(constants.CLIExtensionPrefix) + ` logs --end-date -1d # Download up to 10 runs before yesterday - ` + string(constants.CLIExtensionPrefix) + ` logs --start-date -1mo # Download up to 10 runs from last month +func buildLogsCommandExample() string { + return fmt.Sprintf(logsCommandExampleTemplate, string(constants.CLIExtensionPrefix)) +} - # Content filtering - ` + string(constants.CLIExtensionPrefix) + ` logs --engine claude # Filter logs by claude engine - ` + string(constants.CLIExtensionPrefix) + ` logs --engine codex # Filter logs by codex engine - ` + string(constants.CLIExtensionPrefix) + ` logs --engine copilot # Filter logs by copilot engine - ` + string(constants.CLIExtensionPrefix) + ` logs --firewall # Filter logs with firewall enabled - ` + string(constants.CLIExtensionPrefix) + ` logs --no-firewall # Filter logs without firewall - ` + string(constants.CLIExtensionPrefix) + ` logs --safe-output missing-tool # Filter logs with missing-tool messages - ` + string(constants.CLIExtensionPrefix) + ` logs --safe-output missing-data # Filter logs with missing-data messages - ` + string(constants.CLIExtensionPrefix) + ` logs --safe-output create-issue # Filter logs with create-issue messages - ` + string(constants.CLIExtensionPrefix) + ` logs --safe-output noop # Filter logs with noop messages - ` + string(constants.CLIExtensionPrefix) + ` logs --safe-output report-incomplete # Filter logs with report-incomplete messages - ` + string(constants.CLIExtensionPrefix) + ` logs --ref main # Filter logs by branch or tag - ` + string(constants.CLIExtensionPrefix) + ` logs --ref feature-xyz # Filter logs by feature branch - ` + string(constants.CLIExtensionPrefix) + ` logs --filtered-integrity # Filter logs containing items that were filtered by gateway integrity checks - ` + string(constants.CLIExtensionPrefix) + ` logs --evals # Filter logs from workflows with evals results - ` + string(constants.CLIExtensionPrefix) + ` logs --exclude-staged # Exclude staged workflow runs from results +func runLogsCommand(cmd *cobra.Command, args []string) error { + logsCommandLog.Printf("Starting logs command: args=%d", len(args)) + stdin, _ := cmd.Flags().GetBool("stdin") + if stdin { + return runLogsCommandFromStdin(cmd, args) + } + values, err := loadLogsCommandValues(cmd, args) + if err != nil { + return err + } + logsCommandLog.Printf("Executing logs download: workflow=%s, count=%d, engine=%s, train=%v, cache_before=%s", + values.workflowName, values.Count, values.Engine, values.Train, values.cacheBefore) + return DownloadWorkflowLogs(cmd.Context(), values.LogsDownloadOptions) +} - # Run ID range filtering - ` + string(constants.CLIExtensionPrefix) + ` logs --after-run-id 1000 # Filter runs after run ID 1000 - ` + string(constants.CLIExtensionPrefix) + ` logs --before-run-id 2000 # Filter runs before run ID 2000 - ` + string(constants.CLIExtensionPrefix) + ` logs --after-run-id 1000 --before-run-id 2000 # Filter runs in range +func runLogsCommandFromStdin(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return errors.New(console.FormatErrorWithSuggestions( + "positional arguments are not allowed with --stdin", + []string{"Remove the workflow name argument, or omit --stdin to use the normal discovery mode"}, + )) + } + logsCommandLog.Printf("Reading run IDs from stdin") + runURLs, err := readRunIDsFromStdin(os.Stdin) + if err != nil { + return fmt.Errorf("failed to read run IDs from stdin: %w", err) + } + options, err := loadStdinLogsOptions(cmd) + if err != nil { + return err + } + options.RunURLs = runURLs + return DownloadWorkflowLogsFromStdin(cmd.Context(), options) +} - # Artifact selection (default: usage only - the compact conclusion artifact) - ` + string(constants.CLIExtensionPrefix) + ` logs --artifacts all # Download all artifacts (agent logs, firewall, etc.) - ` + string(constants.CLIExtensionPrefix) + ` logs --artifacts agent # Download only agent logs - ` + string(constants.CLIExtensionPrefix) + ` logs --artifacts agent,firewall # Download agent and firewall artifacts - ` + string(constants.CLIExtensionPrefix) + ` logs --artifacts mcp # Download only MCP gateway logs +func loadStdinLogsOptions(cmd *cobra.Command) (StdinLogsOptions, error) { + values, err := loadCommonLogsOptions(cmd) + if err != nil { + return StdinLogsOptions{}, err + } + return StdinLogsOptions{ + OutputDir: values.OutputDir, + Engine: values.Engine, + RepoOverride: values.RepoOverride, + Verbose: values.Verbose, + ToolGraph: values.ToolGraph, + NoStaged: values.NoStaged, + FirewallOnly: values.FirewallOnly, + NoFirewall: values.NoFirewall, + Parse: values.Parse, + JSONOutput: values.JSONOutput, + Timeout: values.TimeoutMinutes, + SummaryFile: values.SummaryFile, + SafeOutputType: values.SafeOutputType, + FilteredIntegrity: values.FilteredIntegrity, + EvalsOnly: values.EvalsOnly, + Train: values.Train, + Format: values.Format, + ReportFile: values.ReportFile, + ArtifactSets: values.ArtifactSets, + }, nil +} - # Output options (default output is compact format optimized for agents) - ` + string(constants.CLIExtensionPrefix) + ` logs -o ./my-logs # Custom output directory - ` + string(constants.CLIExtensionPrefix) + ` logs --tool-graph # Generate Mermaid tool sequence graph - ` + string(constants.CLIExtensionPrefix) + ` logs --parse # Parse logs and generate Markdown reports - ` + string(constants.CLIExtensionPrefix) + ` logs -v # Verbose compact output (extra columns + sections) - ` + string(constants.CLIExtensionPrefix) + ` logs --json # JSON format (compact by default, use -v for full) - ` + string(constants.CLIExtensionPrefix) + ` logs --json -v # Full JSON with audit metadata - ` + string(constants.CLIExtensionPrefix) + ` logs --format tsv # Tab-separated (minimal, raw data) - ` + string(constants.CLIExtensionPrefix) + ` logs --format console # Decorated console tables (human-friendly) - ` + string(constants.CLIExtensionPrefix) + ` logs --format markdown # Cross-run security audit report (Markdown) - ` + string(constants.CLIExtensionPrefix) + ` logs --format pretty # Cross-run security audit report (console) - ` + string(constants.CLIExtensionPrefix) + ` logs weekly-research --format markdown --last 10 # Cross-run report for last 10 runs - ` + string(constants.CLIExtensionPrefix) + ` logs --train # Train log pattern weights from last 10 runs - ` + string(constants.CLIExtensionPrefix) + ` logs my-workflow --train -c 50 # Train log pattern weights from up to 50 runs of a specific workflow +func loadLogsCommandValues(cmd *cobra.Command, args []string) (*logsCommandValues, error) { + workflowName, err := resolveLogsWorkflowName(cmd, args) + if err != nil { + return nil, err + } + options, err := loadCommonLogsOptions(cmd) + if err != nil { + return nil, err + } + cacheBefore, _ := cmd.Flags().GetString("cache-before") + if !cmd.Flags().Changed("cache-before") && cmd.Flags().Changed("after") { + cacheBefore, _ = cmd.Flags().GetString("after") + } + options.WorkflowName = workflowName + options.After = cacheBefore + return &logsCommandValues{ + workflowName: workflowName, + cacheBefore: cacheBefore, + LogsDownloadOptions: options, + }, nil +} - # Cross-repository - ` + string(constants.CLIExtensionPrefix) + ` logs weekly-research --repo owner/repo # Download logs from specific repository +func loadCommonLogsOptions(cmd *cobra.Command) (LogsDownloadOptions, error) { + count, _ := cmd.Flags().GetInt("count") + if last, _ := cmd.Flags().GetInt("last"); last > 0 { + count = last + } + startDate, _ := cmd.Flags().GetString("start-date") + endDate, _ := cmd.Flags().GetString("end-date") + startDate, endDate, err := resolveLogsDateRange(startDate, endDate, time.Now()) + if err != nil { + return LogsDownloadOptions{}, err + } + options := LogsDownloadOptions{ + Count: count, + StartDate: startDate, + EndDate: endDate, + OutputDir: getStringFlag(cmd, "output"), + Engine: getStringFlag(cmd, "engine"), + Ref: getStringFlag(cmd, "ref"), + BeforeRunID: getInt64Flag(cmd, "before-run-id"), + AfterRunID: getInt64Flag(cmd, "after-run-id"), + RepoOverride: getStringFlag(cmd, "repo"), + Verbose: getBoolFlag(cmd, "verbose"), + ToolGraph: getBoolFlag(cmd, "tool-graph"), + NoStaged: getBoolFlag(cmd, "exclude-staged"), + FirewallOnly: getBoolFlag(cmd, "firewall"), + NoFirewall: getBoolFlag(cmd, "no-firewall"), + Parse: getBoolFlag(cmd, "parse"), + JSONOutput: getBoolFlag(cmd, "json"), + TimeoutMinutes: getIntFlag(cmd, "timeout"), + SummaryFile: getStringFlag(cmd, "summary-file"), + SafeOutputType: getStringFlag(cmd, "safe-output"), + FilteredIntegrity: getBoolFlag(cmd, "filtered-integrity"), + EvalsOnly: getBoolFlag(cmd, "evals"), + Train: getBoolFlag(cmd, "train"), + Format: getStringFlag(cmd, "format"), + ReportFile: getStringFlag(cmd, "report-file"), + ArtifactSets: getStringSliceFlag(cmd, "artifacts"), + } + if err := validateLogsOptions(options); err != nil { + return LogsDownloadOptions{}, err + } + if len(options.ArtifactSets) > 0 { + options.ArtifactSets = applyEvalsArtifact(options.ArtifactSets, options.EvalsOnly) + } + return options, nil +} - # Cache maintenance - ` + string(constants.CLIExtensionPrefix) + ` logs --cache-before -1w # Evict local cache older than 1 week before downloading runs - ` + string(constants.CLIExtensionPrefix) + ` logs --cache-before -30d # Evict local cache older than 30 days before downloading runs - ` + string(constants.CLIExtensionPrefix) + ` logs --cache-before -1mo # Evict local cache older than 1 month before downloading runs - ` + string(constants.CLIExtensionPrefix) + ` logs --cache-before 2024-01-01 # Evict local cache older than 2024-01-01 before downloading runs`, - RunE: func(cmd *cobra.Command, args []string) error { - logsCommandLog.Printf("Starting logs command: args=%d", len(args)) - - stdin, _ := cmd.Flags().GetBool("stdin") - - // When --stdin is provided, read run IDs/URLs from stdin and bypass GitHub API discovery. - if stdin { - if len(args) > 0 { - return errors.New(console.FormatErrorWithSuggestions( - "positional arguments are not allowed with --stdin", - []string{"Remove the workflow name argument, or omit --stdin to use the normal discovery mode"}, - )) - } - logsCommandLog.Printf("Reading run IDs from stdin") - runURLs, err := readRunIDsFromStdin(os.Stdin) - if err != nil { - return fmt.Errorf("failed to read run IDs from stdin: %w", err) - } - - outputDir, _ := cmd.Flags().GetString("output") - engine, _ := cmd.Flags().GetString("engine") - repoOverride, _ := cmd.Flags().GetString("repo") - verbose, _ := cmd.Flags().GetBool("verbose") - toolGraph, _ := cmd.Flags().GetBool("tool-graph") - noStaged, _ := cmd.Flags().GetBool("exclude-staged") - firewallOnly, _ := cmd.Flags().GetBool("firewall") - noFirewall, _ := cmd.Flags().GetBool("no-firewall") - parse, _ := cmd.Flags().GetBool("parse") - jsonOutput, _ := cmd.Flags().GetBool("json") - timeout, _ := cmd.Flags().GetInt("timeout") - summaryFile, _ := cmd.Flags().GetString("summary-file") - safeOutputType, _ := cmd.Flags().GetString("safe-output") - filteredIntegrity, _ := cmd.Flags().GetBool("filtered-integrity") - evalsOnly, _ := cmd.Flags().GetBool("evals") - train, _ := cmd.Flags().GetBool("train") - format, _ := cmd.Flags().GetString("format") - reportFile, _ := cmd.Flags().GetString("report-file") - artifacts, _ := cmd.Flags().GetStringSlice("artifacts") - - if engine != "" { - logsCommandLog.Printf("Validating engine parameter: %s", engine) - registry := workflow.GetGlobalEngineRegistry() - if !registry.IsValidEngine(engine) { - supportedEngines := registry.GetSupportedEngines() - return fmt.Errorf("invalid engine value '%s'. Must be one of: %s", engine, strings.Join(supportedEngines, ", ")) - } - } - - if err := validateReportFileFlags(reportFile, format, jsonOutput); err != nil { - return err - } - - if len(artifacts) > 0 { - artifacts = applyEvalsArtifact(artifacts, evalsOnly) - } - - return DownloadWorkflowLogsFromStdin(cmd.Context(), StdinLogsOptions{ - RunURLs: runURLs, - OutputDir: outputDir, - Engine: engine, - RepoOverride: repoOverride, - Verbose: verbose, - ToolGraph: toolGraph, - NoStaged: noStaged, - FirewallOnly: firewallOnly, - NoFirewall: noFirewall, - Parse: parse, - JSONOutput: jsonOutput, - Timeout: timeout, - SummaryFile: summaryFile, - SafeOutputType: safeOutputType, - FilteredIntegrity: filteredIntegrity, - EvalsOnly: evalsOnly, - Train: train, - Format: format, - ReportFile: reportFile, - ArtifactSets: artifacts, - }) - } - - var workflowName string - if len(args) > 0 && args[0] != "" { - logsCommandLog.Printf("Resolving workflow name from argument: %s", args[0]) - - repoOverrideEarly, _ := cmd.Flags().GetString("repo") - if repoOverrideEarly != "" { - // When --repo is specified, only use local lock-file resolution when - // the target repo is the current repository. Local lock files are - // authoritative for the current repo and allow us to map the workflow - // ID (e.g. "audit-workflows") to its GitHub Actions display name - // (e.g. "Agentic Workflow Audit Agent"), which gh run list requires. - // - // For cross-repo queries, skip local resolution to avoid mapping a - // local display name onto a different repository's workflow topology. - // - // Note: the argument must be a workflow ID (e.g. "test-claude"), - // not a display name (e.g. "Test Claude"). Display-name lookup - // requires local lock files, which are unavailable for remote repos. - if repoIsLocal(repoOverrideEarly) { - if resolved, resolveErr := workflow.FindWorkflowName(args[0]); resolveErr == nil { - workflowName = resolved - logsCommandLog.Printf("Resolved workflow name via local lock files: %s -> %s", args[0], workflowName) - } else { - workflowName = normalizeWorkflowID(args[0]) - logsCommandLog.Printf("Local resolution failed, using normalized workflow name: %s", workflowName) - } - } else { - workflowName = normalizeWorkflowID(args[0]) - logsCommandLog.Printf("Using normalized workflow name for remote repo: %s", workflowName) - } - } else { - // Use flexible workflow name matching (workflow ID or display name) - resolvedName, err := workflow.FindWorkflowName(args[0]) - if err != nil { - // Workflow not found - provide suggestions - suggestions := []string{ - fmt.Sprintf("Run '%s status' to see all available workflows", string(constants.CLIExtensionPrefix)), - "Check for typos in the workflow name", - "Use the workflow ID (e.g., 'test-claude') or GitHub Actions workflow name (e.g., 'Test Claude')", - } - - // Add fuzzy match suggestions - similarNames := suggestWorkflowNames(args[0]) - if len(similarNames) > 0 { - suggestions = append([]string{fmt.Sprintf("Did you mean: %s?", strings.Join(similarNames, ", "))}, suggestions...) - } - - return errors.New(console.FormatErrorWithSuggestions( - fmt.Sprintf("workflow '%s' not found", args[0]), - suggestions, - )) - } - workflowName = resolvedName - } - } - - count, _ := cmd.Flags().GetInt("count") - // --last is an alias for --count (for compatibility with users of `audit report --last`) - if last, _ := cmd.Flags().GetInt("last"); last > 0 { - count = last - } - startDate, _ := cmd.Flags().GetString("start-date") - endDate, _ := cmd.Flags().GetString("end-date") - outputDir, _ := cmd.Flags().GetString("output") - engine, _ := cmd.Flags().GetString("engine") - ref, _ := cmd.Flags().GetString("ref") - beforeRunID, _ := cmd.Flags().GetInt64("before-run-id") - afterRunID, _ := cmd.Flags().GetInt64("after-run-id") - verbose, _ := cmd.Flags().GetBool("verbose") - toolGraph, _ := cmd.Flags().GetBool("tool-graph") - noStaged, _ := cmd.Flags().GetBool("exclude-staged") - firewallOnly, _ := cmd.Flags().GetBool("firewall") - noFirewall, _ := cmd.Flags().GetBool("no-firewall") - parse, _ := cmd.Flags().GetBool("parse") - jsonOutput, _ := cmd.Flags().GetBool("json") - timeout, _ := cmd.Flags().GetInt("timeout") - repoOverride, _ := cmd.Flags().GetString("repo") - summaryFile, _ := cmd.Flags().GetString("summary-file") - safeOutputType, _ := cmd.Flags().GetString("safe-output") - filteredIntegrity, _ := cmd.Flags().GetBool("filtered-integrity") - evalsOnly, _ := cmd.Flags().GetBool("evals") - train, _ := cmd.Flags().GetBool("train") - format, _ := cmd.Flags().GetString("format") - reportFile, _ := cmd.Flags().GetString("report-file") - artifacts, _ := cmd.Flags().GetStringSlice("artifacts") - cacheBefore, _ := cmd.Flags().GetString("cache-before") - if !cmd.Flags().Changed("cache-before") { - if cmd.Flags().Changed("after") { - cacheBefore, _ = cmd.Flags().GetString("after") - } - } - - // Resolve relative dates to absolute dates for GitHub CLI - now := time.Now() - if startDate != "" { - logsCommandLog.Printf("Resolving start date: %s", startDate) - resolvedStartDate, err := workflow.ResolveRelativeDate(startDate, now) - if err != nil { - return fmt.Errorf("invalid start-date format '%s': %w", startDate, err) - } - startDate = resolvedStartDate - logsCommandLog.Printf("Resolved start date to: %s", startDate) - } - if endDate != "" { - logsCommandLog.Printf("Resolving end date: %s", endDate) - resolvedEndDate, err := workflow.ResolveRelativeDate(endDate, now) - if err != nil { - return fmt.Errorf("invalid end-date format '%s': %w", endDate, err) - } - endDate = resolvedEndDate - logsCommandLog.Printf("Resolved end date to: %s", endDate) - } - - // Validate engine parameter using the engine registry - if engine != "" { - logsCommandLog.Printf("Validating engine parameter: %s", engine) - registry := workflow.GetGlobalEngineRegistry() - if !registry.IsValidEngine(engine) { - supportedEngines := registry.GetSupportedEngines() - return fmt.Errorf("invalid engine value '%s'. Must be one of: %s", engine, strings.Join(supportedEngines, ", ")) - } - } - - if err := validateReportFileFlags(reportFile, format, jsonOutput); err != nil { - return err - } - - logsCommandLog.Printf("Executing logs download: workflow=%s, count=%d, engine=%s, train=%v, cache_before=%s", workflowName, count, engine, train, cacheBefore) - - if len(artifacts) > 0 { - artifacts = applyEvalsArtifact(artifacts, evalsOnly) - } - - return DownloadWorkflowLogs(cmd.Context(), LogsDownloadOptions{ - WorkflowName: workflowName, - Count: count, - StartDate: startDate, - EndDate: endDate, - OutputDir: outputDir, - Engine: engine, - Ref: ref, - BeforeRunID: beforeRunID, - AfterRunID: afterRunID, - RepoOverride: repoOverride, - Verbose: verbose, - ToolGraph: toolGraph, - NoStaged: noStaged, - FirewallOnly: firewallOnly, - NoFirewall: noFirewall, - Parse: parse, - JSONOutput: jsonOutput, - TimeoutMinutes: timeout, - SummaryFile: summaryFile, - SafeOutputType: safeOutputType, - FilteredIntegrity: filteredIntegrity, - EvalsOnly: evalsOnly, - Train: train, - Format: format, - ReportFile: reportFile, - ArtifactSets: artifacts, - After: cacheBefore, - }) - }, +func resolveLogsDateRange(startDate, endDate string, now time.Time) (string, string, error) { + resolve := func(label, value string) (string, error) { + if value == "" { + return "", nil + } + logsCommandLog.Printf("Resolving %s date: %s", label, value) + resolved, err := workflow.ResolveRelativeDate(value, now) + if err != nil { + return "", fmt.Errorf("invalid %s-date format '%s': %w", label, value, err) + } + logsCommandLog.Printf("Resolved %s date to: %s", label, resolved) + return resolved, nil + } + resolvedStart, err := resolve("start", startDate) + if err != nil { + return "", "", err } + resolvedEnd, err := resolve("end", endDate) + if err != nil { + return "", "", err + } + return resolvedStart, resolvedEnd, nil +} + +func validateLogsOptions(options LogsDownloadOptions) error { + if err := validateLogsEngine(options.Engine); err != nil { + return err + } + return validateReportFileFlags(options.ReportFile, options.Format, options.JSONOutput) +} + +func validateLogsEngine(engine string) error { + if engine == "" { + return nil + } + logsCommandLog.Printf("Validating engine parameter: %s", engine) + registry := workflow.GetGlobalEngineRegistry() + if registry.IsValidEngine(engine) { + return nil + } + supportedEngines := registry.GetSupportedEngines() + return fmt.Errorf("invalid engine value '%s'. Must be one of: %s", engine, strings.Join(supportedEngines, ", ")) +} - // Add flags to logs command +func resolveLogsWorkflowName(cmd *cobra.Command, args []string) (string, error) { + if len(args) == 0 || args[0] == "" { + return "", nil + } + logsCommandLog.Printf("Resolving workflow name from argument: %s", args[0]) + repoOverride := getStringFlag(cmd, "repo") + if repoOverride != "" { + return resolveLogsWorkflowNameForRepo(args[0], repoOverride), nil + } + return resolveLogsWorkflowNameLocally(args[0]) +} + +func resolveLogsWorkflowNameForRepo(arg, repoOverride string) string { + if !repoIsLocal(repoOverride) { + workflowName := normalizeWorkflowID(arg) + logsCommandLog.Printf("Using normalized workflow name for remote repo: %s", workflowName) + return workflowName + } + if resolved, err := workflow.FindWorkflowName(arg); err == nil { + logsCommandLog.Printf("Resolved workflow name via local lock files: %s -> %s", arg, resolved) + return resolved + } + workflowName := normalizeWorkflowID(arg) + logsCommandLog.Printf("Local resolution failed, using normalized workflow name: %s", workflowName) + return workflowName +} + +func resolveLogsWorkflowNameLocally(arg string) (string, error) { + resolvedName, err := workflow.FindWorkflowName(arg) + if err == nil { + return resolvedName, nil + } + suggestions := []string{ + fmt.Sprintf("Run '%s status' to see all available workflows", string(constants.CLIExtensionPrefix)), + "Check for typos in the workflow name", + "Use the workflow ID (e.g., 'test-claude') or GitHub Actions workflow name (e.g., 'Test Claude')", + } + if similarNames := suggestWorkflowNames(arg); len(similarNames) > 0 { + suggestions = append([]string{fmt.Sprintf("Did you mean: %s?", strings.Join(similarNames, ", "))}, suggestions...) + } + return "", errors.New(console.FormatErrorWithSuggestions( + fmt.Sprintf("workflow '%s' not found", arg), + suggestions, + )) +} + +func addLogsCommandFlags(logsCmd *cobra.Command, validArtifactSets string) { logsCmd.Flags().IntP("count", "c", 10, "Maximum number of matching workflow runs to return (after applying filters)") logsCmd.Flags().String("start-date", "", "Filter runs created after this date (YYYY-MM-DD or delta like -1d, -1w, -1mo)") logsCmd.Flags().String("end-date", "", "Filter runs created before this date (YYYY-MM-DD or delta like -1d, -1w, -1mo)") @@ -397,13 +398,37 @@ Downloaded artifacts include (when using --artifacts all): _ = logsCmd.Flags().MarkDeprecated("after", "use --cache-before") logsCmd.Flags().Bool("stdin", false, "Read workflow run IDs or URLs from stdin (one per line) instead of discovering runs via the GitHub API") logsCmd.MarkFlagsMutuallyExclusive("firewall", "no-firewall") +} - // Register completions for logs command +func registerLogsCommandCompletions(logsCmd *cobra.Command) { logsCmd.ValidArgsFunction = CompleteWorkflowNames RegisterEngineFlagCompletion(logsCmd) RegisterDirFlagCompletion(logsCmd, "output") +} - return logsCmd +func getStringFlag(cmd *cobra.Command, name string) string { + value, _ := cmd.Flags().GetString(name) + return value +} + +func getStringSliceFlag(cmd *cobra.Command, name string) []string { + value, _ := cmd.Flags().GetStringSlice(name) + return value +} + +func getBoolFlag(cmd *cobra.Command, name string) bool { + value, _ := cmd.Flags().GetBool(name) + return value +} + +func getIntFlag(cmd *cobra.Command, name string) int { + value, _ := cmd.Flags().GetInt(name) + return value +} + +func getInt64Flag(cmd *cobra.Command, name string) int64 { + value, _ := cmd.Flags().GetInt64(name) + return value } // flattenSingleFileArtifacts applies the artifact unfold rule to downloaded artifacts diff --git a/pkg/cli/run_workflow_execution.go b/pkg/cli/run_workflow_execution.go index c6bb5231cac..0792a737d64 100644 --- a/pkg/cli/run_workflow_execution.go +++ b/pkg/cli/run_workflow_execution.go @@ -55,584 +55,582 @@ type WorkflowRunResult struct { Error string `json:"error,omitempty"` } +type workflowEnableState struct { + wasDisabled bool + workflowID int64 +} + +type workflowRunPreparation struct { + enableState workflowEnableState + normalizedID string + lockFileName string + lockFilePath string +} + // RunWorkflowOnGitHub runs an agentic workflow on GitHub Actions func RunWorkflowOnGitHub(ctx context.Context, workflowIdOrName string, opts RunOptions) error { executionLog.Printf("Starting workflow run: workflow=%s, enable=%v, engineOverride=%s, repo=%s, ref=%s, push=%v, wait=%v, inputs=%v", workflowIdOrName, opts.Enable, opts.EngineOverride, opts.RepoOverride, opts.RefOverride, opts.Push, opts.WaitForCompletion, opts.Inputs) + if err := checkWorkflowRunContext(ctx, workflowIdOrName); err != nil { + return err + } + if err := validateRunInputs(opts.Inputs); err != nil { + return err + } + if opts.Verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Running workflow on GitHub Actions: "+workflowIdOrName)) + } + if !isGHCLIAvailable() { + return errors.New("GitHub CLI (gh) is required but not available") + } + prep, err := prepareWorkflowRun(ctx, workflowIdOrName, opts) + if err != nil { + return err + } + defer restoreEnabledWorkflow(workflowIdOrName, opts, prep.enableState) + args, ref := buildWorkflowRunArgs(prep.lockFileName, opts) + workflowStartTime := time.Now() + if opts.DryRun { + return handleWorkflowDryRun(prep.lockFileName, args, opts) + } + output, runInfo, runErr, err := executeWorkflowRun(ctx, prep.lockFileName, args, opts) + if err != nil { + return err + } + handleWorkflowRunInfo(output, runInfo, runErr, opts) + return waitForWorkflowRunCompletion(ctx, opts, runInfo, runErr, workflowStartTime, ref) +} - // Check context cancellation at the start +func checkWorkflowRunContext(ctx context.Context, workflowIdOrName string) error { select { case <-ctx.Done(): fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Operation cancelled")) return ctx.Err() default: } - if workflowIdOrName == "" { return errors.New("workflow name or ID is required") } + return nil +} - // Validate input format early before attempting workflow validation - for _, input := range opts.Inputs { +func validateRunInputs(inputs []string) error { + for _, input := range inputs { if !strings.Contains(input, "=") { return fmt.Errorf("invalid input format '%s': expected key=value", input) } - // Check that key (before '=') is not empty - parts := strings.SplitN(input, "=", 2) - if parts[0] == "" { + if parts := strings.SplitN(input, "=", 2); parts[0] == "" { return fmt.Errorf("invalid input format '%s': key cannot be empty", input) } } + return nil +} - if opts.Verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Running workflow on GitHub Actions: "+workflowIdOrName)) +func prepareWorkflowRun(ctx context.Context, workflowIdOrName string, opts RunOptions) (*workflowRunPreparation, error) { + if err := validateWorkflowForRun(workflowIdOrName, opts); err != nil { + return nil, err } - - // Check if gh CLI is available - if !isGHCLIAvailable() { - return errors.New("GitHub CLI (gh) is required but not available") + enableState, err := handleWorkflowEnablement(workflowIdOrName, opts) + if err != nil { + return nil, err + } + prep := &workflowRunPreparation{ + enableState: enableState, + normalizedID: normalizeWorkflowID(workflowIdOrName), + } + prep.lockFileName = prep.normalizedID + ".lock.yml" + prep.lockFilePath, err = resolveWorkflowLockFile(prep.normalizedID, prep.lockFileName, opts.RepoOverride) + if err != nil { + return nil, err + } + if err := applyLocalWorkflowOverrides(ctx, workflowIdOrName, prep, opts); err != nil { + return nil, err } + return prep, nil +} - // Validate workflow exists and is runnable +func validateWorkflowForRun(workflowIdOrName string, opts RunOptions) error { if opts.RepoOverride != "" { executionLog.Printf("Validating remote workflow: %s in repo %s", workflowIdOrName, opts.RepoOverride) - // For remote repositories, use remote validation - if err := validateRemoteWorkflow(workflowIdOrName, opts.RepoOverride, opts.Verbose); err != nil { - return fmt.Errorf("failed to validate remote workflow: %w", err) - } - // Note: We skip local runnable check for remote workflows as we assume they are properly configured - } else { - executionLog.Printf("Validating local workflow: %s", workflowIdOrName) - // For local workflows, use existing local validation - workflowFile, err := resolveWorkflowFile(workflowIdOrName, opts.Verbose) - if err != nil { - // Return error directly without wrapping - it already contains formatted message with suggestions - return err - } - - // Check if the workflow is runnable (has workflow_dispatch trigger) - runnable, err := IsRunnable(workflowFile) - if err != nil { - return fmt.Errorf("failed to check if workflow %s is runnable: %w", workflowFile, err) - } - - if !runnable { - return fmt.Errorf("workflow '%s' cannot be run on GitHub Actions - it must have 'workflow_dispatch' trigger", workflowIdOrName) - } - executionLog.Printf("Workflow is runnable: %s", workflowFile) - - // Validate workflow inputs - if err := validateWorkflowInputs(workflowFile, opts.Inputs); err != nil { - return fmt.Errorf("%w", err) - } - - // Check if the workflow file has local modifications - if status, err := checkWorkflowFileStatus(workflowFile); err == nil && status != nil { - var warnings []string - - if status.IsModified { - warnings = append(warnings, "The workflow file has unstaged changes") - } - if status.IsStaged { - warnings = append(warnings, "The workflow file has staged changes") - } - if status.HasUnpushedCommits { - warnings = append(warnings, "The workflow file has unpushed commits") - } - - if len(warnings) > 0 { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(strings.Join(warnings, ", "))) - fmt.Fprintln(os.Stderr, console.FormatWarningMessage("These changes will not be reflected in the GitHub Actions run")) - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Consider pushing your changes before running the workflow")) - } - } + return validateRemoteWorkflow(workflowIdOrName, opts.RepoOverride, opts.Verbose) } + return validateLocalWorkflowForRun(workflowIdOrName, opts.Inputs) +} - // Handle --enable flag logic: check workflow state and enable if needed - var wasDisabled bool - var workflowID int64 - if opts.Enable { - // Get current workflow status - wf, err := getWorkflowStatus(workflowIdOrName, opts.RepoOverride, opts.Verbose) - if err != nil { - if opts.Verbose { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not check workflow status: %v", err))) - } - } - - // If we successfully got workflow status, check if it needs enabling - if err == nil { - workflowID = wf.ID - if wf.State == "disabled_manually" { - wasDisabled = true - executionLog.Printf("Workflow %s is disabled, temporarily enabling for this run (id=%d)", workflowIdOrName, wf.ID) - if opts.Verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Workflow '%s' is disabled, enabling it temporarily...", workflowIdOrName))) - } - // Enable the workflow - enableArgs := []string{"workflow", "enable", strconv.FormatInt(wf.ID, 10)} - if opts.RepoOverride != "" { - enableArgs = append(enableArgs, "--repo", opts.RepoOverride) - } - cmd := workflow.ExecGH(enableArgs...) - if err := cmd.Run(); err != nil { - executionLog.Printf("Failed to enable workflow %s: %v", workflowIdOrName, err) - return fmt.Errorf("failed to enable workflow '%s': %w", workflowIdOrName, err) - } - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Enabled workflow: "+workflowIdOrName)) - } else { - executionLog.Printf("Workflow %s is already enabled (state=%s)", workflowIdOrName, wf.State) - } - } +func validateLocalWorkflowForRun(workflowIdOrName string, inputs []string) error { + executionLog.Printf("Validating local workflow: %s", workflowIdOrName) + workflowFile, err := resolveWorkflowFile(workflowIdOrName, false) + if err != nil { + return err } + if err := ensureWorkflowRunnable(workflowFile, workflowIdOrName); err != nil { + return err + } + if err := validateWorkflowInputs(workflowFile, inputs); err != nil { + return fmt.Errorf("%w", err) + } + warnLocalWorkflowStatus(workflowFile) + return nil +} - // Normalize workflow ID to handle both \"workflow-name\" and \".github/workflows/workflow-name.md\" formats - normalizedID := normalizeWorkflowID(workflowIdOrName) - - // Construct lock file name from normalized ID (same for both local and remote) - lockFileName := normalizedID + ".lock.yml" - - // For local workflows, validate the workflow exists and check for lock file - var lockFilePath string - if opts.RepoOverride == "" { - // For local workflows, validate the workflow exists locally - workflowsDir := getWorkflowsDir() - - _, _, err := readWorkflowFile(normalizedID+".md", workflowsDir) - if err != nil { - return fmt.Errorf("failed to find workflow in local %s: %w", workflowsDir, err) - } +func ensureWorkflowRunnable(workflowFile, workflowIdOrName string) error { + runnable, err := IsRunnable(workflowFile) + if err != nil { + return fmt.Errorf("failed to check if workflow %s is runnable: %w", workflowFile, err) + } + if !runnable { + return fmt.Errorf("workflow '%s' cannot be run on GitHub Actions - it must have 'workflow_dispatch' trigger", workflowIdOrName) + } + executionLog.Printf("Workflow is runnable: %s", workflowFile) + return nil +} - // Check if the lock file exists in the workflows directory - lockFilePath = filepath.Join(constants.GetWorkflowDir(), lockFileName) - if _, err := os.Stat(lockFilePath); os.IsNotExist(err) { - executionLog.Printf("Lock file not found: %s (workflow must be compiled first)", lockFilePath) - suggestions := []string{ - fmt.Sprintf("Run '%s compile' to compile all workflows", string(constants.CLIExtensionPrefix)), - fmt.Sprintf("Run '%s compile %s' to compile this specific workflow", string(constants.CLIExtensionPrefix), normalizedID), - } - errMsg := console.FormatErrorWithSuggestions( - fmt.Sprintf("workflow lock file '%s' not found in %s", lockFileName, constants.GetWorkflowDir()), - suggestions, - ) - return errors.New(errMsg) - } - executionLog.Printf("Found lock file: %s", lockFilePath) +func warnLocalWorkflowStatus(workflowFile string) { + status, err := checkWorkflowFileStatus(workflowFile) + if err != nil || status == nil { + return + } + var warnings []string + if status.IsModified { + warnings = append(warnings, "The workflow file has unstaged changes") + } + if status.IsStaged { + warnings = append(warnings, "The workflow file has staged changes") + } + if status.HasUnpushedCommits { + warnings = append(warnings, "The workflow file has unpushed commits") + } + if len(warnings) == 0 { + return } + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(strings.Join(warnings, ", "))) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("These changes will not be reflected in the GitHub Actions run")) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Consider pushing your changes before running the workflow")) +} - // Recompile workflow if engine override is provided (only for local workflows) - if opts.EngineOverride != "" && opts.RepoOverride == "" { +func handleWorkflowEnablement(workflowIdOrName string, opts RunOptions) (workflowEnableState, error) { + if !opts.Enable { + return workflowEnableState{}, nil + } + wf, err := getWorkflowStatus(workflowIdOrName, opts.RepoOverride, opts.Verbose) + if err != nil { if opts.Verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Recompiling workflow with engine override: "+opts.EngineOverride)) + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not check workflow status: %v", err))) } + return workflowEnableState{}, nil + } + state := workflowEnableState{workflowID: wf.ID} + if wf.State != "disabled_manually" { + executionLog.Printf("Workflow %s is already enabled (state=%s)", workflowIdOrName, wf.State) + return state, nil + } + state.wasDisabled = true + executionLog.Printf("Workflow %s is disabled, temporarily enabling for this run (id=%d)", workflowIdOrName, wf.ID) + if opts.Verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Workflow '%s' is disabled, enabling it temporarily...", workflowIdOrName))) + } + if err := enableWorkflowForRun(wf.ID, opts.RepoOverride); err != nil { + return workflowEnableState{}, fmt.Errorf("failed to enable workflow '%s': %w", workflowIdOrName, err) + } + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Enabled workflow: "+workflowIdOrName)) + return state, nil +} - workflowMarkdownPath := stringutil.LockFileToMarkdown(lockFilePath) - config := CompileConfig{ - MarkdownFiles: []string{workflowMarkdownPath}, - Verbose: opts.Verbose, - EngineOverride: opts.EngineOverride, - Validate: true, - Watch: false, - WorkflowDir: "", - SkipInstructions: false, - NoEmit: false, - Purge: false, - TrialMode: false, - TrialLogicalRepoSlug: "", - Strict: false, - } - if _, err := CompileWorkflows(ctx, config); err != nil { - return fmt.Errorf("failed to recompile workflow with engine override: %w", err) - } +func enableWorkflowForRun(workflowID int64, repoOverride string) error { + args := []string{"workflow", "enable", strconv.FormatInt(workflowID, 10)} + if repoOverride != "" { + args = append(args, "--repo", repoOverride) + } + return workflow.ExecGH(args...).Run() +} - if opts.Verbose { - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Successfully recompiled workflow with engine: "+opts.EngineOverride)) - } - } else if opts.EngineOverride != "" && opts.RepoOverride != "" { - if opts.Verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Note: Engine override ignored for remote repository workflows")) +func resolveWorkflowLockFile(normalizedID, lockFileName, repoOverride string) (string, error) { + if repoOverride != "" { + return "", nil + } + workflowsDir := getWorkflowsDir() + if _, _, err := readWorkflowFile(normalizedID+".md", workflowsDir); err != nil { + return "", fmt.Errorf("failed to find workflow in local %s: %w", workflowsDir, err) + } + lockFilePath := filepath.Join(constants.GetWorkflowDir(), lockFileName) + if _, err := os.Stat(lockFilePath); os.IsNotExist(err) { + suggestions := []string{ + fmt.Sprintf("Run '%s compile' to compile all workflows", string(constants.CLIExtensionPrefix)), + fmt.Sprintf("Run '%s compile %s' to compile this specific workflow", string(constants.CLIExtensionPrefix), normalizedID), } + return "", errors.New(console.FormatErrorWithSuggestions( + fmt.Sprintf("workflow lock file '%s' not found in %s", lockFileName, constants.GetWorkflowDir()), + suggestions, + )) } + executionLog.Printf("Found lock file: %s", lockFilePath) + return lockFilePath, nil +} +func applyLocalWorkflowOverrides(ctx context.Context, workflowIdOrName string, prep *workflowRunPreparation, opts RunOptions) error { + if err := maybeRecompileWorkflowOverride(ctx, prep.lockFilePath, opts); err != nil { + return err + } if opts.Verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Using lock file: "+lockFileName)) - } - - // Check for missing or outdated lock files (when not using --push) - if !opts.Push && opts.RepoOverride == "" { - workflowMarkdownPath := stringutil.LockFileToMarkdown(lockFilePath) - if status, err := checkLockFileStatus(workflowMarkdownPath); err == nil { - if status.Missing { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Lock file is missing")) - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Run 'gh aw run %s --push' to automatically compile and push the lock file", workflowIdOrName))) - } else if status.Outdated { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Lock file is outdated (workflow file is newer)")) - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Run 'gh aw run %s --push' to automatically compile and push the lock file", workflowIdOrName))) - } - } + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Using lock file: "+prep.lockFileName)) } + maybeWarnAboutLockFile(workflowIdOrName, prep.lockFilePath, opts) + return maybePushWorkflowFiles(ctx, workflowIdOrName, prep.lockFilePath, opts) +} - // Handle --push flag: commit and push workflow files before running - if opts.Push { - // Only valid for local workflows - if opts.RepoOverride != "" { - return errors.New("--push flag is only supported for local workflows, not remote repositories") - } - - if opts.Verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Collecting workflow files for push...")) - } - - // Collect the workflow .md file, .lock.yml file, and transitive imports - workflowMarkdownPath := stringutil.LockFileToMarkdown(lockFilePath) - files, err := collectWorkflowFiles(ctx, workflowMarkdownPath, opts.Verbose, opts.Approve) - if err != nil { - return fmt.Errorf("failed to collect workflow files: %w", err) +func maybeRecompileWorkflowOverride(ctx context.Context, lockFilePath string, opts RunOptions) error { + if opts.EngineOverride == "" || opts.RepoOverride != "" { + if opts.EngineOverride != "" && opts.RepoOverride != "" && opts.Verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Note: Engine override ignored for remote repository workflows")) } + return nil + } + if opts.Verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Recompiling workflow with engine override: "+opts.EngineOverride)) + } + config := CompileConfig{ + MarkdownFiles: []string{stringutil.LockFileToMarkdown(lockFilePath)}, + Verbose: opts.Verbose, + EngineOverride: opts.EngineOverride, + Validate: true, + Watch: false, + WorkflowDir: "", + SkipInstructions: false, + NoEmit: false, + Purge: false, + TrialMode: false, + TrialLogicalRepoSlug: "", + Strict: false, + } + if _, err := CompileWorkflows(ctx, config); err != nil { + return fmt.Errorf("failed to recompile workflow with engine override: %w", err) + } + if opts.Verbose { + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Successfully recompiled workflow with engine: "+opts.EngineOverride)) + } + return nil +} - // Commit and push the files (includes branch verification if --ref is specified) - if err := pushWorkflowFiles(ctx, workflowIdOrName, files, opts.RefOverride, opts.Verbose); err != nil { - return fmt.Errorf("failed to push workflow files: %w", err) - } +func maybeWarnAboutLockFile(workflowIdOrName, lockFilePath string, opts RunOptions) { + if opts.Push || opts.RepoOverride != "" { + return + } + workflowMarkdownPath := stringutil.LockFileToMarkdown(lockFilePath) + status, err := checkLockFileStatus(workflowMarkdownPath) + if err != nil { + return + } + if status.Missing { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Lock file is missing")) + } else if status.Outdated { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Lock file is outdated (workflow file is newer)")) + } else { + return + } + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Run 'gh aw run %s --push' to automatically compile and push the lock file", workflowIdOrName))) +} - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Successfully pushed %d file(s) for workflow %s", len(files), workflowIdOrName))) +func maybePushWorkflowFiles(ctx context.Context, workflowIdOrName, lockFilePath string, opts RunOptions) error { + if !opts.Push { + return nil } + if opts.RepoOverride != "" { + return errors.New("--push flag is only supported for local workflows, not remote repositories") + } + if opts.Verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Collecting workflow files for push...")) + } + files, err := collectWorkflowFiles(ctx, stringutil.LockFileToMarkdown(lockFilePath), opts.Verbose, opts.Approve) + if err != nil { + return fmt.Errorf("failed to collect workflow files: %w", err) + } + if err := pushWorkflowFiles(ctx, workflowIdOrName, files, opts.RefOverride, opts.Verbose); err != nil { + return fmt.Errorf("failed to push workflow files: %w", err) + } + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Successfully pushed %d file(s) for workflow %s", len(files), workflowIdOrName))) + return nil +} - // Build the gh workflow run command with optional repo and ref overrides +func buildWorkflowRunArgs(lockFileName string, opts RunOptions) ([]string, string) { args := []string{"workflow", "run", lockFileName} if opts.RepoOverride != "" { args = append(args, "--repo", opts.RepoOverride) } - - // Determine the ref to use (branch/tag) - // If refOverride is specified, use it; otherwise for local workflows, use current branch - ref := opts.RefOverride - if ref == "" && opts.RepoOverride == "" { - // For local workflows without explicit ref, use the current branch - if currentBranch, err := getCurrentBranch(); err == nil { - ref = currentBranch - executionLog.Printf("Using current branch for workflow run: %s", ref) - } else if opts.Verbose { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Note: Could not determine current branch: %v", err))) - } - } + ref := resolveWorkflowRunRef(opts) if ref != "" { args = append(args, "--ref", ref) } - - // Add workflow inputs if provided - if len(opts.Inputs) > 0 { - for _, input := range opts.Inputs { - // Add as raw field flag to gh workflow run - args = append(args, "-f", input) - } + for _, input := range opts.Inputs { + args = append(args, "-f", input) } + return args, ref +} - // Record the start time for auto-merge PR filtering - workflowStartTime := time.Now() - - // Handle dry-run mode: validate everything but skip actual execution - if opts.DryRun { - if opts.Verbose { - var cmdParts []string - cmdParts = append(cmdParts, "gh workflow run", lockFileName) - if opts.RepoOverride != "" { - cmdParts = append(cmdParts, "--repo", opts.RepoOverride) - } - if ref != "" { - cmdParts = append(cmdParts, "--ref", ref) - } - if len(opts.Inputs) > 0 { - for _, input := range opts.Inputs { - cmdParts = append(cmdParts, "-f", input) - } - } - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Dry run mode - command that would be executed:")) - fmt.Fprintln(os.Stderr, console.FormatCommandMessage(strings.Join(cmdParts, " "))) - } - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("✓ Validation passed for workflow: %s (dry run - not executed)", lockFileName))) - - // Restore workflow state if it was disabled and we enabled it - if opts.Enable && wasDisabled && workflowID != 0 { - restoreWorkflowState(workflowIdOrName, workflowID, opts.RepoOverride, opts.Verbose) - } - - return nil +func resolveWorkflowRunRef(opts RunOptions) string { + if opts.RefOverride != "" || opts.RepoOverride != "" { + return opts.RefOverride } + currentBranch, err := getCurrentBranch() + if err == nil { + executionLog.Printf("Using current branch for workflow run: %s", currentBranch) + return currentBranch + } + if opts.Verbose { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Note: Could not determine current branch: %v", err))) + } + return "" +} - // Execute gh workflow run command and capture output - cmd := workflow.ExecGHContext(ctx, args...) - +func handleWorkflowDryRun(lockFileName string, args []string, opts RunOptions) error { if opts.Verbose { - var cmdParts []string - cmdParts = append(cmdParts, "gh workflow run", lockFileName) - if opts.RepoOverride != "" { - cmdParts = append(cmdParts, "--repo", opts.RepoOverride) - } - if ref != "" { - cmdParts = append(cmdParts, "--ref", ref) - } - if len(opts.Inputs) > 0 { - for _, input := range opts.Inputs { - cmdParts = append(cmdParts, "-f", input) - } - } - fmt.Fprintln(os.Stderr, console.FormatCommandMessage(strings.Join(cmdParts, " "))) + fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Dry run mode - command that would be executed:")) + fmt.Fprintln(os.Stderr, console.FormatCommandMessage("gh "+strings.Join(args, " "))) } + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("✓ Validation passed for workflow: %s (dry run - not executed)", lockFileName))) + return nil +} - // Capture both stdout and stderr - stdout, err := cmd.Output() +func executeWorkflowRun(ctx context.Context, lockFileName string, args []string, opts RunOptions) (string, *WorkflowRunInfo, error, error) { + if opts.Verbose { + fmt.Fprintln(os.Stderr, console.FormatCommandMessage("gh "+strings.Join(args, " "))) + } + stdout, err := workflow.ExecGHContext(ctx, args...).Output() if err != nil { - // If there's an error, try to get stderr for better error reporting - var stderrOutput string - var exitError *exec.ExitError - if errors.As(err, &exitError) { - stderrOutput = string(exitError.Stderr) - fmt.Fprintf(os.Stderr, "%s", exitError.Stderr) - } - - // Restore workflow state if it was disabled and we enabled it (even on error) - if opts.Enable && wasDisabled && workflowID != 0 { - restoreWorkflowState(workflowIdOrName, workflowID, opts.RepoOverride, opts.Verbose) - } - - // Check if this is a permission error in a codespace - errorMsg := err.Error() + " " + stderrOutput - if isRunningInCodespace() && is403PermissionError(errorMsg) { - // Show specialized error message for codespace users - fmt.Fprint(os.Stderr, getCodespacePermissionErrorMessage()) - return errors.New("failed to run workflow on GitHub Actions: permission denied (403)") - } - - return fmt.Errorf("failed to run workflow on GitHub Actions: %w", err) + return "", nil, nil, formatWorkflowRunError(err) } - - // Display the output from gh workflow run output := strings.TrimSpace(string(stdout)) if output != "" { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(output)) } - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Successfully triggered workflow: "+lockFileName)) executionLog.Printf("Workflow triggered successfully: %s", lockFileName) + runInfo, runErr := resolveWorkflowRunInfo(lockFileName, output, opts) + return output, runInfo, runErr, nil +} + +func formatWorkflowRunError(err error) error { + var stderrOutput string + var exitError *exec.ExitError + if errors.As(err, &exitError) { + stderrOutput = string(exitError.Stderr) + fmt.Fprintf(os.Stderr, "%s", exitError.Stderr) + } + errorMsg := err.Error() + " " + stderrOutput + if isRunningInCodespace() && is403PermissionError(errorMsg) { + fmt.Fprint(os.Stderr, getCodespacePermissionErrorMessage()) + return errors.New("failed to run workflow on GitHub Actions: permission denied (403)") + } + return fmt.Errorf("failed to run workflow on GitHub Actions: %w", err) +} - // Try to get run info: first attempt to parse from gh workflow run output (new in v2.87+), - // then fall back to polling with getLatestWorkflowRunWithRetry for older gh CLI versions. - // Parsing failure is expected for older gh CLI versions and the fallback ensures backward compatibility. - var runInfo *WorkflowRunInfo - var runErr error +func resolveWorkflowRunInfo(lockFileName, output string, opts RunOptions) (*WorkflowRunInfo, error) { if parsedRunInfo := parseRunInfoFromOutput(output); parsedRunInfo != nil { executionLog.Printf("Parsed run info from gh output: id=%d, url=%s", parsedRunInfo.DatabaseID, parsedRunInfo.URL) - runInfo = parsedRunInfo - } else { - runInfo, runErr = getLatestWorkflowRunWithRetry(lockFileName, opts.RepoOverride, opts.Verbose) + return parsedRunInfo, nil } - if runErr == nil && runInfo.URL != "" { + return getLatestWorkflowRunWithRetry(lockFileName, opts.RepoOverride, opts.Verbose) +} + +func handleWorkflowRunInfo(output string, runInfo *WorkflowRunInfo, runErr error, opts RunOptions) { + if runErr == nil && runInfo != nil && runInfo.URL != "" { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("🔗 View workflow run: "+runInfo.URL)) executionLog.Printf("Workflow run URL: %s (ID: %d)", runInfo.URL, runInfo.DatabaseID) - - // Suggest audit command for analysis fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("💡 To analyze this run, use: %s audit %d", string(constants.CLIExtensionPrefix), runInfo.DatabaseID))) - } else if opts.Verbose && runErr != nil { + return + } + if opts.Verbose && runErr != nil { fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Note: Could not get workflow run URL: %v", runErr))) } +} - // Wait for workflow completion if requested (for --repeat or --auto-merge-prs) - if opts.WaitForCompletion || opts.AutoMergePRs { - if runErr != nil { - if opts.AutoMergePRs { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not get workflow run information for auto-merge: %v", runErr))) - } else if opts.WaitForCompletion { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not get workflow run information: %v", runErr))) - } - } else { - // Determine target repository: use repo override if provided, otherwise get current repo - targetRepo := opts.RepoOverride - if targetRepo == "" { - if currentRepo, err := GetCurrentRepoSlug(); err != nil { - if opts.AutoMergePRs { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not determine target repository for auto-merge: %v", err))) - } - targetRepo = "" - } else { - targetRepo = currentRepo - } - } - - if targetRepo != "" { - // Wait for workflow completion - if opts.AutoMergePRs { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Auto-merge PRs enabled - waiting for workflow completion...")) - } else { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Waiting for workflow completion...")) - } - - runIDStr := strconv.FormatInt(runInfo.DatabaseID, 10) - if err := WaitForWorkflowCompletion(ctx, targetRepo, runIDStr, workflowCompletionWaitTimeoutMinutes, opts.Verbose); err != nil { - // Propagate interrupts/cancellation so the caller (repeat loop) can stop - if ctx.Err() != nil || errors.Is(err, ErrInterrupted) { - return err - } - if opts.AutoMergePRs { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Workflow did not complete successfully, skipping auto-merge: %v", err))) - } else { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Workflow did not complete successfully: %v", err))) - } - } else { - // Auto-merge PRs if requested and workflow completed successfully - if opts.AutoMergePRs { - if err := AutoMergePullRequestsCreatedAfter(targetRepo, workflowStartTime, opts.Verbose); err != nil { - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to auto-merge pull requests: %v", err))) - } - } - } - } +func waitForWorkflowRunCompletion(ctx context.Context, opts RunOptions, runInfo *WorkflowRunInfo, runErr error, workflowStartTime time.Time, _ string) error { + if !opts.WaitForCompletion && !opts.AutoMergePRs { + return nil + } + if runErr != nil { + printWorkflowRunInfoWarning(opts, runErr) + return nil + } + targetRepo := resolveWorkflowTargetRepo(opts) + if targetRepo == "" || runInfo == nil { + return nil + } + printWorkflowWaitMessage(opts.AutoMergePRs) + runIDStr := strconv.FormatInt(runInfo.DatabaseID, 10) + if err := WaitForWorkflowCompletion(ctx, targetRepo, runIDStr, workflowCompletionWaitTimeoutMinutes, opts.Verbose); err != nil { + if ctx.Err() != nil || errors.Is(err, ErrInterrupted) { + return err + } + printWorkflowCompletionWarning(opts.AutoMergePRs, err) + return nil + } + if opts.AutoMergePRs { + if err := AutoMergePullRequestsCreatedAfter(targetRepo, workflowStartTime, opts.Verbose); err != nil { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to auto-merge pull requests: %v", err))) } } + return nil +} - // Restore workflow state if it was disabled and we enabled it - if opts.Enable && wasDisabled && workflowID != 0 { - restoreWorkflowState(workflowIdOrName, workflowID, opts.RepoOverride, opts.Verbose) +func printWorkflowRunInfoWarning(opts RunOptions, runErr error) { + if opts.AutoMergePRs { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not get workflow run information for auto-merge: %v", runErr))) + } else if opts.WaitForCompletion { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not get workflow run information: %v", runErr))) } +} - return nil +func resolveWorkflowTargetRepo(opts RunOptions) string { + if opts.RepoOverride != "" { + return opts.RepoOverride + } + currentRepo, err := GetCurrentRepoSlug() + if err != nil { + if opts.AutoMergePRs { + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Could not determine target repository for auto-merge: %v", err))) + } + return "" + } + return currentRepo } -// RunWorkflowsOnGitHub runs multiple agentic workflows on GitHub Actions, optionally repeating a specified number of times -func RunWorkflowsOnGitHub(ctx context.Context, workflowNames []string, opts RunOptions) error { - if len(workflowNames) == 0 { - return errors.New("at least one workflow name or ID is required") +func printWorkflowWaitMessage(autoMerge bool) { + message := "Waiting for workflow completion..." + if autoMerge { + message = "Auto-merge PRs enabled - waiting for workflow completion..." } + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(message)) +} - // Check context cancellation at the start - select { - case <-ctx.Done(): - fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Operation cancelled")) - return ctx.Err() - default: +func printWorkflowCompletionWarning(autoMerge bool, err error) { + message := fmt.Sprintf("Workflow did not complete successfully: %v", err) + if autoMerge { + message = fmt.Sprintf("Workflow did not complete successfully, skipping auto-merge: %v", err) } + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(message)) +} - // Validate all workflows exist and are runnable before starting +func restoreEnabledWorkflow(workflowIdOrName string, opts RunOptions, state workflowEnableState) { + if opts.Enable && state.wasDisabled && state.workflowID != 0 { + restoreWorkflowState(workflowIdOrName, state.workflowID, opts.RepoOverride, opts.Verbose) + } +} + +// validateWorkflowsForRun validates all workflow names before running. +func validateWorkflowsForRun(workflowNames []string, opts RunOptions) error { for _, workflowName := range workflowNames { if workflowName == "" { return errors.New("workflow name cannot be empty") } - - // Validate workflow exists if opts.RepoOverride != "" { - // For remote repositories, use remote validation if err := validateRemoteWorkflow(workflowName, opts.RepoOverride, opts.Verbose); err != nil { return fmt.Errorf("failed to validate remote workflow '%s': %w", workflowName, err) } } else { - // For local workflows, use existing local validation workflowFile, err := resolveWorkflowFile(workflowName, opts.Verbose) if err != nil { - // Return error directly without wrapping - it already contains formatted message with suggestions return err } - runnable, err := IsRunnable(workflowFile) if err != nil { return fmt.Errorf("failed to check if workflow '%s' is runnable: %w", workflowName, err) } - if !runnable { return fmt.Errorf("workflow '%s' cannot be run on GitHub Actions - it must have 'workflow_dispatch' trigger", workflowName) } } } + return nil +} - // Function to run all workflows once - runAllWorkflows := func() error { - fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Running %d workflow(s)...", len(workflowNames)))) - - for i, workflowName := range workflowNames { - // Check for cancellation before each workflow +// executeAllWorkflowsOnce runs each workflow name once in sequence. +func executeAllWorkflowsOnce(ctx context.Context, workflowNames []string, opts RunOptions) error { + fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Running %d workflow(s)...", len(workflowNames)))) + for i, workflowName := range workflowNames { + select { + case <-ctx.Done(): + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Operation cancelled")) + return ctx.Err() + default: + } + if len(workflowNames) > 1 { + fmt.Fprintln(os.Stderr, console.FormatProgressMessage(fmt.Sprintf("Running workflow %d/%d: %s", i+1, len(workflowNames), workflowName))) + } + workflowOpts := opts + if opts.RepeatCount > 0 { + workflowOpts.WaitForCompletion = true + } + if err := RunWorkflowOnGitHub(ctx, workflowName, workflowOpts); err != nil { + return fmt.Errorf("failed to run workflow '%s': %w", workflowName, err) + } + if i < len(workflowNames)-1 { + timer := time.NewTimer(betweenWorkflowsDelay) select { case <-ctx.Done(): + timer.Stop() fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Operation cancelled")) return ctx.Err() - default: - } - - if len(workflowNames) > 1 { - fmt.Fprintln(os.Stderr, console.FormatProgressMessage(fmt.Sprintf("Running workflow %d/%d: %s", i+1, len(workflowNames), workflowName))) - } - - // Create a copy of opts with WaitForCompletion set when using --repeat - workflowOpts := opts - if opts.RepeatCount > 0 { - workflowOpts.WaitForCompletion = true - } - - if err := RunWorkflowOnGitHub(ctx, workflowName, workflowOpts); err != nil { - return fmt.Errorf("failed to run workflow '%s': %w", workflowName, err) - } - - // Add a small delay between workflows to avoid overwhelming GitHub API - if i < len(workflowNames)-1 { - timer := time.NewTimer(betweenWorkflowsDelay) - select { - case <-ctx.Done(): - timer.Stop() - fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Operation cancelled")) - return ctx.Err() - case <-timer.C: - } + case <-timer.C: } } - - fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Successfully triggered %d workflow(s)", len(workflowNames)))) - return nil } + fmt.Fprintln(os.Stderr, console.FormatSuccessMessage(fmt.Sprintf("Successfully triggered %d workflow(s)", len(workflowNames)))) + return nil +} - // When JSON output is requested, wrap runAllWorkflows to emit a JSON summary - if opts.JSON { - runAllWorkflowsInner := runAllWorkflows - runAllWorkflows = func() error { - // Build per-workflow results - var results []WorkflowRunResult - for _, workflowName := range workflowNames { - normalizedID := normalizeWorkflowID(workflowName) - lockFileName := normalizedID + ".lock.yml" - status := "triggered" - if opts.DryRun { - status = "dry_run" - } - results = append(results, WorkflowRunResult{ - Workflow: normalizedID, - LockFile: lockFileName, - Status: status, - }) - } - - // Execute the actual runs (text output still goes to stderr) - runErr := runAllWorkflowsInner() - if runErr != nil { - // Mark all as error when we can't distinguish which failed - for i := range results { - results[i].Status = "error" - results[i].Error = runErr.Error() - } +// wrapRunWithJSONOutput wraps a run-all function to emit a JSON summary to stdout. +func wrapRunWithJSONOutput(inner func() error, workflowNames []string, opts RunOptions) func() error { + return func() error { + var results []WorkflowRunResult + for _, workflowName := range workflowNames { + normalizedID := normalizeWorkflowID(workflowName) + status := "triggered" + if opts.DryRun { + status = "dry_run" } - - // Output JSON to stdout - jsonBytes, err := json.MarshalIndent(results, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal JSON: %w", err) + results = append(results, WorkflowRunResult{Workflow: normalizedID, LockFile: normalizedID + ".lock.yml", Status: status}) + } + runErr := inner() + if runErr != nil { + for i := range results { + results[i].Status = "error" + results[i].Error = runErr.Error() } - fmt.Fprintln(os.Stdout, string(jsonBytes)) - return runErr } + jsonBytes, err := json.MarshalIndent(results, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON: %w", err) + } + fmt.Fprintln(os.Stdout, string(jsonBytes)) + return runErr } +} - // Execute workflows with optional repeat functionality +// RunWorkflowsOnGitHub runs multiple agentic workflows on GitHub Actions, optionally repeating a specified number of times +func RunWorkflowsOnGitHub(ctx context.Context, workflowNames []string, opts RunOptions) error { + if len(workflowNames) == 0 { + return errors.New("at least one workflow name or ID is required") + } + select { + case <-ctx.Done(): + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("Operation cancelled")) + return ctx.Err() + default: + } + if err := validateWorkflowsForRun(workflowNames, opts); err != nil { + return err + } + runAllWorkflows := func() error { + return executeAllWorkflowsOnce(ctx, workflowNames, opts) + } + if opts.JSON { + runAllWorkflows = wrapRunWithJSONOutput(runAllWorkflows, workflowNames, opts) + } return ExecuteWithRepeat(RepeatOptions{ Ctx: ctx, RepeatCount: opts.RepeatCount, RepeatMessage: "Repeating workflow run", ExecuteFunc: runAllWorkflows, - UseStderr: false, // Use stdout for run command + UseStderr: false, }) } diff --git a/pkg/workflow/engine.go b/pkg/workflow/engine.go index 8032698194e..28e25084ff1 100644 --- a/pkg/workflow/engine.go +++ b/pkg/workflow/engine.go @@ -169,6 +169,15 @@ type EngineNetworkConfig struct { Network *NetworkPermissions } +type engineTopLevelConfig struct { + maxTurns string + maxToolDenials string + maxAICredits int64 + maxTurnCacheMisses int + maxRuns int + model string +} + // GetMaxAICredits returns the configured engine AI credits budget, falling back to the default. func (e *EngineConfig) GetMaxAICredits() int64 { if e == nil || e.MaxAICredits == 0 { @@ -197,430 +206,391 @@ func (e *EngineConfig) GetMaxTurnCacheMisses() int { // ExtractEngineConfig extracts engine configuration from frontmatter, supporting both string and object formats. // It returns the resolved engine setting, the parsed engine configuration, and the resolved model string. func (c *Compiler) ExtractEngineConfig(frontmatter map[string]any) (string, *EngineConfig, string) { - topLevelMaxTurns := parseMaxTurnsValue(frontmatter["max-turns"]) - topLevelMaxToolDenials := parseMaxToolDenialsValue(frontmatter["max-tool-denials"]) - topLevelMaxAICredits := parseMaxAICreditsValue(frontmatter["max-ai-credits"]) - topLevelMaxTurnCacheMisses := parseMaxTurnCacheMissesValue(frontmatter["max-turn-cache-misses"]) - topLevelMaxRuns := parseMaxRunsValue(frontmatter["max-turns"]) - if topLevelMaxRuns == 0 { - topLevelMaxRuns = parseMaxRunsValue(frontmatter["max-runs"]) - } - topLevelModel, _ := frontmatter["model"].(string) - - if engine, exists := frontmatter["engine"]; exists { - engineLog.Print("Extracting engine configuration from frontmatter") - - // Handle string format (backwards compatibility) - if engineStr, ok := engine.(string); ok { - engineLog.Printf("Found engine in string format: %s", engineStr) - return engineStr, &EngineConfig{ - ID: engineStr, - MaxTurns: topLevelMaxTurns, - MaxToolDenials: topLevelMaxToolDenials, - MaxRuns: topLevelMaxRuns, - MaxTurnCacheMisses: topLevelMaxTurnCacheMisses, - MaxAICredits: topLevelMaxAICredits, - }, topLevelModel - } - - // Handle object format - if engineObj, ok := engine.(map[string]any); ok { - engineLog.Print("Found engine in object format, parsing configuration") - config := &EngineConfig{} - resolvedModel := "" - - // Detect inline definition: engine.runtime sub-object present instead of engine.id - if runtime, hasRuntime := engineObj["runtime"]; hasRuntime { - engineLog.Print("Found inline engine definition (engine.runtime sub-object)") - config.IsInlineDefinition = true - - if runtimeObj, ok := runtime.(map[string]any); ok { - if id, ok := runtimeObj["id"].(string); ok { - config.ID = id - engineLog.Printf("Inline engine runtime.id: %s", config.ID) - } - if version, hasVersion := runtimeObj["version"]; hasVersion { - config.Version = stringutil.ParseVersionValue(version) - } - } - - // Extract optional provider override: - // - string form: engine.provider: "openai" - // - object form: engine.provider.id / auth / request - if provider, hasProvider := engineObj["provider"]; hasProvider { - switch providerTyped := provider.(type) { - case string: - config.InlineProviderID = strings.ToLower(strings.TrimSpace(providerTyped)) - case map[string]any: - if id, ok := providerTyped["id"].(string); ok { - config.InlineProviderID = id - } - if model, ok := providerTyped["model"].(string); ok { - resolvedModel = model - } - if auth, hasAuth := providerTyped["auth"]; hasAuth { - if authObj, ok := auth.(map[string]any); ok { - authDef := parseAuthDefinition(authObj) - // Only store an AuthDefinition when the user actually provided - // at least one recognised field. An empty map (e.g. `auth: {}`) - // must not be treated as an explicit auth override. - if authDef.Strategy != "" || authDef.Secret != "" || - authDef.TokenURL != "" || authDef.ClientIDRef != "" || - authDef.ClientSecretRef != "" || authDef.HeaderName != "" || - authDef.TokenField != "" { - config.InlineProviderAuth = authDef - } - } - } - if request, hasRequest := providerTyped["request"]; hasRequest { - if requestObj, ok := request.(map[string]any); ok { - config.InlineProviderRequest = parseRequestShape(requestObj) - } - } - } - } - - // Extract optional 'bare' field (shared with non-inline path) - if bare, hasBare := engineObj["bare"]; hasBare { - if bareBool, ok := bare.(bool); ok { - config.Bare = bareBool - engineLog.Printf("Extracted bare mode (inline): %v", config.Bare) - } - } - // Extract optional 'permission-mode' field (shared with non-inline path) - if permissionMode, hasPermissionMode := engineObj["permission-mode"]; hasPermissionMode { - if permissionModeStr, ok := permissionMode.(string); ok { - config.PermissionMode = permissionModeStr - } - } - if topLevelMaxTurns != "" { - config.MaxTurns = topLevelMaxTurns - } - if topLevelMaxToolDenials != "" { - config.MaxToolDenials = topLevelMaxToolDenials - } - config.MaxRuns = topLevelMaxRuns - config.MaxTurnCacheMisses = topLevelMaxTurnCacheMisses - config.MaxAICredits = topLevelMaxAICredits - if model, hasModel := engineObj["model"]; hasModel { - if modelStr, ok := model.(string); ok { - resolvedModel = modelStr - fmt.Fprintln(os.Stderr, console.FormatWarningMessage("'engine.model' is deprecated. Use top-level 'model' instead. Run 'gh aw fix' to automatically migrate.")) - } - } - if topLevelModel != "" { - resolvedModel = topLevelModel - } - - engineLog.Printf("Extracted inline engine definition: runtimeID=%s, providerID=%s", config.ID, config.InlineProviderID) - return config.ID, config, resolvedModel - } + topLevel := parseTopLevelEngineConfig(frontmatter) + engine, exists := frontmatter["engine"] + if !exists { + return buildTopLevelOnlyEngineConfig(topLevel) + } + engineLog.Print("Extracting engine configuration from frontmatter") + if engineStr, ok := engine.(string); ok { + return extractStringEngineConfig(engineStr, topLevel) + } + if engineObj, ok := engine.(map[string]any); ok { + return extractObjectEngineConfig(engineObj, topLevel) + } + return buildTopLevelOnlyEngineConfig(topLevel) +} - // Extract required 'id' field - if id, hasID := engineObj["id"]; hasID { - if idStr, ok := id.(string); ok { - config.ID = idStr - } - } +func parseTopLevelEngineConfig(frontmatter map[string]any) engineTopLevelConfig { + topLevel := engineTopLevelConfig{ + maxTurns: parseMaxTurnsValue(frontmatter["max-turns"]), + maxToolDenials: parseMaxToolDenialsValue(frontmatter["max-tool-denials"]), + maxAICredits: parseMaxAICreditsValue(frontmatter["max-ai-credits"]), + maxTurnCacheMisses: parseMaxTurnCacheMissesValue(frontmatter["max-turn-cache-misses"]), + maxRuns: parseMaxRunsValue(frontmatter["max-turns"]), + } + if topLevel.maxRuns == 0 { + topLevel.maxRuns = parseMaxRunsValue(frontmatter["max-runs"]) + } + topLevel.model, _ = frontmatter["model"].(string) + return topLevel +} - // Extract optional 'version' field - if version, hasVersion := engineObj["version"]; hasVersion { - config.Version = stringutil.ParseVersionValue(version) - } +func extractStringEngineConfig(engineStr string, topLevel engineTopLevelConfig) (string, *EngineConfig, string) { + engineLog.Printf("Found engine in string format: %s", engineStr) + return engineStr, &EngineConfig{ + ID: engineStr, + MaxTurns: topLevel.maxTurns, + MaxToolDenials: topLevel.maxToolDenials, + MaxRuns: topLevel.maxRuns, + MaxTurnCacheMisses: topLevel.maxTurnCacheMisses, + MaxAICredits: topLevel.maxAICredits, + }, topLevel.model +} - // Extract optional 'model' field - if model, hasModel := engineObj["model"]; hasModel { - if modelStr, ok := model.(string); ok { - resolvedModel = modelStr - fmt.Fprintln(os.Stderr, console.FormatWarningMessage("'engine.model' is deprecated. Use top-level 'model' instead. Run 'gh aw fix' to automatically migrate.")) - } - } - // Top-level 'model' takes precedence over engine.model. - if topLevelModel != "" { - resolvedModel = topLevelModel - } +func extractObjectEngineConfig(engineObj map[string]any, topLevel engineTopLevelConfig) (string, *EngineConfig, string) { + engineLog.Print("Found engine in object format, parsing configuration") + if runtime, hasRuntime := engineObj["runtime"]; hasRuntime { + return extractInlineEngineConfig(runtime, engineObj, topLevel) + } + return extractReferencedEngineConfig(engineObj, topLevel) +} - // Extract optional 'model-provider' field. - providerValue, hasProvider := engineObj["model-provider"] - if hasProvider { - if providerStr, ok := providerValue.(string); ok { - config.LLMProvider = strings.ToLower(strings.TrimSpace(providerStr)) - } - } - // Extract optional 'provider' shorthand override for built-in engines. - // Inline definitions are excluded here because they use engine.provider object - // semantics (id/auth/request) parsed in the engine.runtime branch above. - if providerValue, hasProvider := engineObj["provider"]; hasProvider && !config.IsInlineDefinition { - if providerStr, ok := providerValue.(string); ok { - config.LLMProvider = strings.ToLower(strings.TrimSpace(providerStr)) - } - } +func extractInlineEngineConfig(runtime any, engineObj map[string]any, topLevel engineTopLevelConfig) (string, *EngineConfig, string) { + engineLog.Print("Found inline engine definition (engine.runtime sub-object)") + config := &EngineConfig{IsInlineDefinition: true} + resolvedModel := "" + if runtimeObj, ok := runtime.(map[string]any); ok { + if id, ok := runtimeObj["id"].(string); ok { + config.ID = id + engineLog.Printf("Inline engine runtime.id: %s", config.ID) + } + if version, hasVersion := runtimeObj["version"]; hasVersion { + config.Version = stringutil.ParseVersionValue(version) + } + } + resolvedModel = extractInlineProviderConfig(config, engineObj["provider"]) + applyInlineEngineFields(config, engineObj, topLevel) + resolvedModel = resolveEngineModel(engineObj, topLevel, resolvedModel) + engineLog.Printf("Extracted inline engine definition: runtimeID=%s, providerID=%s", config.ID, config.InlineProviderID) + return config.ID, config, resolvedModel +} - // Extract optional 'permission-mode' field - if permissionMode, hasPermissionMode := engineObj["permission-mode"]; hasPermissionMode { - if permissionModeStr, ok := permissionMode.(string); ok { - config.PermissionMode = permissionModeStr - } +func extractInlineProviderConfig(config *EngineConfig, provider any) string { + switch providerTyped := provider.(type) { + case string: + config.InlineProviderID = normalizeEngineProvider(providerTyped) + case map[string]any: + if id, ok := providerTyped["id"].(string); ok { + config.InlineProviderID = id + } + model, _ := providerTyped["model"].(string) + if authObj, ok := providerTyped["auth"].(map[string]any); ok { + if authDef := parseNonEmptyAuthDefinition(authObj); authDef != nil { + config.InlineProviderAuth = authDef } + } + if requestObj, ok := providerTyped["request"].(map[string]any); ok { + config.InlineProviderRequest = parseRequestShape(requestObj) + } + return model + } + return "" +} - // Extract optional 'max-turns' field (deprecated alias for top-level max-turns). - // Use parseMaxTurnsValue for consistent validation: rejects negative values and - // arbitrary strings while preserving valid integers and GitHub Actions expressions. - if maxTurns, hasMaxTurns := engineObj["max-turns"]; hasMaxTurns { - config.MaxTurns = parseMaxTurnsValue(maxTurns) - } - if topLevelMaxTurns != "" { - config.MaxTurns = topLevelMaxTurns - } - if topLevelMaxToolDenials != "" { - config.MaxToolDenials = topLevelMaxToolDenials - } +func parseNonEmptyAuthDefinition(authObj map[string]any) *AuthDefinition { + authDef := parseAuthDefinition(authObj) + if authDef.Strategy != "" || authDef.Secret != "" || authDef.TokenURL != "" || + authDef.ClientIDRef != "" || authDef.ClientSecretRef != "" || authDef.HeaderName != "" || + authDef.TokenField != "" { + return authDef + } + return nil +} - // Extract optional 'max-continuations' field - if maxCont, hasMaxCont := engineObj["max-continuations"]; hasMaxCont { - if val, ok := typeutil.ParseIntValue(maxCont); ok { - config.MaxContinuations = val - } else if maxContStr, ok := maxCont.(string); ok { - if parsed, err := strconv.Atoi(maxContStr); err == nil { - config.MaxContinuations = parsed - } - } - } +func applyInlineEngineFields(config *EngineConfig, engineObj map[string]any, topLevel engineTopLevelConfig) { + applyEngineBareField(config, engineObj) + applyEnginePermissionMode(config, engineObj) + config.MaxTurns = topLevel.maxTurns + config.MaxToolDenials = topLevel.maxToolDenials + config.MaxRuns = topLevel.maxRuns + config.MaxTurnCacheMisses = topLevel.maxTurnCacheMisses + config.MaxAICredits = topLevel.maxAICredits +} - // Extract optional 'concurrency' field (string or object format) - if concurrency, hasConcurrency := engineObj["concurrency"]; hasConcurrency { - if concurrencyStr, ok := concurrency.(string); ok { - // Simple string format (group name) - config.Concurrency = fmt.Sprintf("concurrency:\n group: \"%s\"", concurrencyStr) - } else if concurrencyObj, ok := concurrency.(map[string]any); ok { - // Object format with group and optional cancel-in-progress - var parts []string - if group, hasGroup := concurrencyObj["group"]; hasGroup { - if groupStr, ok := group.(string); ok { - parts = append(parts, fmt.Sprintf("concurrency:\n group: \"%s\"", groupStr)) - } - } - if cancel, hasCancel := concurrencyObj["cancel-in-progress"]; hasCancel { - if cancelBool, ok := cancel.(bool); ok && cancelBool { - if len(parts) > 0 { - parts[0] += "\n cancel-in-progress: true" - } - } - } - if queue, hasQueue := concurrencyObj["queue"]; hasQueue { - if queueStr, ok := queue.(string); ok && queueStr != "" { - if len(parts) > 0 { - parts[0] += "\n queue: " + queueStr - } - } - } - if len(parts) > 0 { - config.Concurrency = parts[0] - } - } - } +func extractReferencedEngineConfig(engineObj map[string]any, topLevel engineTopLevelConfig) (string, *EngineConfig, string) { + config := &EngineConfig{} + if id, ok := engineObj["id"].(string); ok { + config.ID = id + } + if version, hasVersion := engineObj["version"]; hasVersion { + config.Version = stringutil.ParseVersionValue(version) + } + resolvedModel := resolveEngineModel(engineObj, topLevel, "") + applyReferencedEngineFields(config, engineObj, topLevel) + engineLog.Printf("Extracted engine configuration: ID=%s", config.ID) + return config.ID, config, resolvedModel +} - // Extract optional 'user-agent' field - if userAgent, hasUserAgent := engineObj["user-agent"]; hasUserAgent { - if userAgentStr, ok := userAgent.(string); ok { - config.UserAgent = userAgentStr - } - } +func applyReferencedEngineFields(config *EngineConfig, engineObj map[string]any, topLevel engineTopLevelConfig) { + applyEngineProviderFields(config, engineObj) + applyEnginePermissionMode(config, engineObj) + applyEngineTurnFields(config, engineObj, topLevel) + applyEngineConcurrencyField(config, engineObj) + applyEngineStringFields(config, engineObj) + applyEngineHarnessField(config, engineObj) + applyEngineEnvField(config, engineObj) + applyEngineAuthField(config, engineObj) + applyEngineArgsField(config, engineObj) + applyEngineMCPField(config, engineObj) + applyEngineExtensionsField(config, engineObj) + applyEngineBooleanFields(config, engineObj) + applyEngineTopLevelOverrides(config, topLevel) +} - // Extract optional 'command' field - if command, hasCommand := engineObj["command"]; hasCommand { - if commandStr, ok := command.(string); ok { - config.Command = commandStr - } - } +func resolveEngineModel(engineObj map[string]any, topLevel engineTopLevelConfig, fallback string) string { + if modelStr, ok := engineObj["model"].(string); ok { + fallback = modelStr + fmt.Fprintln(os.Stderr, console.FormatWarningMessage("'engine.model' is deprecated. Use top-level 'model' instead. Run 'gh aw fix' to automatically migrate.")) + } + if topLevel.model != "" { + return topLevel.model + } + return fallback +} - // Extract optional 'harness' field: - // - string form (legacy): engine.harness: "custom.cjs" → sets HarnessScript - // - object form: engine.harness: { use: "custom.cjs", max-retries: N, ... } - if harness, hasHarness := engineObj["harness"]; hasHarness { - switch h := harness.(type) { - case string: - config.HarnessScript = h - case map[string]any: - if use, ok := h["use"].(string); ok { - config.HarnessScript = use - } - if v, ok := h["max-retries"]; ok { - config.HarnessMaxRetries = parseHarnessMaxRetriesValue(v) - } - if v, ok := h["initial-delay-ms"]; ok { - config.HarnessInitialDelayMs = parseMaxTurnsValue(v) - } - if v, ok := h["backoff-multiplier"]; ok { - config.HarnessBackoffMultiplier = parseMaxTurnsValue(v) - } - if v, ok := h["max-delay-ms"]; ok { - config.HarnessMaxDelayMs = parseMaxTurnsValue(v) - } - } - } +func applyEngineProviderFields(config *EngineConfig, engineObj map[string]any) { + if providerStr, ok := engineObj["model-provider"].(string); ok { + config.LLMProvider = normalizeEngineProvider(providerStr) + } + if providerStr, ok := engineObj["provider"].(string); ok && !config.IsInlineDefinition { + config.LLMProvider = normalizeEngineProvider(providerStr) + } +} - // Extract optional 'driver' field (string - validated separately). - if driver, hasDriver := engineObj["driver"]; hasDriver { - if driverStr, ok := driver.(string); ok { - config.Driver = driverStr - engineLog.Printf("Extracted engine.driver: %s", driverStr) - } - } +func normalizeEngineProvider(provider string) string { + return strings.ToLower(strings.TrimSpace(provider)) +} - // Extract optional 'env' field (object/map of scalar values) - if env, hasEnv := engineObj["env"]; hasEnv { - if envMap, ok := env.(map[string]any); ok { - config.Env = make(map[string]string) - for key, value := range envMap { - if valueStr, ok := toEngineEnvValueString(value); ok { - config.Env[key] = valueStr - } - } - } - } +func applyEnginePermissionMode(config *EngineConfig, engineObj map[string]any) { + if permissionMode, ok := engineObj["permission-mode"].(string); ok { + config.PermissionMode = permissionMode + } +} - // Extract optional 'auth' field (object) - if auth, hasAuth := engineObj["auth"]; hasAuth { - if authObj, ok := auth.(map[string]any); ok { - config.Auth = parseEngineAuthConfig(authObj) - applyEngineAuthEnv(config) - } +func applyEngineTurnFields(config *EngineConfig, engineObj map[string]any, topLevel engineTopLevelConfig) { + if maxTurns, hasMaxTurns := engineObj["max-turns"]; hasMaxTurns { + config.MaxTurns = parseMaxTurnsValue(maxTurns) + } + if topLevel.maxTurns != "" { + config.MaxTurns = topLevel.maxTurns + } + config.MaxToolDenials = topLevel.maxToolDenials + if maxCont, hasMaxCont := engineObj["max-continuations"]; hasMaxCont { + if val, ok := typeutil.ParseIntValue(maxCont); ok { + config.MaxContinuations = val + } else if maxContStr, ok := maxCont.(string); ok { + if parsed, err := strconv.Atoi(maxContStr); err == nil { + config.MaxContinuations = parsed } + } + } +} - // Extract optional 'config' field (additional TOML configuration) - if config_field, hasConfig := engineObj["config"]; hasConfig { - if configStr, ok := config_field.(string); ok { - config.Config = configStr - } - } +func applyEngineConcurrencyField(config *EngineConfig, engineObj map[string]any) { + concurrency, hasConcurrency := engineObj["concurrency"] + if !hasConcurrency { + return + } + if concurrencyStr, ok := concurrency.(string); ok { + config.Concurrency = fmt.Sprintf("concurrency:\n group: \"%s\"", concurrencyStr) + return + } + concurrencyObj, ok := concurrency.(map[string]any) + if !ok { + return + } + if groupStr, ok := concurrencyObj["group"].(string); ok { + config.Concurrency = fmt.Sprintf("concurrency:\n group: \"%s\"", groupStr) + } + if cancelBool, ok := concurrencyObj["cancel-in-progress"].(bool); ok && cancelBool && config.Concurrency != "" { + config.Concurrency += "\n cancel-in-progress: true" + } + if queueStr, ok := concurrencyObj["queue"].(string); ok && queueStr != "" && config.Concurrency != "" { + config.Concurrency += "\n queue: " + queueStr + } +} - // Extract optional 'args' field (array of strings) - if args, hasArgs := engineObj["args"]; hasArgs { - if argsArray, ok := args.([]any); ok { - config.Args = make([]string, 0, len(argsArray)) - for _, arg := range argsArray { - if argStr, ok := arg.(string); ok { - config.Args = append(config.Args, argStr) - } - } - } else if argsStrArray, ok := args.([]string); ok { - config.Args = argsStrArray - } - } +func applyEngineStringFields(config *EngineConfig, engineObj map[string]any) { + if userAgent, ok := engineObj["user-agent"].(string); ok { + config.UserAgent = userAgent + } + if command, ok := engineObj["command"].(string); ok { + config.Command = command + } + if driver, ok := engineObj["driver"].(string); ok { + config.Driver = driver + engineLog.Printf("Extracted engine.driver: %s", driver) + } + if configStr, ok := engineObj["config"].(string); ok { + config.Config = configStr + } + if agent, ok := engineObj["agent"].(string); ok { + config.Agent = agent + engineLog.Printf("Extracted agent identifier: %s", agent) + } + if apiTarget, ok := engineObj["api-target"].(string); ok && apiTarget != "" { + config.APITarget = apiTarget + engineLog.Printf("Extracted api-target: %s", config.APITarget) + } + if cwd, ok := engineObj["cwd"].(string); ok && cwd != "" { + config.Cwd = cwd + engineLog.Printf("Extracted engine.cwd: %s", config.Cwd) + } +} - // Extract optional 'agent' field (string - copilot engine only) - if agent, hasAgent := engineObj["agent"]; hasAgent { - if agentStr, ok := agent.(string); ok { - config.Agent = agentStr - engineLog.Printf("Extracted agent identifier: %s", agentStr) - } - } +func applyEngineHarnessField(config *EngineConfig, engineObj map[string]any) { + harness, hasHarness := engineObj["harness"] + if !hasHarness { + return + } + switch h := harness.(type) { + case string: + config.HarnessScript = h + case map[string]any: + if use, ok := h["use"].(string); ok { + config.HarnessScript = use + } + if v, ok := h["max-retries"]; ok { + config.HarnessMaxRetries = parseHarnessMaxRetriesValue(v) + } + if v, ok := h["initial-delay-ms"]; ok { + config.HarnessInitialDelayMs = parseMaxTurnsValue(v) + } + if v, ok := h["backoff-multiplier"]; ok { + config.HarnessBackoffMultiplier = parseMaxTurnsValue(v) + } + if v, ok := h["max-delay-ms"]; ok { + config.HarnessMaxDelayMs = parseMaxTurnsValue(v) + } + } +} - // Extract optional 'api-target' field (custom API endpoint for any engine) - if apiTarget, hasAPITarget := engineObj["api-target"]; hasAPITarget { - if apiTargetStr, ok := apiTarget.(string); ok && apiTargetStr != "" { - config.APITarget = apiTargetStr - engineLog.Printf("Extracted api-target: %s", apiTargetStr) - } - } +func applyEngineEnvField(config *EngineConfig, engineObj map[string]any) { + envMap, ok := engineObj["env"].(map[string]any) + if !ok { + return + } + config.Env = make(map[string]string) + for key, value := range envMap { + if valueStr, ok := toEngineEnvValueString(value); ok { + config.Env[key] = valueStr + } + } +} - // Extract optional 'bare' field (disable automatic context/instruction loading) - if bare, hasBare := engineObj["bare"]; hasBare { - if bareBool, ok := bare.(bool); ok { - config.Bare = bareBool - engineLog.Printf("Extracted bare mode: %v", config.Bare) - } - } +func applyEngineAuthField(config *EngineConfig, engineObj map[string]any) { + authObj, ok := engineObj["auth"].(map[string]any) + if !ok { + return + } + config.Auth = parseEngineAuthConfig(authObj) + applyEngineAuthEnv(config) +} - // Extract optional 'mcp' sub-object (engine-level MCP gateway configuration) - if mcpVal, hasMCP := engineObj["mcp"]; hasMCP { - if mcpObj, ok := mcpVal.(map[string]any); ok { - // Extract session-timeout (kebab-case only; camelCase is not supported) - if stVal, hasSessionTimeout := mcpObj["session-timeout"]; hasSessionTimeout { - if stStr, ok := stVal.(string); ok && stStr != "" { - config.MCPSessionTimeout = stStr - engineLog.Printf("Extracted engine.mcp.session-timeout: %s", config.MCPSessionTimeout) - } - } - // Extract tool-timeout (kebab-case only; camelCase is not supported) - if ttVal, hasToolTimeout := mcpObj["tool-timeout"]; hasToolTimeout { - if ttStr, ok := ttVal.(string); ok && ttStr != "" { - config.MCPToolTimeout = ttStr - engineLog.Printf("Extracted engine.mcp.tool-timeout: %s", config.MCPToolTimeout) - } - } - } +func applyEngineArgsField(config *EngineConfig, engineObj map[string]any) { + switch args := engineObj["args"].(type) { + case []any: + config.Args = make([]string, 0, len(args)) + for _, arg := range args { + if argStr, ok := arg.(string); ok { + config.Args = append(config.Args, argStr) } + } + case []string: + config.Args = args + } +} - // Extract optional 'extensions' field (array of strings; used by the Pi engine) - if extVal, hasExt := engineObj["extensions"]; hasExt { - switch v := extVal.(type) { - case []any: - config.Extensions = make([]string, 0, len(v)) - for _, ext := range v { - if extStr, ok := ext.(string); ok && extStr != "" { - config.Extensions = append(config.Extensions, extStr) - } - } - engineLog.Printf("Extracted engine.extensions: %v", config.Extensions) - case []string: - config.Extensions = make([]string, 0, len(v)) - for _, ext := range v { - if ext != "" { - config.Extensions = append(config.Extensions, ext) - } - } - engineLog.Printf("Extracted engine.extensions ([]string): %v", config.Extensions) - default: - engineLog.Printf("Unexpected type for engine.extensions: %T, ignoring", extVal) - } - } +func applyEngineMCPField(config *EngineConfig, engineObj map[string]any) { + mcpObj, ok := engineObj["mcp"].(map[string]any) + if !ok { + return + } + if sessionTimeout, ok := mcpObj["session-timeout"].(string); ok && sessionTimeout != "" { + config.MCPSessionTimeout = sessionTimeout + engineLog.Printf("Extracted engine.mcp.session-timeout: %s", config.MCPSessionTimeout) + } + if toolTimeout, ok := mcpObj["tool-timeout"].(string); ok && toolTimeout != "" { + config.MCPToolTimeout = toolTimeout + engineLog.Printf("Extracted engine.mcp.tool-timeout: %s", config.MCPToolTimeout) + } +} - // Return the ID as the engineSetting for backwards compatibility - if topLevelMaxTurns != "" { - config.MaxTurns = topLevelMaxTurns +func applyEngineExtensionsField(config *EngineConfig, engineObj map[string]any) { + switch v := engineObj["extensions"].(type) { + case []any: + config.Extensions = make([]string, 0, len(v)) + for _, ext := range v { + if extStr, ok := ext.(string); ok && extStr != "" { + config.Extensions = append(config.Extensions, extStr) } - config.MaxRuns = topLevelMaxRuns - config.MaxTurnCacheMisses = topLevelMaxTurnCacheMisses - config.MaxAICredits = topLevelMaxAICredits - - // Extract optional 'copilot-sdk' field (bool; copilot engine only) - if sdkVal, hasSDK := engineObj["copilot-sdk"]; hasSDK { - if sdkBool, ok := sdkVal.(bool); ok { - config.CopilotSDK = sdkBool - engineLog.Printf("Extracted copilot-sdk: %v", config.CopilotSDK) - } - } - if config.Driver != "" && config.ID == "copilot" && !config.CopilotSDK { - config.CopilotSDK = true - engineLog.Print("Enabled copilot-sdk because driver is configured for copilot engine") + } + case []string: + config.Extensions = make([]string, 0, len(v)) + for _, ext := range v { + if ext != "" { + config.Extensions = append(config.Extensions, ext) } + } + case nil: + return + default: + engineLog.Printf("Unexpected type for engine.extensions: %T, ignoring", engineObj["extensions"]) + return + } + engineLog.Printf("Extracted engine.extensions: %v", config.Extensions) +} - // Extract optional 'cwd' field (templatable string for engine working directory) - if cwdVal, hasCwd := engineObj["cwd"]; hasCwd { - if cwdStr, ok := cwdVal.(string); ok && cwdStr != "" { - config.Cwd = cwdStr - engineLog.Printf("Extracted engine.cwd: %s", config.Cwd) - } - } +func applyEngineBooleanFields(config *EngineConfig, engineObj map[string]any) { + applyEngineBareField(config, engineObj) + if sdkVal, ok := engineObj["copilot-sdk"].(bool); ok { + config.CopilotSDK = sdkVal + engineLog.Printf("Extracted copilot-sdk: %v", config.CopilotSDK) + } + if config.Driver != "" && config.ID == "copilot" && !config.CopilotSDK { + config.CopilotSDK = true + engineLog.Print("Enabled copilot-sdk because driver is configured for copilot engine") + } +} - engineLog.Printf("Extracted engine configuration: ID=%s", config.ID) - return config.ID, config, resolvedModel - } +func applyEngineBareField(config *EngineConfig, engineObj map[string]any) { + if bare, ok := engineObj["bare"].(bool); ok { + config.Bare = bare + engineLog.Printf("Extracted bare mode: %v", config.Bare) } +} - if topLevelMaxTurns != "" || topLevelMaxToolDenials != "" || topLevelMaxAICredits != 0 || topLevelMaxRuns > 0 || topLevelMaxTurnCacheMisses > 0 || topLevelModel != "" { - return "", &EngineConfig{ - MaxTurns: topLevelMaxTurns, - MaxToolDenials: topLevelMaxToolDenials, - MaxRuns: topLevelMaxRuns, - MaxTurnCacheMisses: topLevelMaxTurnCacheMisses, - MaxAICredits: topLevelMaxAICredits, - }, topLevelModel +func applyEngineTopLevelOverrides(config *EngineConfig, topLevel engineTopLevelConfig) { + if topLevel.maxTurns != "" { + config.MaxTurns = topLevel.maxTurns } + config.MaxRuns = topLevel.maxRuns + config.MaxTurnCacheMisses = topLevel.maxTurnCacheMisses + config.MaxAICredits = topLevel.maxAICredits +} - // No engine specified +func buildTopLevelOnlyEngineConfig(topLevel engineTopLevelConfig) (string, *EngineConfig, string) { + if topLevel.maxTurns != "" || topLevel.maxToolDenials != "" || topLevel.maxAICredits != 0 || + topLevel.maxRuns > 0 || topLevel.maxTurnCacheMisses > 0 || topLevel.model != "" { + return "", &EngineConfig{ + MaxTurns: topLevel.maxTurns, + MaxToolDenials: topLevel.maxToolDenials, + MaxRuns: topLevel.maxRuns, + MaxTurnCacheMisses: topLevel.maxTurnCacheMisses, + MaxAICredits: topLevel.maxAICredits, + }, topLevel.model + } engineLog.Print("No engine configuration found in frontmatter") return "", nil, "" } @@ -697,60 +667,24 @@ func applyEngineAuthEnv(config *EngineConfig) { if config.Env == nil { config.Env = make(map[string]string) } + setEngineAuthEnv(config.Env, "AWF_AUTH_TYPE", config.Auth.Type) + setEngineAuthEnv(config.Env, "AWF_AUTH_OIDC_AUDIENCE", config.Auth.Audience) + setEngineAuthEnv(config.Env, "AWF_AUTH_AZURE_TENANT_ID", config.Auth.AzureTenantID) + setEngineAuthEnv(config.Env, "AWF_AUTH_AZURE_CLIENT_ID", config.Auth.AzureClientID) + setEngineAuthEnv(config.Env, "AWF_AUTH_AZURE_SCOPE", config.Auth.AzureScope) + setEngineAuthEnv(config.Env, "AWF_AUTH_AZURE_CLOUD", config.Auth.AzureCloud) + setEngineAuthEnv(config.Env, "AWF_AUTH_PROVIDER", config.Auth.Provider) + setEngineAuthEnv(config.Env, "AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID", config.Auth.AnthropicFederationRuleID) + setEngineAuthEnv(config.Env, "AWF_AUTH_ANTHROPIC_ORGANIZATION_ID", config.Auth.AnthropicOrganizationID) + setEngineAuthEnv(config.Env, "AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID", config.Auth.AnthropicServiceAccountID) + setEngineAuthEnv(config.Env, "AWF_AUTH_ANTHROPIC_WORKSPACE_ID", config.Auth.AnthropicWorkspaceID) +} - if config.Auth.Type != "" { - if _, exists := config.Env["AWF_AUTH_TYPE"]; !exists { - config.Env["AWF_AUTH_TYPE"] = config.Auth.Type - } - } - if config.Auth.Audience != "" { - if _, exists := config.Env["AWF_AUTH_OIDC_AUDIENCE"]; !exists { - config.Env["AWF_AUTH_OIDC_AUDIENCE"] = config.Auth.Audience - } - } - if config.Auth.AzureTenantID != "" { - if _, exists := config.Env["AWF_AUTH_AZURE_TENANT_ID"]; !exists { - config.Env["AWF_AUTH_AZURE_TENANT_ID"] = config.Auth.AzureTenantID - } - } - if config.Auth.AzureClientID != "" { - if _, exists := config.Env["AWF_AUTH_AZURE_CLIENT_ID"]; !exists { - config.Env["AWF_AUTH_AZURE_CLIENT_ID"] = config.Auth.AzureClientID - } - } - if config.Auth.AzureScope != "" { - if _, exists := config.Env["AWF_AUTH_AZURE_SCOPE"]; !exists { - config.Env["AWF_AUTH_AZURE_SCOPE"] = config.Auth.AzureScope - } - } - if config.Auth.AzureCloud != "" { - if _, exists := config.Env["AWF_AUTH_AZURE_CLOUD"]; !exists { - config.Env["AWF_AUTH_AZURE_CLOUD"] = config.Auth.AzureCloud - } - } - if config.Auth.Provider != "" { - if _, exists := config.Env["AWF_AUTH_PROVIDER"]; !exists { - config.Env["AWF_AUTH_PROVIDER"] = config.Auth.Provider - } - } - if config.Auth.AnthropicFederationRuleID != "" { - if _, exists := config.Env["AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID"]; !exists { - config.Env["AWF_AUTH_ANTHROPIC_FEDERATION_RULE_ID"] = config.Auth.AnthropicFederationRuleID - } - } - if config.Auth.AnthropicOrganizationID != "" { - if _, exists := config.Env["AWF_AUTH_ANTHROPIC_ORGANIZATION_ID"]; !exists { - config.Env["AWF_AUTH_ANTHROPIC_ORGANIZATION_ID"] = config.Auth.AnthropicOrganizationID - } - } - if config.Auth.AnthropicServiceAccountID != "" { - if _, exists := config.Env["AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID"]; !exists { - config.Env["AWF_AUTH_ANTHROPIC_SERVICE_ACCOUNT_ID"] = config.Auth.AnthropicServiceAccountID - } +func setEngineAuthEnv(env map[string]string, key, value string) { + if value == "" { + return } - if config.Auth.AnthropicWorkspaceID != "" { - if _, exists := config.Env["AWF_AUTH_ANTHROPIC_WORKSPACE_ID"]; !exists { - config.Env["AWF_AUTH_ANTHROPIC_WORKSPACE_ID"] = config.Auth.AnthropicWorkspaceID - } + if _, exists := env[key]; !exists { + env[key] = value } } diff --git a/pkg/workflow/frontmatter_on_section_cleanup.go b/pkg/workflow/frontmatter_on_section_cleanup.go index bc43b163f61..a5183b59c59 100644 --- a/pkg/workflow/frontmatter_on_section_cleanup.go +++ b/pkg/workflow/frontmatter_on_section_cleanup.go @@ -13,701 +13,587 @@ import ( func (c *Compiler) commentOutProcessedFieldsInOnSection(yamlStr string, frontmatter map[string]any) string { frontmatterLog.Print("Processing 'on' section to comment out processed fields") - // Check frontmatter for native label filter markers - nativeLabelFilterSections := make(map[string]struct { - }) - if onValue, exists := frontmatter["on"]; exists { - if onMap, ok := onValue.(map[string]any); ok { - for _, sectionKey := range []string{"issues", "pull_request", "discussion", "issue_comment"} { - if sectionValue, hasSec := onMap[sectionKey]; hasSec { - if sectionMap, ok := sectionValue.(map[string]any); ok { - if marker, hasMarker := sectionMap["__gh_aw_native_label_filter__"]; hasMarker { - if useNative, ok := marker.(bool); ok && useNative { - nativeLabelFilterSections[sectionKey] = struct { - }{} - frontmatterLog.Printf("Section %s uses native label filtering", sectionKey) - } - } - } - } - } - } - } - + nativeLabelFilterSections := collectNativeLabelFilterSections(frontmatter) + state := newOnSectionCleanupState() lines := strings.Split(yamlStr, "\n") - var result []string - inPullRequest := false - inIssues := false - inDiscussion := false - inIssueComment := false - inDeploymentStatus := false - inWorkflowRun := false - inWorkflowRunConclusionArray := false - inForksArray := false - inSkipIfMatch := false - inSkipIfNoMatch := false - inSkipIfCheckFailing := false - inSkipAuthorAssociations := false - inSkipRolesArray := false - inSkipBotsArray := false - inRolesArray := false - inBotsArray := false - inLabelsArray := false - inNeedsArray := false - inGitHubApp := false - inOnSteps := false - inOnPermissions := false - // Track the leading indentation of the current run of consecutive commented-out - // lines. yamllint's comments-indentation rule flags any comment that is indented - // deeper than the comment above it, so we flatten every line of a commented block - // to the indentation of the block's first line (which sits at the surrounding - // content's level). inCommentBlock is false until the first line of a block is - // emitted and is reset whenever a real (uncommented) line is written. - commentBlockIndent := "" - inCommentBlock := false - currentSection := "" // Track which section we're in ("issues", "pull_request", "discussion", or "issue_comment") - currentSectionIndent := -1 - deploymentStatusIndent := -1 - workflowRunIndent := -1 - // activateEventSection resets all event-section flags and then activates the selected section. - // It also clears every top-level on: extension-array tracker (inBotsArray, inRolesArray, - // inSkipIfCheckFailing, etc.) before entering the new section. This reset is required - // because each activateEventSection call ends with "continue", which bypasses the - // indent-based deactivation logic further down the loop. Without the explicit reset here, - // a stale flag from a preceding bots:/roles:/skip-if-check-failing: block would cause that - // section's list items (e.g. "workflow_run.workflows: - CI") to be incorrectly commented out. - activateEventSection := func(section string, indent int) { - // Clear all top-level on: extension-array state so no sibling section leaks in. - inSkipRolesArray = false - inSkipBotsArray = false - inRolesArray = false - inBotsArray = false - inLabelsArray = false - inNeedsArray = false - // These trackers share the same exit-check-ordering issue: their deactivation - // logic runs after the "continue" that terminates each activateEventSection call, - // so they must also be reset here explicitly. - inSkipIfMatch = false - inSkipIfNoMatch = false - inSkipIfCheckFailing = false - inSkipAuthorAssociations = false - - // Reset the comment-block anchor so the first commented line of the new - // section uses its own indentation rather than a stale indent from the - // previous section (which would otherwise persist because activateEventSection - // ends with "continue" and bypasses the normal else-branch reset). - inCommentBlock = false - commentBlockIndent = "" - - inPullRequest = section == "pull_request" - inIssues = section == "issues" - inDiscussion = section == "discussion" - inIssueComment = section == "issue_comment" - inDeploymentStatus = section == "deployment_status" - inWorkflowRun = section == "workflow_run" - inWorkflowRunConclusionArray = false - inForksArray = false - - switch section { - case "pull_request", "issues", "discussion", "issue_comment": - currentSection = section - currentSectionIndent = indent - default: - currentSection = "" - currentSectionIndent = -1 - } + result := make([]string, 0, len(lines)) - if section == "deployment_status" { - deploymentStatusIndent = indent - } else { - deploymentStatusIndent = -1 + for _, line := range lines { + info := newOnSectionLine(line) + if state.handleEventSectionEntry(info, &result) { + continue } - if section == "workflow_run" { - workflowRunIndent = indent - } else { - workflowRunIndent = -1 + state.leaveEventSections(info) + if state.shouldSkipNativeLabelFilterMarker(info) { + continue } + state.enterSubsections(info) + state.leaveSubsections(info) + shouldComment, commentReason := state.determineComment(info, result, nativeLabelFilterSections) + result = state.appendLine(result, info, shouldComment, commentReason) } - for _, line := range lines { - trimmedLine := strings.TrimSpace(line) - lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) - - // Check if we're entering a pull_request, issues, discussion, or issue_comment section. - // Skip these checks when inside on.permissions or on.steps to avoid false matches. - // Example: ` issues: read` inside on.permissions was previously matched as the - // `issues:` event trigger, incorrectly entering the inIssues state and suppressing - // the permission comment-out logic. - if !inOnPermissions && !inOnSteps && !inSkipAuthorAssociations { - if (lineIndent == 2 || lineIndent == 4) && trimmedLine == "pull_request:" { - activateEventSection("pull_request", lineIndent) - result = append(result, line) - continue - } - if (lineIndent == 2 || lineIndent == 4) && trimmedLine == "issues:" { - activateEventSection("issues", lineIndent) - result = append(result, line) - continue - } - if (lineIndent == 2 || lineIndent == 4) && trimmedLine == "discussion:" { - activateEventSection("discussion", lineIndent) - result = append(result, line) - continue - } - if (lineIndent == 2 || lineIndent == 4) && trimmedLine == "issue_comment:" { - activateEventSection("issue_comment", lineIndent) - result = append(result, line) - continue - } - if (lineIndent == 2 || lineIndent == 4) && trimmedLine == "deployment_status:" { - activateEventSection("deployment_status", lineIndent) - result = append(result, line) - continue - } - if (lineIndent == 2 || lineIndent == 4) && trimmedLine == "workflow_run:" { - activateEventSection("workflow_run", lineIndent) - result = append(result, line) - continue - } - } + result = dedentTrailingOnCommentBlock(result) + return strings.Join(result, "\n") +} - // Check if we're leaving the pull_request, issues, discussion, or issue_comment section (new top-level key or end of indent) - if inPullRequest || inIssues || inDiscussion || inIssueComment { - // If line is at or above section indentation, we're out of the section. - if strings.TrimSpace(line) != "" && !strings.HasPrefix(trimmedLine, "#") && - currentSectionIndent >= 0 && lineIndent <= currentSectionIndent { - inPullRequest = false - inIssues = false - inDiscussion = false - inIssueComment = false - inForksArray = false - currentSection = "" - currentSectionIndent = -1 - } - } +type onSectionLine struct { + raw string + trimmed string + indent int +} - // Check if we're leaving the deployment_status section - if inDeploymentStatus && strings.TrimSpace(line) != "" && !strings.HasPrefix(trimmedLine, "#") && - deploymentStatusIndent >= 0 && lineIndent <= deploymentStatusIndent { - inDeploymentStatus = false - deploymentStatusIndent = -1 - } +func newOnSectionLine(raw string) onSectionLine { + return onSectionLine{ + raw: raw, + trimmed: strings.TrimSpace(raw), + indent: len(raw) - len(strings.TrimLeft(raw, " ")), + } +} - // Check if we're leaving the workflow_run section - if inWorkflowRun && strings.TrimSpace(line) != "" && !strings.HasPrefix(trimmedLine, "#") && - workflowRunIndent >= 0 && lineIndent <= workflowRunIndent { - inWorkflowRun = false - inWorkflowRunConclusionArray = false - workflowRunIndent = -1 - } +type onSectionCleanupState struct { + inPullRequest bool + inIssues bool + inDiscussion bool + inIssueComment bool + inDeploymentStatus bool + inWorkflowRun bool + inWorkflowRunConclusionArray bool + inForksArray bool + inSkipIfMatch bool + inSkipIfNoMatch bool + inSkipIfCheckFailing bool + inSkipAuthorAssociations bool + inSkipRolesArray bool + inSkipBotsArray bool + inRolesArray bool + inBotsArray bool + inLabelsArray bool + inNeedsArray bool + inGitHubApp bool + inOnSteps bool + inOnPermissions bool + commentBlockIndent string + inCommentBlock bool + currentSection string + currentSectionIndent int + deploymentStatusIndent int + workflowRunIndent int +} - // Skip marker lines in the YAML output - if (inPullRequest || inIssues || inDiscussion || inIssueComment) && strings.Contains(trimmedLine, "__gh_aw_native_label_filter__:") { - // Don't include the marker line in the output - continue - } +func newOnSectionCleanupState() *onSectionCleanupState { + return &onSectionCleanupState{ + currentSectionIndent: -1, + deploymentStatusIndent: -1, + workflowRunIndent: -1, + } +} - // Check if we're entering the forks array - if inPullRequest && strings.HasPrefix(trimmedLine, "forks:") { - inForksArray = true +func collectNativeLabelFilterSections(frontmatter map[string]any) map[string]struct{} { + sections := make(map[string]struct{}) + onValue, exists := frontmatter["on"] + if !exists { + return sections + } + onMap, ok := onValue.(map[string]any) + if !ok { + return sections + } + for _, sectionKey := range []string{"issues", "pull_request", "discussion", "issue_comment"} { + sectionValue, hasSection := onMap[sectionKey] + if !hasSection { + continue } - - // Check if we're entering skip-roles array - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && strings.HasPrefix(trimmedLine, "skip-roles:") { - // Check if this is an array (next line will be "- ") - // We'll set the flag and handle it on the next iteration - inSkipRolesArray = true + sectionMap, ok := sectionValue.(map[string]any) + if !ok { + continue } - - // Check if we're entering skip-bots array - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && strings.HasPrefix(trimmedLine, "skip-bots:") { - // Check if this is an array (next line will be "- ") - // We'll set the flag and handle it on the next iteration - inSkipBotsArray = true + marker, hasMarker := sectionMap["__gh_aw_native_label_filter__"] + useNative, ok := marker.(bool) + if hasMarker && ok && useNative { + sections[sectionKey] = struct{}{} + frontmatterLog.Printf("Section %s uses native label filtering", sectionKey) } + } + return sections +} - // Check if we're entering roles field - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && strings.HasPrefix(trimmedLine, "roles:") { - // Check if this is an array (next line will be "- ") or inline value - inRolesArray = true - } +func (s *onSectionCleanupState) inEventSection() bool { + return s.inPullRequest || s.inIssues || s.inDiscussion || s.inIssueComment +} - // Check if we're entering bots array - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && strings.HasPrefix(trimmedLine, "bots:") { - // Check if this is an array (next line will be "- ") or inline value - inBotsArray = true - } +func (s *onSectionCleanupState) handleEventSectionEntry(info onSectionLine, result *[]string) bool { + section, ok := s.detectEventSection(info) + if !ok { + return false + } + s.activateEventSection(section, info.indent) + *result = append(*result, info.raw) + return true +} - // Check if we're entering labels array - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && - !inOnSteps && !inOnPermissions && - lineIndent == 2 && trimmedLine == "labels:" { - inLabelsArray = true - } +func (s *onSectionCleanupState) detectEventSection(info onSectionLine) (string, bool) { + if s.inOnPermissions || s.inOnSteps || s.inSkipAuthorAssociations { + return "", false + } + if info.indent != 2 && info.indent != 4 { + return "", false + } + switch info.trimmed { + case "pull_request:", "issues:", "discussion:", "issue_comment:", "deployment_status:", "workflow_run:": + return strings.TrimSuffix(info.trimmed, ":"), true + default: + return "", false + } +} - // Check if we're entering needs array - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && - !inOnSteps && !inOnPermissions && - lineIndent == 2 && strings.HasPrefix(trimmedLine, "needs:") { - inNeedsArray = true - } +func (s *onSectionCleanupState) activateEventSection(section string, indent int) { + s.resetTopLevelExtensionState() + s.inCommentBlock = false + s.commentBlockIndent = "" + s.inPullRequest = section == "pull_request" + s.inIssues = section == "issues" + s.inDiscussion = section == "discussion" + s.inIssueComment = section == "issue_comment" + s.inDeploymentStatus = section == "deployment_status" + s.inWorkflowRun = section == "workflow_run" + s.inWorkflowRunConclusionArray = false + s.inForksArray = false + s.currentSection, s.currentSectionIndent = "", -1 + if s.inEventSection() { + s.currentSection, s.currentSectionIndent = section, indent + } + s.deploymentStatusIndent = -1 + if section == "deployment_status" { + s.deploymentStatusIndent = indent + } + s.workflowRunIndent = -1 + if section == "workflow_run" { + s.workflowRunIndent = indent + } +} - // Check if we're entering on.steps array - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && strings.HasPrefix(trimmedLine, "steps:") { - inOnSteps = true - } +func (s *onSectionCleanupState) resetTopLevelExtensionState() { + s.inSkipRolesArray = false + s.inSkipBotsArray = false + s.inRolesArray = false + s.inBotsArray = false + s.inLabelsArray = false + s.inNeedsArray = false + s.inSkipIfMatch = false + s.inSkipIfNoMatch = false + s.inSkipIfCheckFailing = false + s.inSkipAuthorAssociations = false +} - // Check if we're entering on.permissions object - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && !inOnPermissions && - strings.HasPrefix(trimmedLine, "permissions:") { - inOnPermissions = true - } +func (s *onSectionCleanupState) leaveEventSections(info onSectionLine) { + s.leaveCurrentEventSection(info) + s.leaveDeploymentStatusSection(info) + s.leaveWorkflowRunSection(info) +} - // Check if we're entering skip-if-match object - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && !inSkipIfMatch { - // Check both uncommented and commented forms - if (strings.HasPrefix(trimmedLine, "skip-if-match:") && trimmedLine == "skip-if-match:") || - (strings.HasPrefix(trimmedLine, "# skip-if-match:") && strings.Contains(trimmedLine, "pre-activation job")) { - inSkipIfMatch = true - } - } +func (s *onSectionCleanupState) leaveCurrentEventSection(info onSectionLine) { + if !s.inEventSection() || info.trimmed == "" || strings.HasPrefix(info.trimmed, "#") { + return + } + if s.currentSectionIndent >= 0 && info.indent <= s.currentSectionIndent { + s.inPullRequest = false + s.inIssues = false + s.inDiscussion = false + s.inIssueComment = false + s.inForksArray = false + s.currentSection = "" + s.currentSectionIndent = -1 + } +} - // Check if we're entering skip-if-no-match object - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && !inSkipIfNoMatch { - // Check both uncommented and commented forms - if (strings.HasPrefix(trimmedLine, "skip-if-no-match:") && trimmedLine == "skip-if-no-match:") || - (strings.HasPrefix(trimmedLine, "# skip-if-no-match:") && strings.Contains(trimmedLine, "pre-activation job")) { - inSkipIfNoMatch = true - } - } +func (s *onSectionCleanupState) leaveDeploymentStatusSection(info onSectionLine) { + if !s.inDeploymentStatus || info.trimmed == "" || strings.HasPrefix(info.trimmed, "#") { + return + } + if s.deploymentStatusIndent >= 0 && info.indent <= s.deploymentStatusIndent { + s.inDeploymentStatus = false + s.deploymentStatusIndent = -1 + } +} - // Check if we're entering skip-if-check-failing object - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && !inSkipIfCheckFailing { - // Check both uncommented and commented forms - if trimmedLine == "skip-if-check-failing:" || - (strings.HasPrefix(trimmedLine, "# skip-if-check-failing:") && strings.Contains(trimmedLine, "pre-activation job")) { - inSkipIfCheckFailing = true - } - } +func (s *onSectionCleanupState) leaveWorkflowRunSection(info onSectionLine) { + if !s.inWorkflowRun || info.trimmed == "" || strings.HasPrefix(info.trimmed, "#") { + return + } + if s.workflowRunIndent >= 0 && info.indent <= s.workflowRunIndent { + s.inWorkflowRun = false + s.inWorkflowRunConclusionArray = false + s.workflowRunIndent = -1 + } +} - // Check if we're entering skip-author-associations object - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && !inSkipAuthorAssociations { - if strings.HasPrefix(trimmedLine, "skip-author-associations:") && trimmedLine == "skip-author-associations:" { - inSkipAuthorAssociations = true - } - } +func (s *onSectionCleanupState) shouldSkipNativeLabelFilterMarker(info onSectionLine) bool { + return s.inEventSection() && strings.Contains(info.trimmed, "__gh_aw_native_label_filter__:") +} - // Check if we're entering github-app object - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment && !inGitHubApp { - // Check both uncommented and commented forms - if (strings.HasPrefix(trimmedLine, "github-app:") && trimmedLine == "github-app:") || - (strings.HasPrefix(trimmedLine, "# github-app:") && strings.Contains(trimmedLine, "pre-activation job")) { - inGitHubApp = true - } - } +func (s *onSectionCleanupState) enterSubsections(info onSectionLine) { + s.enterArraySections(info) + s.enterObjectSections(info) +} - // Check if we're leaving skip-if-match object (encountering another top-level field) - // Skip this check if we just entered skip-if-match on this line - if inSkipIfMatch && strings.TrimSpace(line) != "" && - !strings.HasPrefix(trimmedLine, "skip-if-match:") && - !strings.HasPrefix(trimmedLine, "# skip-if-match:") { - // Get the indentation of the current line - lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) - // If this is a field at same level as skip-if-match (2 spaces) and not a comment, we're out of skip-if-match - if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "#") { - inSkipIfMatch = false - } - } +func (s *onSectionCleanupState) enterArraySections(info onSectionLine) { + if s.inPullRequest && strings.HasPrefix(info.trimmed, "forks:") { + s.inForksArray = true + } + if !s.inEventSection() && strings.HasPrefix(info.trimmed, "skip-roles:") { + s.inSkipRolesArray = true + } + if !s.inEventSection() && strings.HasPrefix(info.trimmed, "skip-bots:") { + s.inSkipBotsArray = true + } + if !s.inEventSection() && strings.HasPrefix(info.trimmed, "roles:") { + s.inRolesArray = true + } + if !s.inEventSection() && strings.HasPrefix(info.trimmed, "bots:") { + s.inBotsArray = true + } + if !s.inEventSection() && !s.inOnSteps && !s.inOnPermissions && info.indent == 2 && info.trimmed == "labels:" { + s.inLabelsArray = true + } + if !s.inEventSection() && !s.inOnSteps && !s.inOnPermissions && info.indent == 2 && strings.HasPrefix(info.trimmed, "needs:") { + s.inNeedsArray = true + } + if !s.inEventSection() && strings.HasPrefix(info.trimmed, "steps:") { + s.inOnSteps = true + } + if !s.inEventSection() && !s.inOnPermissions && strings.HasPrefix(info.trimmed, "permissions:") { + s.inOnPermissions = true + } +} - // Check if we're leaving skip-if-no-match object (encountering another top-level field) - // Skip this check if we just entered skip-if-no-match on this line - if inSkipIfNoMatch && strings.TrimSpace(line) != "" && - !strings.HasPrefix(trimmedLine, "skip-if-no-match:") && - !strings.HasPrefix(trimmedLine, "# skip-if-no-match:") { - // Get the indentation of the current line - lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) - // If this is a field at same level as skip-if-no-match (2 spaces) and not a comment, we're out of skip-if-no-match - if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "#") { - inSkipIfNoMatch = false - } - } +func (s *onSectionCleanupState) enterObjectSections(info onSectionLine) { + if !s.inEventSection() && !s.inSkipIfMatch && ((strings.HasPrefix(info.trimmed, "skip-if-match:") && info.trimmed == "skip-if-match:") || + (strings.HasPrefix(info.trimmed, "# skip-if-match:") && strings.Contains(info.trimmed, "pre-activation job"))) { + s.inSkipIfMatch = true + } + if !s.inEventSection() && !s.inSkipIfNoMatch && ((strings.HasPrefix(info.trimmed, "skip-if-no-match:") && info.trimmed == "skip-if-no-match:") || + (strings.HasPrefix(info.trimmed, "# skip-if-no-match:") && strings.Contains(info.trimmed, "pre-activation job"))) { + s.inSkipIfNoMatch = true + } + if !s.inEventSection() && !s.inSkipIfCheckFailing && (info.trimmed == "skip-if-check-failing:" || + (strings.HasPrefix(info.trimmed, "# skip-if-check-failing:") && strings.Contains(info.trimmed, "pre-activation job"))) { + s.inSkipIfCheckFailing = true + } + if !s.inEventSection() && !s.inSkipAuthorAssociations && strings.HasPrefix(info.trimmed, "skip-author-associations:") && info.trimmed == "skip-author-associations:" { + s.inSkipAuthorAssociations = true + } + if !s.inEventSection() && !s.inGitHubApp && ((strings.HasPrefix(info.trimmed, "github-app:") && info.trimmed == "github-app:") || + (strings.HasPrefix(info.trimmed, "# github-app:") && strings.Contains(info.trimmed, "pre-activation job"))) { + s.inGitHubApp = true + } +} - // Check if we're leaving skip-if-check-failing object (encountering another top-level field) - // Skip this check if we just entered skip-if-check-failing on this line - if inSkipIfCheckFailing && strings.TrimSpace(line) != "" && - !strings.HasPrefix(trimmedLine, "skip-if-check-failing:") && - !strings.HasPrefix(trimmedLine, "# skip-if-check-failing:") { - // Get the indentation of the current line - lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) - // If this is a field at same level as skip-if-check-failing (2 spaces) and not a comment, we're out - if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "#") { - inSkipIfCheckFailing = false - } - } +func (s *onSectionCleanupState) leaveSubsections(info onSectionLine) { + s.leaveObjectSections(info) + s.leaveArraySections(info) + s.leaveStepsAndPermissions(info) +} - // Check if we're leaving skip-author-associations object (encountering another top-level field) - if inSkipAuthorAssociations && strings.TrimSpace(line) != "" && - !strings.HasPrefix(trimmedLine, "skip-author-associations:") && - !strings.HasPrefix(trimmedLine, "# skip-author-associations:") { - currentIndent := len(line) - len(strings.TrimLeft(line, " \t")) - if currentIndent == 2 && !strings.HasPrefix(trimmedLine, "#") { - inSkipAuthorAssociations = false - } - } +func (s *onSectionCleanupState) leaveObjectSections(info onSectionLine) { + if s.inSkipIfMatch && isLeavingTopLevelObject(info, "skip-if-match:", "# skip-if-match:") { + s.inSkipIfMatch = false + } + if s.inSkipIfNoMatch && isLeavingTopLevelObject(info, "skip-if-no-match:", "# skip-if-no-match:") { + s.inSkipIfNoMatch = false + } + if s.inSkipIfCheckFailing && isLeavingTopLevelObject(info, "skip-if-check-failing:", "# skip-if-check-failing:") { + s.inSkipIfCheckFailing = false + } + if s.inSkipAuthorAssociations && isLeavingTopLevelObject(info, "skip-author-associations:", "# skip-author-associations:") { + s.inSkipAuthorAssociations = false + } + if s.inGitHubApp && isLeavingTopLevelObject(info, "github-app:", "# github-app:") { + s.inGitHubApp = false + } +} - // Check if we're leaving github-app object (encountering another top-level field) - // Skip this check if we just entered github-app on this line - if inGitHubApp && strings.TrimSpace(line) != "" && - !strings.HasPrefix(trimmedLine, "github-app:") && - !strings.HasPrefix(trimmedLine, "# github-app:") { - // Get the indentation of the current line - lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) - // If this is a field at same level as github-app (2 spaces) and not a comment, we're out of github-app - if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "#") { - inGitHubApp = false - } - } +func (s *onSectionCleanupState) leaveArraySections(info onSectionLine) { + if s.inForksArray && s.inPullRequest && isLeavingArray(info, "forks:", 4) { + s.inForksArray = false + } + if s.inSkipRolesArray && isLeavingArray(info, "skip-roles:", 2) { + s.inSkipRolesArray = false + } + if s.inSkipBotsArray && isLeavingArray(info, "skip-bots:", 2) { + s.inSkipBotsArray = false + } + if s.inRolesArray && isLeavingArray(info, "roles:", 2) { + s.inRolesArray = false + } + if s.inBotsArray && isLeavingArray(info, "bots:", 2) { + s.inBotsArray = false + } + if s.inLabelsArray && isLeavingArray(info, "labels:", 2) { + s.inLabelsArray = false + } + if s.inNeedsArray && isLeavingArray(info, "needs:", 2) { + s.inNeedsArray = false + } +} - // Check if we're leaving the forks array by encountering another top-level field at the same level - if inForksArray && inPullRequest && strings.TrimSpace(line) != "" { - // Get the indentation of the current line - lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) +func (s *onSectionCleanupState) leaveStepsAndPermissions(info onSectionLine) { + if s.inOnSteps && isLeavingArray(info, "steps:", 2) { + s.inOnSteps = false + } + if s.inOnPermissions && isLeavingTopLevelObject(info, "permissions:", "# permissions:") { + s.inOnPermissions = false + } +} - // If this is a non-dash line at the same level as the forks field (4 spaces), we're out of the array - if lineIndent == 4 && !strings.HasPrefix(trimmedLine, "-") && !strings.HasPrefix(trimmedLine, "forks:") { - inForksArray = false - } - } +func isLeavingTopLevelObject(info onSectionLine, key, commentedKey string) bool { + return info.trimmed != "" && + !strings.HasPrefix(info.trimmed, key) && + !strings.HasPrefix(info.trimmed, commentedKey) && + info.indent == 2 && + !strings.HasPrefix(info.trimmed, "#") +} - // Check if we're leaving the skip-roles array by encountering another top-level field - if inSkipRolesArray && strings.TrimSpace(line) != "" { - // Get the indentation of the current line - lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) +func isLeavingArray(info onSectionLine, key string, indent int) bool { + return info.trimmed != "" && + info.indent == indent && + !strings.HasPrefix(info.trimmed, "-") && + !strings.HasPrefix(info.trimmed, key) && + !strings.HasPrefix(info.trimmed, "#") +} - // If this is a non-dash line at the same level as skip-roles (2 spaces), we're out of the array - if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "-") && !strings.HasPrefix(trimmedLine, "skip-roles:") && !strings.HasPrefix(trimmedLine, "#") { - inSkipRolesArray = false - } +func (s *onSectionCleanupState) determineComment(info onSectionLine, result []string, native map[string]struct{}) (bool, string) { + if !s.inEventSection() { + if shouldComment, reason := s.determineTopLevelComment(info); shouldComment { + return true, reason } + } + return s.determineEventSectionComment(info, result, native) +} - // Check if we're leaving the skip-bots array by encountering another top-level field - if inSkipBotsArray && strings.TrimSpace(line) != "" { - // Get the indentation of the current line - lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) - - // If this is a non-dash line at the same level as skip-bots (2 spaces), we're out of the array - if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "-") && !strings.HasPrefix(trimmedLine, "skip-bots:") && !strings.HasPrefix(trimmedLine, "#") { - inSkipBotsArray = false - } +func (s *onSectionCleanupState) determineTopLevelComment(info onSectionLine) (bool, string) { + if s.inEventSection() { + return false, "" + } + for _, fn := range []func(onSectionLine) (bool, string){ + s.commentSimpleTopLevelField, + s.commentConditionalObjectField, + s.commentRoleAndLabelField, + s.commentStepPermissionAndAppField, + } { + if shouldComment, reason := fn(info); shouldComment { + return true, reason } + } + return false, "" +} - // Check if we're leaving the roles array by encountering another top-level field - if inRolesArray && strings.TrimSpace(line) != "" { - // Get the indentation of the current line - lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) +func (s *onSectionCleanupState) commentSimpleTopLevelField(info onSectionLine) (bool, string) { + switch { + case strings.HasPrefix(info.trimmed, "manual-approval:"): + return true, " # Manual approval processed as environment field in activation job" + case strings.HasPrefix(info.trimmed, "stop-after:"): + return true, " # Stop-after processed as stop-time check in pre-activation job" + case strings.HasPrefix(info.trimmed, "restore-memory:"): + return true, " # Restore-memory enables pre-activation memory restore" + case strings.HasPrefix(info.trimmed, "reaction:"): + return true, " # Reaction processed as activation job step" + case strings.HasPrefix(info.trimmed, "github-token:"): + return true, " # GitHub token used for reactions and status comments in activation" + case strings.HasPrefix(info.trimmed, "stale-check:"): + return true, " # Stale-check processed as frontmatter hash check step in activation job" + default: + return false, "" + } +} - // If this is a non-dash line at the same level as roles (2 spaces), we're out of the array - if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "-") && !strings.HasPrefix(trimmedLine, "roles:") && !strings.HasPrefix(trimmedLine, "#") { - inRolesArray = false - } - } +func (s *onSectionCleanupState) commentConditionalObjectField(info onSectionLine) (bool, string) { + switch { + case strings.HasPrefix(info.trimmed, "skip-if-match:"): + return true, " # Skip-if-match processed as search check in pre-activation job" + case s.inSkipIfMatch && hasAnyPrefixInLine(info.trimmed, "query:", "max:", "scope:"): + return true, "" + case strings.HasPrefix(info.trimmed, "skip-if-no-match:"): + return true, " # Skip-if-no-match processed as search check in pre-activation job" + case s.inSkipIfNoMatch && hasAnyPrefixInLine(info.trimmed, "query:", "min:", "scope:"): + return true, "" + case strings.HasPrefix(info.trimmed, "skip-if-check-failing:"): + return true, " # Skip-if-check-failing processed as check status gate in pre-activation job" + case s.inSkipIfCheckFailing && (hasAnyPrefixInLine(info.trimmed, "include:", "exclude:", "branch:", "allow-pending:") || strings.HasPrefix(info.trimmed, "-")): + return true, "" + case strings.HasPrefix(info.trimmed, "skip-author-associations:"): + return true, " # Skip-author-associations compiled into pre-activation job if condition" + case s.inSkipAuthorAssociations && info.indent > 2: + return true, "" + default: + return false, "" + } +} - // Check if we're leaving the bots array by encountering another top-level field - if inBotsArray && strings.TrimSpace(line) != "" { - // Get the indentation of the current line - lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) +func (s *onSectionCleanupState) commentRoleAndLabelField(info onSectionLine) (bool, string) { + switch { + case strings.HasPrefix(info.trimmed, "skip-roles:"): + return true, " # Skip-roles processed as role check in pre-activation job" + case s.inSkipRolesArray && strings.HasPrefix(info.trimmed, "-"): + return true, " # Skip-roles processed as role check in pre-activation job" + case strings.HasPrefix(info.trimmed, "skip-bots:"): + return true, " # Skip-bots processed as bot check in pre-activation job" + case s.inSkipBotsArray && strings.HasPrefix(info.trimmed, "-"): + return true, " # Skip-bots processed as bot check in pre-activation job" + case strings.HasPrefix(info.trimmed, "roles:"): + return true, " # Roles processed as role check in pre-activation job" + case s.inRolesArray && strings.HasPrefix(info.trimmed, "-"): + return true, " # Roles processed as role check in pre-activation job" + case strings.HasPrefix(info.trimmed, "bots:"): + return true, " # Bots processed as bot check in pre-activation job" + case s.inBotsArray && strings.HasPrefix(info.trimmed, "-"): + return true, " # Bots processed as bot check in pre-activation job" + default: + return s.commentLabelAndNeedsField(info) + } +} - // If this is a non-dash line at the same level as bots (2 spaces), we're out of the array - if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "-") && !strings.HasPrefix(trimmedLine, "bots:") && !strings.HasPrefix(trimmedLine, "#") { - inBotsArray = false - } - } +func (s *onSectionCleanupState) commentLabelAndNeedsField(info onSectionLine) (bool, string) { + switch { + case !s.inOnSteps && !s.inOnPermissions && info.indent == 2 && strings.HasPrefix(info.trimmed, "labels:"): + return true, " # Label filtering applied via job conditions" + case s.inLabelsArray && strings.HasPrefix(info.trimmed, "-"): + return true, " # Label filtering applied via job conditions" + case !s.inOnSteps && !s.inOnPermissions && info.indent == 2 && strings.HasPrefix(info.trimmed, "needs:"): + return true, " # Needs processed as dependency in pre-activation job" + case s.inNeedsArray && strings.HasPrefix(info.trimmed, "-"): + return true, " # Needs processed as dependency in pre-activation job" + default: + return false, "" + } +} - // Check if we're leaving the labels array by encountering another top-level field - if inLabelsArray && strings.TrimSpace(line) != "" { - // Get the indentation of the current line - lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) +func (s *onSectionCleanupState) commentStepPermissionAndAppField(info onSectionLine) (bool, string) { + switch { + case strings.HasPrefix(info.trimmed, "steps:"): + return true, " # Steps injected into pre-activation job" + case s.inOnSteps: + return true, "" + case strings.HasPrefix(info.trimmed, "permissions:"): + return true, " # Permissions applied to pre-activation job" + case s.inOnPermissions: + return true, "" + case strings.HasPrefix(info.trimmed, "github-app:"): + return true, " # GitHub App used to mint token for reactions and status comments in activation" + case s.inGitHubApp && isGitHubAppNestedField(info.trimmed): + return true, "" + default: + return false, "" + } +} - // If this is a non-dash line at the same level as labels (2 spaces), we're out of the array - if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "-") && !strings.HasPrefix(trimmedLine, "labels:") && !strings.HasPrefix(trimmedLine, "#") { - inLabelsArray = false - } +func hasAnyPrefixInLine(line string, prefixes ...string) bool { + for _, prefix := range prefixes { + if strings.HasPrefix(line, prefix) { + return true } + } + return false +} - // Check if we're leaving the needs array by encountering another top-level field - if inNeedsArray && strings.TrimSpace(line) != "" { - // Get the indentation of the current line - lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) +func (s *onSectionCleanupState) determineEventSectionComment(info onSectionLine, result []string, native map[string]struct{}) (bool, string) { + if shouldComment, reason := s.commentPullRequestAndTriggerField(info); shouldComment { + return true, reason + } + if s.inWorkflowRun && !strings.HasPrefix(info.trimmed, "-") && strings.Contains(info.trimmed, ":") && !strings.HasPrefix(info.trimmed, "conclusion:") { + s.inWorkflowRunConclusionArray = false + } + if s.inEventSection() { + return s.commentLabelFilteringEventField(info, result, native) + } + return false, "" +} - // If this is a non-dash line at the same level as needs (2 spaces), we're out of the array - if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "-") && !strings.HasPrefix(trimmedLine, "needs:") && !strings.HasPrefix(trimmedLine, "#") { - inNeedsArray = false - } - } +func (s *onSectionCleanupState) commentPullRequestAndTriggerField(info onSectionLine) (bool, string) { + switch { + case s.inPullRequest && strings.Contains(info.trimmed, "draft:"): + return true, " # Draft filtering applied via job conditions" + case s.inPullRequest && strings.HasPrefix(info.trimmed, "forks:"): + return true, " # Fork filtering applied via job conditions" + case s.inForksArray && strings.HasPrefix(info.trimmed, "-"): + return true, " # Fork filtering applied via job conditions" + case s.inDeploymentStatus && strings.HasPrefix(info.trimmed, "state:"): + return true, " # State filtering compiled into if condition" + case s.inDeploymentStatus && strings.HasPrefix(info.trimmed, "-"): + return true, " # State filtering compiled into if condition" + case s.inWorkflowRun && strings.HasPrefix(info.trimmed, "conclusion:"): + s.inWorkflowRunConclusionArray = true + return true, " # Conclusion filtering compiled into if condition" + case s.inWorkflowRunConclusionArray && strings.HasPrefix(info.trimmed, "-"): + return true, " # Conclusion filtering compiled into if condition" + default: + return false, "" + } +} - // Check if we're leaving the on.steps array by encountering another top-level field - if inOnSteps && strings.TrimSpace(line) != "" { - lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) - // If this is a line at the same level as steps (2 spaces) and not a dash or comment, we're out - if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "-") && !strings.HasPrefix(trimmedLine, "steps:") && !strings.HasPrefix(trimmedLine, "#") { - inOnSteps = false - } +func (s *onSectionCleanupState) commentLabelFilteringEventField(info onSectionLine, result []string, native map[string]struct{}) (bool, string) { + if strings.HasPrefix(info.trimmed, "lock-for-agent:") { + return true, " # Lock-for-agent processed as issue locking in activation job" + } + if strings.HasPrefix(info.trimmed, "names:") { + if !setutil.Contains(native, s.currentSection) { + return true, " # Label filtering applied via job conditions" } + return false, "" + } + if !setutil.Contains(native, s.currentSection) && shouldCommentNamesArrayItem(result, info.trimmed) { + return true, " # Label filtering applied via job conditions" + } + return false, "" +} - // Check if we're leaving the on.permissions object by encountering another top-level field - if inOnPermissions && strings.TrimSpace(line) != "" && - !strings.HasPrefix(trimmedLine, "permissions:") && - !strings.HasPrefix(trimmedLine, "# permissions:") { - lineIndent := len(line) - len(strings.TrimLeft(line, " \t")) - if lineIndent == 2 && !strings.HasPrefix(trimmedLine, "#") { - inOnPermissions = false - } +func shouldCommentNamesArrayItem(result []string, trimmedLine string) bool { + if trimmedLine == "" || !strings.HasPrefix(trimmedLine, "-") { + return false + } + for i := range slices.Backward(result) { + prevTrimmed := strings.TrimSpace(result[i]) + if prevTrimmed == "" { + continue } - - // Determine if we should comment out this line - shouldComment := false - var commentReason string - - // Check for top-level fields that should be commented out (not inside pull_request, issues, discussion, or issue_comment) - if !inPullRequest && !inIssues && !inDiscussion && !inIssueComment { - if strings.HasPrefix(trimmedLine, "manual-approval:") { - shouldComment = true - commentReason = " # Manual approval processed as environment field in activation job" - } else if strings.HasPrefix(trimmedLine, "stop-after:") { - shouldComment = true - commentReason = " # Stop-after processed as stop-time check in pre-activation job" - } else if strings.HasPrefix(trimmedLine, "skip-if-match:") { - shouldComment = true - commentReason = " # Skip-if-match processed as search check in pre-activation job" - } else if inSkipIfMatch && (strings.HasPrefix(trimmedLine, "query:") || strings.HasPrefix(trimmedLine, "max:") || strings.HasPrefix(trimmedLine, "scope:")) { - // Comment out nested fields in skip-if-match object - shouldComment = true - commentReason = "" - } else if strings.HasPrefix(trimmedLine, "skip-if-no-match:") { - shouldComment = true - commentReason = " # Skip-if-no-match processed as search check in pre-activation job" - } else if inSkipIfNoMatch && (strings.HasPrefix(trimmedLine, "query:") || strings.HasPrefix(trimmedLine, "min:") || strings.HasPrefix(trimmedLine, "scope:")) { - // Comment out nested fields in skip-if-no-match object - shouldComment = true - commentReason = "" - } else if strings.HasPrefix(trimmedLine, "skip-if-check-failing:") { - shouldComment = true - commentReason = " # Skip-if-check-failing processed as check status gate in pre-activation job" - } else if inSkipIfCheckFailing && (strings.HasPrefix(trimmedLine, "include:") || strings.HasPrefix(trimmedLine, "exclude:") || strings.HasPrefix(trimmedLine, "branch:") || strings.HasPrefix(trimmedLine, "allow-pending:") || strings.HasPrefix(trimmedLine, "-")) { - // Comment out nested fields and list items in skip-if-check-failing object - shouldComment = true - commentReason = "" - } else if strings.HasPrefix(trimmedLine, "skip-author-associations:") { - shouldComment = true - commentReason = " # Skip-author-associations compiled into pre-activation job if condition" - } else if inSkipAuthorAssociations && lineIndent > 2 { - shouldComment = true - commentReason = "" - } else if strings.HasPrefix(trimmedLine, "skip-roles:") { - shouldComment = true - commentReason = " # Skip-roles processed as role check in pre-activation job" - } else if inSkipRolesArray && strings.HasPrefix(trimmedLine, "-") { - // Comment out array items in skip-roles - shouldComment = true - commentReason = " # Skip-roles processed as role check in pre-activation job" - } else if strings.HasPrefix(trimmedLine, "skip-bots:") { - shouldComment = true - commentReason = " # Skip-bots processed as bot check in pre-activation job" - } else if inSkipBotsArray && strings.HasPrefix(trimmedLine, "-") { - // Comment out array items in skip-bots - shouldComment = true - commentReason = " # Skip-bots processed as bot check in pre-activation job" - } else if strings.HasPrefix(trimmedLine, "roles:") { - shouldComment = true - commentReason = " # Roles processed as role check in pre-activation job" - } else if inRolesArray && strings.HasPrefix(trimmedLine, "-") { - // Comment out array items in roles - shouldComment = true - commentReason = " # Roles processed as role check in pre-activation job" - } else if strings.HasPrefix(trimmedLine, "bots:") { - shouldComment = true - commentReason = " # Bots processed as bot check in pre-activation job" - } else if inBotsArray && strings.HasPrefix(trimmedLine, "-") { - // Comment out array items in bots - shouldComment = true - commentReason = " # Bots processed as bot check in pre-activation job" - } else if !inOnSteps && !inOnPermissions && lineIndent == 2 && strings.HasPrefix(trimmedLine, "labels:") { - shouldComment = true - commentReason = " # Label filtering applied via job conditions" - } else if inLabelsArray && strings.HasPrefix(trimmedLine, "-") { - // Comment out array items in labels - shouldComment = true - commentReason = " # Label filtering applied via job conditions" - } else if !inOnSteps && !inOnPermissions && lineIndent == 2 && strings.HasPrefix(trimmedLine, "needs:") { - shouldComment = true - commentReason = " # Needs processed as dependency in pre-activation job" - } else if inNeedsArray && strings.HasPrefix(trimmedLine, "-") { - // Comment out array items in needs - shouldComment = true - commentReason = " # Needs processed as dependency in pre-activation job" - } else if strings.HasPrefix(trimmedLine, "restore-memory:") { - shouldComment = true - commentReason = " # Restore-memory enables pre-activation memory restore" - } else if strings.HasPrefix(trimmedLine, "steps:") { - shouldComment = true - commentReason = " # Steps injected into pre-activation job" - } else if inOnSteps { - // Comment out all content of on.steps (both array items and their nested fields) - shouldComment = true - commentReason = "" - } else if strings.HasPrefix(trimmedLine, "permissions:") { - shouldComment = true - commentReason = " # Permissions applied to pre-activation job" - } else if inOnPermissions { - // Comment out all nested permission scope lines - shouldComment = true - commentReason = "" - } else if strings.HasPrefix(trimmedLine, "reaction:") { - shouldComment = true - commentReason = " # Reaction processed as activation job step" - } else if strings.HasPrefix(trimmedLine, "github-token:") { - shouldComment = true - commentReason = " # GitHub token used for reactions and status comments in activation" - } else if strings.HasPrefix(trimmedLine, "github-app:") { - shouldComment = true - commentReason = " # GitHub App used to mint token for reactions and status comments in activation" - } else if inGitHubApp && isGitHubAppNestedField(trimmedLine) { - // Comment out nested fields and array items in github-app object - shouldComment = true - commentReason = "" - } else if strings.HasPrefix(trimmedLine, "stale-check:") { - shouldComment = true - commentReason = " # Stale-check processed as frontmatter hash check step in activation job" - } + if strings.Contains(prevTrimmed, "names:") && strings.Contains(prevTrimmed, "# Label filtering") { + return true } - - if !shouldComment && inPullRequest && strings.Contains(trimmedLine, "draft:") { - shouldComment = true - commentReason = " # Draft filtering applied via job conditions" - } else if inPullRequest && strings.HasPrefix(trimmedLine, "forks:") { - shouldComment = true - commentReason = " # Fork filtering applied via job conditions" - } else if inForksArray && strings.HasPrefix(trimmedLine, "-") { - shouldComment = true - commentReason = " # Fork filtering applied via job conditions" - } else if inDeploymentStatus && strings.HasPrefix(trimmedLine, "state:") { - shouldComment = true - commentReason = " # State filtering compiled into if condition" - } else if inDeploymentStatus && strings.HasPrefix(trimmedLine, "-") { - // Comment out array items inside deployment_status.state - shouldComment = true - commentReason = " # State filtering compiled into if condition" - } else if inWorkflowRun && strings.HasPrefix(trimmedLine, "conclusion:") { - shouldComment = true - commentReason = " # Conclusion filtering compiled into if condition" - inWorkflowRunConclusionArray = true - } else if inWorkflowRunConclusionArray && strings.HasPrefix(trimmedLine, "-") { - // Comment out array items inside workflow_run.conclusion - shouldComment = true - commentReason = " # Conclusion filtering compiled into if condition" - } else if inWorkflowRun && !strings.HasPrefix(trimmedLine, "-") && strings.Contains(trimmedLine, ":") { - // Any new field inside workflow_run resets the conclusion array tracker - inWorkflowRunConclusionArray = false - } else if (inPullRequest || inIssues || inDiscussion || inIssueComment) && strings.HasPrefix(trimmedLine, "lock-for-agent:") { - shouldComment = true - commentReason = " # Lock-for-agent processed as issue locking in activation job" - } else if (inPullRequest || inIssues || inDiscussion || inIssueComment) && strings.HasPrefix(trimmedLine, "names:") { - // Only comment out names if NOT using native label filtering for this section - if !setutil.Contains(nativeLabelFilterSections, currentSection) { - shouldComment = true - commentReason = " # Label filtering applied via job conditions" - } - } else if (inPullRequest || inIssues || inDiscussion || inIssueComment) && line != "" { - // Check if we're in a names array (after "names:" line) - // Look back to see if the previous uncommented line was "names:" - // Only do this if NOT using native label filtering for this section - if !setutil.Contains(nativeLabelFilterSections, currentSection) { - if len(result) > 0 { - for i := range slices.Backward(result) { - prevLine := result[i] - prevTrimmed := strings.TrimSpace(prevLine) - - // Skip empty lines - if prevTrimmed == "" { - continue - } - - // If we find "names:", and current line is an array item, comment it - if strings.Contains(prevTrimmed, "names:") && strings.Contains(prevTrimmed, "# Label filtering") { - if strings.HasPrefix(trimmedLine, "-") { - shouldComment = true - commentReason = " # Label filtering applied via job conditions" - } - break - } - - // If we find a different field or commented names array item, break - if !strings.HasPrefix(prevTrimmed, "#") || !strings.Contains(prevTrimmed, "Label filtering") { - break - } - - // If it's a commented names array item, continue - if strings.HasPrefix(prevTrimmed, "# -") && strings.Contains(prevTrimmed, "Label filtering") { - if strings.HasPrefix(trimmedLine, "-") { - shouldComment = true - commentReason = " # Label filtering applied via job conditions" - } - continue - } - - break - } - } - } // Close native filter check + if !strings.HasPrefix(prevTrimmed, "#") || !strings.Contains(prevTrimmed, "Label filtering") { + return false } - - if shouldComment { - trimmed := strings.TrimLeft(line, " \t") - - // Forks array items must preserve their own indentation so that commented - // array entries appear more deeply indented than the parent "forks:" key, - // matching the original YAML structure. Resetting the comment-block anchor - // here lets each "- …" item re-anchor to its own indentation level. - if inForksArray && strings.HasPrefix(trimmed, "-") { - inCommentBlock = false - commentBlockIndent = "" - } - - // Flatten the indentation of the commented block. The first non-blank - // line of a block adopts its natural indentation (which matches the - // surrounding content level) as the block anchor; every subsequent line - // — including blank lines inside a multi-line `steps:` script — reuses - // that same indentation. This keeps the whole block in a single comment - // group at a constant indent, so nested fields never appear more deeply - // indented than the comment above them (which is what yamllint's - // comments-indentation rule flags). Blank source lines are not allowed to - // anchor the block, so it always aligns to a real field. - if !inCommentBlock && trimmed != "" { - commentBlockIndent = "" - if len(line) > len(trimmed) { - commentBlockIndent = line[:len(line)-len(trimmed)] - } - inCommentBlock = true - } - - commentedLine := commentBlockIndent + "# " + trimmed + commentReason - // Strip any trailing whitespace carried from the source content (e.g. - // commented-out multi-line `steps:` scripts whose lines end in spaces, or - // blank lines that would otherwise become "# " with trailing whitespace). - // Trailing whitespace on a comment is never meaningful and yamllint flags - // it as trailing-spaces. - commentedLine = strings.TrimRight(commentedLine, " \t") - result = append(result, commentedLine) - } else { - inCommentBlock = false - commentBlockIndent = "" - result = append(result, line) + if strings.HasPrefix(prevTrimmed, "# -") && strings.Contains(prevTrimmed, "Label filtering") { + continue } + return false } + return false +} - result = dedentTrailingOnCommentBlock(result) +func (s *onSectionCleanupState) appendLine(result []string, info onSectionLine, shouldComment bool, commentReason string) []string { + if !shouldComment { + s.inCommentBlock = false + s.commentBlockIndent = "" + return append(result, info.raw) + } + return append(result, s.renderCommentedLine(info.raw, commentReason)) +} - return strings.Join(result, "\n") +func (s *onSectionCleanupState) renderCommentedLine(line, commentReason string) string { + trimmed := strings.TrimLeft(line, " ") + if s.inForksArray && strings.HasPrefix(trimmed, "-") { + s.inCommentBlock = false + s.commentBlockIndent = "" + } + if !s.inCommentBlock && trimmed != "" { + s.commentBlockIndent = "" + if len(line) > len(trimmed) { + s.commentBlockIndent = line[:len(line)-len(trimmed)] + } + s.inCommentBlock = true + } + commentedLine := s.commentBlockIndent + "# " + trimmed + commentReason + return strings.TrimRight(commentedLine, " ") } // dedentTrailingOnCommentBlock re-indents the final run of commented-out lines at the diff --git a/pkg/workflow/maintenance_workflow_yaml.go b/pkg/workflow/maintenance_workflow_yaml.go index 7f509c849d2..25a0580fc03 100644 --- a/pkg/workflow/maintenance_workflow_yaml.go +++ b/pkg/workflow/maintenance_workflow_yaml.go @@ -36,28 +36,41 @@ func buildMaintenanceWorkflowYAML( ctx context.Context, opts buildMaintenanceWorkflowYAMLOptions, ) string { - cronSchedule := opts.cronSchedule - scheduleDesc := opts.scheduleDesc - minExpiresDays := opts.minExpiresDays - runsOnValue := opts.runsOnValue - actionMode := opts.actionMode - version := opts.version - actionTag := opts.actionTag - resolver := opts.resolver - configuredRunsOn := opts.configuredRunsOn - defaultBranch := opts.defaultBranch - disableLabelTrigger := opts.disableLabelTrigger - maintenanceConfig := opts.maintenanceConfig - compileGitHubToken := opts.compileGitHubToken - createCompilePR := opts.createCompilePR - copilotOrgBilling := opts.copilotOrgBilling - maintenanceWorkflowYAMLLog.Printf("Building maintenance workflow YAML: actionMode=%s minExpiresDays=%d cronSchedule=%q defaultBranch=%q disableLabelTrigger=%v createCompilePR=%v copilotOrgBilling=%v", actionMode, minExpiresDays, cronSchedule, defaultBranch, disableLabelTrigger, createCompilePR, copilotOrgBilling) - labelDisableJobEnabled := !disableLabelTrigger && !maintenanceConfig.IsJobDisabled("label_disable_agentic_workflow") - labelApplySafeOutputsJobEnabled := !disableLabelTrigger && !maintenanceConfig.IsJobDisabled("label_apply_safe_outputs") + maintenanceWorkflowYAMLLog.Printf("Building maintenance workflow YAML: actionMode=%s minExpiresDays=%d cronSchedule=%q defaultBranch=%q disableLabelTrigger=%v createCompilePR=%v copilotOrgBilling=%v", opts.actionMode, opts.minExpiresDays, opts.cronSchedule, opts.defaultBranch, opts.disableLabelTrigger, opts.createCompilePR, opts.copilotOrgBilling) + labelDisableJobEnabled := !opts.disableLabelTrigger && !opts.maintenanceConfig.IsJobDisabled("label_disable_agentic_workflow") + labelApplySafeOutputsJobEnabled := !opts.disableLabelTrigger && !opts.maintenanceConfig.IsJobDisabled("label_apply_safe_outputs") + appliedRunURLValue, appliedRunURLDescription := buildMaintenanceAppliedRunURLOutput(opts) + setupActionRef := ResolveSetupActionReference(ctx, opts.actionMode, opts.version, opts.actionTag, opts.resolver) var yaml strings.Builder + yaml.WriteString(buildMaintenanceWorkflowHeaderYAML(opts)) + yaml.WriteString(buildMaintenanceWorkflowTriggerYAML(opts, labelDisableJobEnabled, labelApplySafeOutputsJobEnabled, appliedRunURLDescription, appliedRunURLValue)) + yaml.WriteString(buildMaintenanceCloseExpiredJobs(opts, setupActionRef)) + yaml.WriteString(buildMaintenanceCleanupCacheJob(opts, setupActionRef)) + yaml.WriteString(buildMaintenanceRunOperationJob(ctx, opts, setupActionRef)) + yaml.WriteString(buildMaintenanceUpdatePRBranchesJob(opts, setupActionRef)) + yaml.WriteString(buildMaintenanceApplySafeOutputsJob(opts, setupActionRef)) + yaml.WriteString(buildMaintenanceCreateLabelsJob(ctx, opts, setupActionRef)) + yaml.WriteString(buildMaintenanceActivityReportJob(ctx, opts, setupActionRef)) + yaml.WriteString(buildMaintenanceForecastReportJob(ctx, opts, setupActionRef)) + yaml.WriteString(buildMaintenanceCloseIssuesJob(opts, setupActionRef)) + yaml.WriteString(buildMaintenanceValidateWorkflowsJob(ctx, opts, setupActionRef)) + yaml.WriteString(buildMaintenanceLabelTriggeredJobs(opts, setupActionRef)) + yaml.WriteString(buildMaintenanceDevOnlyJobs(ctx, opts, setupActionRef)) + return yaml.String() +} - // Add workflow header with logo and instructions +func buildMaintenanceAppliedRunURLOutput(opts buildMaintenanceWorkflowYAMLOptions) (string, string) { + appliedRunURLValue := "${{ jobs.apply_safe_outputs.outputs.run_url }}" + appliedRunURLDescription := "The run URL that safe outputs were applied from" + if opts.maintenanceConfig.IsJobDisabled("apply_safe_outputs") { + appliedRunURLValue = "${{ inputs.run_url }}" + appliedRunURLDescription = "The run URL that safe outputs were applied from (workflow_call falls back to inputs.run_url when apply_safe_outputs is disabled; other triggers leave this empty)" + } + return appliedRunURLValue, appliedRunURLDescription +} + +func buildMaintenanceWorkflowHeaderYAML(opts buildMaintenanceWorkflowYAMLOptions) string { customInstructions := `This file defines the generated agentic maintenance workflow for this repository. It runs scheduled cleanup for expiring safe outputs and supports manual maintenance operations. @@ -70,43 +83,39 @@ To disable maintenance workflow generation, set in .github/workflows/aw.json: Agentic maintenance docs: https://github.github.com/gh-aw/reference/ephemerals/#manual-maintenance-operations` - header := GenerateWorkflowHeader("", "pkg/workflow/maintenance_workflow.go", customInstructions) - yaml.WriteString(header) - - yaml.WriteString(`name: Agentic Maintenance + return GenerateWorkflowHeader("", "pkg/workflow/maintenance_workflow.go", customInstructions) + `name: Agentic Maintenance on: schedule: - - cron: "` + cronSchedule + `" # ` + scheduleDesc + ` (based on minimum expires: ` + strconv.Itoa(minExpiresDays) + ` days) -`) + - cron: "` + opts.cronSchedule + `" # ` + opts.scheduleDesc + ` (based on minimum expires: ` + strconv.Itoa(opts.minExpiresDays) + ` days) +` +} - // Add push trigger in dev mode so compile-workflows runs when workflow files change - if actionMode == ActionModeDev { - maintenanceWorkflowYAMLLog.Printf("Adding dev-mode push trigger for branch %q", defaultBranch) - yaml.WriteString(` push: - branches: - - ` + defaultBranch + ` - paths: - - '.github/workflows/*.md' -`) +func buildMaintenanceWorkflowTriggerYAML( + opts buildMaintenanceWorkflowYAMLOptions, + labelDisableJobEnabled bool, + labelApplySafeOutputsJobEnabled bool, + appliedRunURLDescription string, + appliedRunURLValue string, +) string { + var yaml strings.Builder + if opts.actionMode == ActionModeDev { + maintenanceWorkflowYAMLLog.Printf("Adding dev-mode push trigger for branch %q", opts.defaultBranch) + yaml.WriteString(" push:\n branches:\n - " + opts.defaultBranch + "\n paths:\n - '.github/workflows/*.md'\n") } - - // Add label-event trigger only when the label-triggered jobs are enabled if labelDisableJobEnabled || labelApplySafeOutputsJobEnabled { maintenanceWorkflowYAMLLog.Print("Adding issues:labeled trigger for label-triggered maintenance jobs") - yaml.WriteString(` issues: - types: [labeled] -`) - } - - appliedRunURLValue := "${{ jobs.apply_safe_outputs.outputs.run_url }}" - appliedRunURLDescription := "The run URL that safe outputs were applied from" - if maintenanceConfig.IsJobDisabled("apply_safe_outputs") { - appliedRunURLValue = "${{ inputs.run_url }}" - appliedRunURLDescription = "The run URL that safe outputs were applied from (workflow_call falls back to inputs.run_url when apply_safe_outputs is disabled; other triggers leave this empty)" + yaml.WriteString(" issues:\n types: [labeled]\n") } + yaml.WriteString(buildMaintenanceDispatchInputsYAML()) + yaml.WriteString(buildMaintenanceWorkflowCallYAML(appliedRunURLDescription, appliedRunURLValue)) + yaml.WriteString("\npermissions: {}\n\njobs:\n") + return yaml.String() +} - yaml.WriteString(` workflow_dispatch: +// buildMaintenanceDispatchInputsYAML returns the workflow_dispatch trigger block. +func buildMaintenanceDispatchInputsYAML() string { + return ` workflow_dispatch: inputs: operation: description: 'Optional maintenance operation to run' @@ -132,7 +141,12 @@ on: required: false type: string default: '' - workflow_call: +` +} + +// buildMaintenanceWorkflowCallYAML returns the workflow_call trigger block. +func buildMaintenanceWorkflowCallYAML(appliedRunURLDescription, appliedRunURLValue string) string { + return ` workflow_call: inputs: operation: description: 'Optional maintenance operation to run (disable, enable, update, upgrade, safe_outputs, create_labels, activity_report, close_agentic_workflows_issues, clean_cache_memories, update_pull_request_branches, validate, forecast)' @@ -151,850 +165,5 @@ on: applied_run_url: description: '` + appliedRunURLDescription + `' value: ` + appliedRunURLValue + ` - -permissions: {} - -jobs: -`) - - setupActionRef := ResolveSetupActionReference(ctx, actionMode, version, actionTag, resolver) - - writeCloseExpiredJob := func(jobName string, permissionLine string, stepName string, scriptName string) { - yaml.WriteString(` ` + jobName + `: - if: ${{ ` + RenderCondition(buildNotForkAndScheduleOnly()) + ` }} - runs-on: ` + runsOnValue + ` - permissions: - ` + permissionLine + ` - steps: -`) - if actionMode == ActionModeDev || actionMode == ActionModeScript { - maintenanceWorkflowYAMLLog.Printf("Adding checkout step for %s (actionMode=%s)", jobName, actionMode) - yaml.WriteString(" - name: Checkout actions folder\n") - yaml.WriteString(" uses: " + getActionPin("actions/checkout") + "\n") - yaml.WriteString(" with:\n") - yaml.WriteString(" sparse-checkout: |\n") - yaml.WriteString(" actions\n") - yaml.WriteString(" clean: false\n") - yaml.WriteString(" persist-credentials: false\n\n") - } - yaml.WriteString(` - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: ` + stepName + ` - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/` + scriptName + `.cjs'); - await main(); -`) - } - - if !maintenanceConfig.IsJobDisabled("close-expired-entities") { - writeCloseExpiredJob("close-expired-discussions", "discussions: write", "Close expired discussions", "close_expired_discussions") - writeCloseExpiredJob("close-expired-issues", "issues: write", "Close expired issues", "close_expired_issues") - writeCloseExpiredJob("close-expired-pull-requests", "pull-requests: write", "Close expired pull requests", "close_expired_pull_requests") - } - - // Add cleanup-cache-memory job for scheduled runs and clean_cache_memories operation - // This job lists all caches starting with "memory-", groups them by key prefix, - // keeps the latest run ID per group, and deletes the rest. - cleanupCacheCondition := buildNotForkAndScheduleOnlyOrOperation("clean_cache_memories") - yaml.WriteString(` - cleanup-cache-memory: - if: ${{ ` + RenderCondition(cleanupCacheCondition) + ` }} - runs-on: ` + runsOnValue + ` - permissions: - actions: write - steps: -`) - - // Add checkout step only in dev/script mode (for local action paths) - if actionMode == ActionModeDev || actionMode == ActionModeScript { - yaml.WriteString(" - name: Checkout actions folder\n") - yaml.WriteString(" uses: " + getActionPin("actions/checkout") + "\n") - yaml.WriteString(" with:\n") - yaml.WriteString(" sparse-checkout: |\n") - yaml.WriteString(" actions\n") - yaml.WriteString(" clean: false\n") - yaml.WriteString(" persist-credentials: false\n\n") - } - - yaml.WriteString(` - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Cleanup outdated cache-memory entries - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/cleanup_cache_memory.cjs'); - await main(); -`) - - // Add unified run_operation job for all dispatch operations except those with dedicated jobs - // (safe_outputs, create_labels, activity_report, close_agentic_workflows_issues, clean_cache_memories, update_pull_request_branches, validate, forecast) - runOperationCondition := buildRunOperationCondition("safe_outputs", "create_labels", "activity_report", "close_agentic_workflows_issues", "clean_cache_memories", "update_pull_request_branches", "validate", "forecast") - yaml.WriteString(` - run_operation: - if: ${{ ` + RenderCondition(runOperationCondition) + ` }} - runs-on: ` + runsOnValue + ` - permissions: - actions: write - contents: write - pull-requests: write - outputs: - operation: ${{ steps.record.outputs.operation }} - steps: - - name: Checkout repository - uses: ` + getActionPin("actions/checkout") + ` - with: - persist-credentials: false - - - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Check admin/maintainer permissions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); - await main(); - -`) - - yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) - yaml.WriteString(` - name: Run operation - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_AW_OPERATION: ${{ inputs.operation }} - GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/run_operation_update_upgrade.cjs'); - await main(); - - - name: Record outputs - id: record - env: - GH_AW_OPERATION: ${{ inputs.operation }} - run: echo "operation=$GH_AW_OPERATION" >> "$GITHUB_OUTPUT" -`) - - // Add update_pull_request_branches job for workflow_dispatch with operation == 'update_pull_request_branches' - yaml.WriteString(` - update_pull_request_branches: - if: ${{ ` + RenderCondition(buildDispatchOperationCondition("update_pull_request_branches")) + ` }} - runs-on: ` + runsOnValue + ` - permissions: - contents: write - pull-requests: write - steps: -`) - - // Add checkout step only in dev/script mode (for local action paths) - if actionMode == ActionModeDev || actionMode == ActionModeScript { - yaml.WriteString(" - name: Checkout actions folder\n") - yaml.WriteString(" uses: " + getActionPin("actions/checkout") + "\n") - yaml.WriteString(" with:\n") - yaml.WriteString(" sparse-checkout: |\n") - yaml.WriteString(" actions\n") - yaml.WriteString(" clean: false\n") - yaml.WriteString(" persist-credentials: false\n\n") - } - - yaml.WriteString(` - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Check admin/maintainer permissions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); - await main(); - - - name: Update pull request branches - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/update_pull_request_branches.cjs'); - await main(); -`) - - // Add apply_safe_outputs job for workflow_dispatch with operation == 'safe_outputs' - if !maintenanceConfig.IsJobDisabled("apply_safe_outputs") { - yaml.WriteString(` - apply_safe_outputs: - if: ${{ ` + RenderCondition(buildDispatchOperationCondition("safe_outputs")) + ` }} - runs-on: ` + runsOnValue + ` - permissions: - actions: read - contents: write - discussions: write - issues: write - pull-requests: write - outputs: - run_url: ${{ steps.record.outputs.run_url }} - steps: - - name: Checkout actions folder - uses: ` + getActionPin("actions/checkout") + ` - with: - sparse-checkout: | - actions - clean: false - persist-credentials: false - - - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Check admin/maintainer permissions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); - await main(); - - - name: Apply Safe Outputs - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_AW_RUN_URL: ${{ inputs.run_url }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/apply_safe_outputs_replay.cjs'); - await main(); - - - name: Record outputs - id: record - env: - GH_AW_RUN_URL: ${{ inputs.run_url }} - run: echo "run_url=$GH_AW_RUN_URL" >> "$GITHUB_OUTPUT" -`) - } - - // Add create_labels job for workflow_dispatch with operation == 'create_labels' - yaml.WriteString(` - create_labels: - if: ${{ ` + RenderCondition(buildDispatchOperationCondition("create_labels")) + ` }} - runs-on: ` + runsOnValue + ` - permissions: - contents: read - issues: write - steps: - - name: Checkout repository - uses: ` + getActionPin("actions/checkout") + ` - with: - persist-credentials: false - - - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Check admin/maintainer permissions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); - await main(); - -`) - - yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) - yaml.WriteString(` - name: Create missing labels - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/create_labels.cjs'); - await main(); -`) - - // Add activity_report job for workflow_dispatch with operation == 'activity_report' - yaml.WriteString(` - activity_report: - if: ${{ ` + RenderCondition(buildDispatchOperationCondition("activity_report")) + ` }} - runs-on: ` + runsOnValue + ` - timeout-minutes: 120 - permissions: - actions: read - contents: read - issues: write - steps: - - name: Checkout repository - uses: ` + getActionPin("actions/checkout") + ` - with: - persist-credentials: false - - - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Check admin/maintainer permissions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); - await main(); - -`) - - yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) - yaml.WriteString(` - name: Restore activity report logs cache - id: activity_report_logs_cache - uses: ` + getActionPin("actions/cache/restore") + ` - with: - path: ./.cache/gh-aw/activity-report-logs - key: ${{ runner.os }}-activity-report-logs-${{ github.repository }}-${{ github.ref_name }}-${{ github.run_id }} - restore-keys: | - ${{ runner.os }}-activity-report-logs-${{ github.repository }}- - ${{ runner.os }}-activity-report-logs- -`) - yaml.WriteString(` - name: Download activity report logs - timeout-minutes: 20 - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` - run: | - ${GH_AW_CMD_PREFIX} logs \ - --repo "$GITHUB_REPOSITORY" \ - --start-date -1w \ - --count 500 \ - --output ./.cache/gh-aw/activity-report-logs \ - --format markdown \ - --report-file ./.cache/gh-aw/activity-report-logs/report.md - - - name: Save activity report logs cache - if: ${{ always() }} - uses: ` + getActionPin("actions/cache/save") + ` - with: - path: ./.cache/gh-aw/activity-report-logs - key: ${{ steps.activity_report_logs_cache.outputs.cache-primary-key }} - - - name: Generate activity report issue - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('node:fs'); - const reportPath = './.cache/gh-aw/activity-report-logs/report.md'; - if (!fs.existsSync(reportPath)) { - core.warning('Activity report markdown not found at ' + reportPath + '; skipping issue creation.'); - return; - } - let reportBody = ''; - try { - reportBody = fs.readFileSync(reportPath, 'utf8').trim(); - } catch (error) { - core.warning('Failed to read activity report markdown at ' + reportPath + ': ' + error.message); - return; - } - if (!reportBody) { - core.warning('Activity report markdown is empty at ' + reportPath + '; skipping issue creation.'); - return; - } - const repoSlug = context.repo.owner + '/' + context.repo.repo; - const body = [ - '### Agentic workflow activity report', - '', - 'Repository: ' + repoSlug, - 'Generated at: ' + new Date().toISOString(), - '', - reportBody, - ].join('\n'); - const createdIssue = await github.rest.issues.create({ - owner: context.repo.owner, - repo: context.repo.repo, - title: '[aw] agentic status report', - body, - labels: ['agentic-workflows'], - }); - core.info('Created issue #' + createdIssue.data.number + ': ' + createdIssue.data.html_url); -`) - - // Add forecast_report job for workflow_dispatch with operation == 'forecast' - yaml.WriteString(` - forecast_report: - if: ${{ ` + RenderCondition(buildDispatchOperationCondition("forecast")) + ` }} - runs-on: ` + runsOnValue + ` - timeout-minutes: 60 - permissions: - actions: read - contents: read - issues: write - steps: - - name: Checkout repository - uses: ` + getActionPin("actions/checkout") + ` - with: - persist-credentials: false - - - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Check admin/maintainer permissions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); - await main(); - -`) - - yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) - yaml.WriteString(` - name: Restore forecast report logs cache - id: forecast_report_logs_cache - uses: ` + getActionPin("actions/cache/restore") + ` - with: - path: ./.github/aw/logs - key: ${{ runner.os }}-forecast-report-logs-${{ github.repository }}-${{ github.ref_name }}-${{ github.run_id }} - restore-keys: | - ${{ runner.os }}-forecast-report-logs-${{ github.repository }}- - ${{ runner.os }}-forecast-report-logs- - - - name: Generate forecast report - id: generate_forecast_report - timeout-minutes: 30 - shell: bash - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - DEBUG: "*" - GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` - run: | - mkdir -p ./.cache/gh-aw/forecast - set +e - ${GH_AW_CMD_PREFIX} forecast --repo "$GITHUB_REPOSITORY" --timeout 30 --verbose --json > ./.cache/gh-aw/forecast/report.json - forecast_exit_code=$? - set -e - if [ "${forecast_exit_code}" -eq 124 ]; then - echo '{"outcome":"timeout","message":"Forecast computation timed out after 30 minutes."}' > ./.cache/gh-aw/forecast/error.json - echo "::error::Forecast computation timed out after 30 minutes." - exit 1 - fi - if [ "${forecast_exit_code}" -ne 0 ]; then - echo '{"outcome":"error","message":"Forecast computation failed before producing a report."}' > ./.cache/gh-aw/forecast/error.json - echo "::error::Forecast computation failed with exit code ${forecast_exit_code}." - exit 1 - fi - - - name: Debug forecast logs folder - if: ${{ always() }} - shell: bash - run: | - if [ ! -d ./.github/aw/logs ]; then - echo "Logs directory not found: ./.github/aw/logs" - exit 0 - fi - echo "Files under ./.github/aw/logs:" - find ./.github/aw/logs -type f | sort - - - name: Save forecast report logs cache - if: ${{ always() }} - uses: ` + getActionPin("actions/cache/save") + ` - with: - path: ./.github/aw/logs - key: ${{ runner.os }}-forecast-report-logs-${{ github.repository }}-${{ github.ref_name }}-${{ github.run_id }} - - - name: Generate forecast issue - if: ${{ always() }} - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - FORECAST_STEP_OUTCOME: ${{ steps.generate_forecast_report.outcome }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/create_forecast_issue.cjs'); - await main(); -`) - - // Add close_agentic_workflows_issues job for workflow_dispatch with operation == 'close_agentic_workflows_issues' - yaml.WriteString(` - close_agentic_workflows_issues: - if: ${{ ` + RenderCondition(buildDispatchOperationCondition("close_agentic_workflows_issues")) + ` }} - runs-on: ` + runsOnValue + ` - permissions: - issues: write - steps: -`) - - // Add checkout step only in dev/script mode (for local action paths) - if actionMode == ActionModeDev || actionMode == ActionModeScript { - yaml.WriteString(" - name: Checkout actions folder\n") - yaml.WriteString(" uses: " + getActionPin("actions/checkout") + "\n") - yaml.WriteString(" with:\n") - yaml.WriteString(" sparse-checkout: |\n") - yaml.WriteString(" actions\n") - yaml.WriteString(" clean: false\n") - yaml.WriteString(" persist-credentials: false\n\n") - } - - yaml.WriteString(` - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Check admin/maintainer permissions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); - await main(); - - - name: Close no-repro agentic-workflows issues - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/close_agentic_workflows_issues.cjs'); - await main(); -`) - - // Add validate_workflows job for workflow_dispatch with operation == 'validate' - // This job uses ubuntu-latest by default (needs full runner for CLI installation). - formattedRunsOn := FormatRunsOn(configuredRunsOn, "ubuntu-latest") - yaml.WriteString(` - validate_workflows: - if: ${{ ` + RenderCondition(buildDispatchOperationCondition("validate")) + ` }} - runs-on: ` + formattedRunsOn + ` - permissions: - contents: read - issues: write - steps: - - name: Checkout repository - uses: ` + getActionPin("actions/checkout") + ` - with: - persist-credentials: false - - - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Check admin/maintainer permissions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); - await main(); - -`) - - yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) - - yaml.WriteString(` - name: Validate workflows and file issue on findings - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/run_validate_workflows.cjs'); - await main(); -`) - - // Add label_disable_agentic_workflow job triggered by label "agentic-workflows:disable" on issues. - // This job reads the body of the labeled issue to extract the workflow_id from XML comment - // markers, disables the corresponding agentic workflow via the GitHub REST API, and posts - // a confirmation comment. - // Skipped when label_triggers is set to false in aw.json maintenance config. - if labelDisableJobEnabled || labelApplySafeOutputsJobEnabled { - maintenanceWorkflowYAMLLog.Print("Adding label-triggered jobs") - if labelDisableJobEnabled { - disableLabelCondition := buildLabeledDisableCondition() - yaml.WriteString(` - label_disable_agentic_workflow: - if: ${{ ` + RenderCondition(disableLabelCondition) + ` }} - runs-on: ` + runsOnValue + ` - permissions: - actions: write - contents: read - issues: write - steps: - - name: Checkout actions folder - uses: ` + getActionPin("actions/checkout") + ` - with: - sparse-checkout: | - actions - clean: false - persist-credentials: false - - - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Check admin/maintainer permissions - id: check_permissions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); - await main(); - - - name: Disable agentic workflow - if: ${{ steps.check_permissions.outcome == 'success' }} - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/disable_agentic_workflow.cjs'); - await main(); -`) - } - - // Add label_apply_safe_outputs job triggered by "agentic-workflows:apply-safe-outputs" label on issues. - // This job extracts a workflow run URL from the issue body XML comments and re-applies the safe outputs. - if labelApplySafeOutputsJobEnabled { - applySafeOutputsCondition := buildLabeledApplySafeOutputsCondition() - yaml.WriteString(` - label_apply_safe_outputs: - if: ${{ ` + RenderCondition(applySafeOutputsCondition) + ` }} - runs-on: ` + runsOnValue + ` - permissions: - actions: read - contents: write - discussions: write - issues: write - pull-requests: write - steps: - - name: Checkout actions folder - uses: ` + getActionPin("actions/checkout") + ` - with: - sparse-checkout: | - actions - clean: false - persist-credentials: false - - - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Check admin/maintainer permissions - id: check_permissions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); - await main(); - - - name: Apply safe outputs from referenced run - if: ${{ steps.check_permissions.outcome == 'success' }} - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/label_apply_safe_outputs.cjs'); - await main(); -`) - } - } - - // Add compile-workflows and zizmor-scan jobs only in dev mode - // These jobs are specific to the gh-aw repository and require go.mod, make build, etc. - // User repositories won't have these dependencies, so we skip them in release mode - if actionMode == ActionModeDev { - maintenanceWorkflowYAMLLog.Printf("Adding dev-only jobs: compile-workflows and secret-validation") - // Add compile-workflows job - yaml.WriteString(` - compile-workflows: - if: ${{ ` + RenderCondition(buildNotForkAndScheduled()) + ` }} - runs-on: ` + runsOnValue + ` - concurrency: - group: ${{ github.workflow }}-compile-workflows-${{ github.repository }} - cancel-in-progress: true - permissions: - contents: read - issues: write - steps: -`) - - // Dev mode: checkout entire repository (no sparse checkout, but no credentials) - yaml.WriteString(` - name: Checkout repository - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - with: - persist-credentials: false - -`) - - yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) - yaml.WriteString(` - name: Pre-compile validation - run: | - ` + getCLICmdPrefix(actionMode) + ` compile --validate --no-emit --verbose - echo "✓ Pre-compile validation passed" - - - name: Compile workflows - run: | - ` + getCLICmdPrefix(actionMode) + ` compile --validate --verbose - echo "✓ All workflows compiled successfully" - - - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Check for out-of-sync workflows and create issue or pull request if needed - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` -`) - if compileGitHubToken != "" { - yaml.WriteString(` env: - GH_AW_MAINTENANCE_GITHUB_TOKEN: ` + compileGitHubToken + ` -`) - } - yaml.WriteString(` with: -`) - if compileGitHubToken != "" { - yaml.WriteString(` github-token: ${{ env.GH_AW_MAINTENANCE_GITHUB_TOKEN }} -`) - } - yaml.WriteString(` script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_recompile_needed.cjs'); - await main(); - - secret-validation: - if: ${{ ` + RenderCondition(buildNotForkAndScheduleOnly()) + ` }} - runs-on: ` + runsOnValue + ` - permissions: - contents: read - steps: -`) - - // Add checkout step only in dev mode (for local action paths) - yaml.WriteString(` - name: Checkout actions folder - uses: ` + getActionPin("actions/checkout") + ` - with: - sparse-checkout: | - actions - clean: false - persist-credentials: false - -`) - - yaml.WriteString(` - name: Setup Node.js - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 - with: - node-version: '22' - - - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - -`) - // Build the Validate Secrets step, conditionally including the org billing flag. - // The line uses 10-space indentation to match the surrounding env block structure. - copilotOrgBillingLine := "" - if copilotOrgBilling { - maintenanceWorkflowYAMLLog.Print("Copilot org billing mode detected: adding GH_AW_COPILOT_ORG_BILLING=true to secret-validation step") - copilotOrgBillingLine = ` GH_AW_COPILOT_ORG_BILLING: "true" ` - } - yaml.WriteString(` - name: Validate Secrets - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - # GitHub tokens - GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} - GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} - GH_AW_PROJECT_GITHUB_TOKEN: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} - GH_AW_COPILOT_TOKEN: ${{ secrets.GH_AW_COPILOT_TOKEN }} -` + copilotOrgBillingLine + ` # AI Engine API keys - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} - # Integration tokens - NOTION_API_TOKEN: ${{ secrets.NOTION_API_TOKEN }} - with: - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/validate_secrets.cjs'); - await main(); - - - name: Upload secret validation report - if: always() - uses: ` + getActionPin("actions/upload-artifact") + ` - with: - name: secret-validation-report - path: secret-validation-report.md - retention-days: 30 - if-no-files-found: warn -`) - } - - return yaml.String() } diff --git a/pkg/workflow/maintenance_workflow_yaml_jobs.go b/pkg/workflow/maintenance_workflow_yaml_jobs.go new file mode 100644 index 00000000000..0d77f69886c --- /dev/null +++ b/pkg/workflow/maintenance_workflow_yaml_jobs.go @@ -0,0 +1,717 @@ +package workflow + +import ( + "context" + "strconv" + "strings" +) + +func writeMaintenanceConditionalActionsCheckoutStep(b *strings.Builder, opts buildMaintenanceWorkflowYAMLOptions) { + if opts.actionMode != ActionModeDev && opts.actionMode != ActionModeScript { + return + } + b.WriteString(` - name: Checkout actions folder + uses: ` + getActionPin("actions/checkout") + ` + with: + sparse-checkout: | + actions + clean: false + persist-credentials: false + +`) +} + +func writeMaintenanceActionsFolderCheckoutStep(b *strings.Builder) { + b.WriteString(` - name: Checkout actions folder + uses: ` + getActionPin("actions/checkout") + ` + with: + sparse-checkout: | + actions + clean: false + persist-credentials: false + +`) +} + +func writeMaintenanceRepositoryCheckoutStep(b *strings.Builder) { + b.WriteString(` - name: Checkout repository + uses: ` + getActionPin("actions/checkout") + ` + with: + persist-credentials: false + +`) +} + +func writeMaintenanceSetupScriptsStep(b *strings.Builder, setupActionRef string) { + b.WriteString(` - name: Setup Scripts + uses: ` + setupActionRef + ` + with: + destination: ${{ runner.temp }}/gh-aw/actions + +`) +} + +func writeMaintenanceAdminPermissionsStep(b *strings.Builder, opts buildMaintenanceWorkflowYAMLOptions, id string) { + b.WriteString(" - name: Check admin/maintainer permissions\n") + if id != "" { + b.WriteString(" id: " + id + "\n") + } + b.WriteString(` uses: ` + getCachedActionPinFromResolver("actions/github-script", opts.resolver) + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); + await main(); + +`) +} + +func writeMaintenanceCloseExpiredJobYAML(b *strings.Builder, opts buildMaintenanceWorkflowYAMLOptions, setupActionRef, jobName, permissionLine, stepName, scriptName string) { + b.WriteString(` ` + jobName + `: + if: ${{ ` + RenderCondition(buildNotForkAndScheduleOnly()) + ` }} + runs-on: ` + opts.runsOnValue + ` + permissions: + ` + permissionLine + ` + steps: +`) + writeMaintenanceConditionalActionsCheckoutStep(b, opts) + writeMaintenanceSetupScriptsStep(b, setupActionRef) + b.WriteString(` - name: ` + stepName + ` + uses: ` + getCachedActionPinFromResolver("actions/github-script", opts.resolver) + ` + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/` + scriptName + `.cjs'); + await main(); +`) +} + +func buildMaintenanceCloseExpiredJobs(opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + if opts.maintenanceConfig.IsJobDisabled("close-expired-entities") { + return "" + } + var b strings.Builder + writeMaintenanceCloseExpiredJobYAML(&b, opts, setupActionRef, "close-expired-discussions", "discussions: write", "Close expired discussions", "close_expired_discussions") + writeMaintenanceCloseExpiredJobYAML(&b, opts, setupActionRef, "close-expired-issues", "issues: write", "Close expired issues", "close_expired_issues") + writeMaintenanceCloseExpiredJobYAML(&b, opts, setupActionRef, "close-expired-pull-requests", "pull-requests: write", "Close expired pull requests", "close_expired_pull_requests") + return b.String() +} + +func buildMaintenanceCleanupCacheJob(opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + var b strings.Builder + b.WriteString(` + cleanup-cache-memory: + if: ${{ ` + RenderCondition(buildNotForkAndScheduleOnlyOrOperation("clean_cache_memories")) + ` }} + runs-on: ` + opts.runsOnValue + ` + permissions: + actions: write + steps: +`) + writeMaintenanceConditionalActionsCheckoutStep(&b, opts) + writeMaintenanceSetupScriptsStep(&b, setupActionRef) + b.WriteString(` - name: Cleanup outdated cache-memory entries + uses: ` + getCachedActionPinFromResolver("actions/github-script", opts.resolver) + ` + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/cleanup_cache_memory.cjs'); + await main(); +`) + return b.String() +} + +func buildMaintenanceRunOperationJob(ctx context.Context, opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + var b strings.Builder + b.WriteString(` + run_operation: + if: ${{ ` + RenderCondition(buildRunOperationCondition("safe_outputs", "create_labels", "activity_report", "close_agentic_workflows_issues", "clean_cache_memories", "update_pull_request_branches", "validate", "forecast")) + ` }} + runs-on: ` + opts.runsOnValue + ` + permissions: + actions: write + contents: write + pull-requests: write + outputs: + operation: ${{ steps.record.outputs.operation }} + steps: +`) + writeMaintenanceRepositoryCheckoutStep(&b) + writeMaintenanceSetupScriptsStep(&b, setupActionRef) + writeMaintenanceAdminPermissionsStep(&b, opts, "") + b.WriteString(generateInstallCLISteps(ctx, opts.actionMode, opts.version, opts.actionTag, opts.resolver)) + b.WriteString(` - name: Run operation + uses: ` + getCachedActionPinFromResolver("actions/github-script", opts.resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_OPERATION: ${{ inputs.operation }} + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(opts.actionMode) + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/run_operation_update_upgrade.cjs'); + await main(); + + - name: Record outputs + id: record + env: + GH_AW_OPERATION: ${{ inputs.operation }} + run: echo "operation=$GH_AW_OPERATION" >> "$GITHUB_OUTPUT" +`) + return b.String() +} + +func buildMaintenanceUpdatePRBranchesJob(opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + var b strings.Builder + b.WriteString(` + update_pull_request_branches: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("update_pull_request_branches")) + ` }} + runs-on: ` + opts.runsOnValue + ` + permissions: + contents: write + pull-requests: write + steps: +`) + writeMaintenanceConditionalActionsCheckoutStep(&b, opts) + writeMaintenanceSetupScriptsStep(&b, setupActionRef) + writeMaintenanceAdminPermissionsStep(&b, opts, "") + b.WriteString(` - name: Update pull request branches + uses: ` + getCachedActionPinFromResolver("actions/github-script", opts.resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/update_pull_request_branches.cjs'); + await main(); +`) + return b.String() +} + +func buildMaintenanceApplySafeOutputsJob(opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + if opts.maintenanceConfig.IsJobDisabled("apply_safe_outputs") { + return "" + } + var b strings.Builder + b.WriteString(` + apply_safe_outputs: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("safe_outputs")) + ` }} + runs-on: ` + opts.runsOnValue + ` + permissions: + actions: read + contents: write + discussions: write + issues: write + pull-requests: write + outputs: + run_url: ${{ steps.record.outputs.run_url }} + steps: +`) + writeMaintenanceActionsFolderCheckoutStep(&b) + writeMaintenanceSetupScriptsStep(&b, setupActionRef) + writeMaintenanceAdminPermissionsStep(&b, opts, "") + b.WriteString(` - name: Apply Safe Outputs + uses: ` + getCachedActionPinFromResolver("actions/github-script", opts.resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_RUN_URL: ${{ inputs.run_url }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/apply_safe_outputs_replay.cjs'); + await main(); + + - name: Record outputs + id: record + env: + GH_AW_RUN_URL: ${{ inputs.run_url }} + run: echo "run_url=$GH_AW_RUN_URL" >> "$GITHUB_OUTPUT" +`) + return b.String() +} + +func buildMaintenanceCreateLabelsJob(ctx context.Context, opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + var b strings.Builder + b.WriteString(` + create_labels: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("create_labels")) + ` }} + runs-on: ` + opts.runsOnValue + ` + permissions: + contents: read + issues: write + steps: +`) + writeMaintenanceRepositoryCheckoutStep(&b) + writeMaintenanceSetupScriptsStep(&b, setupActionRef) + writeMaintenanceAdminPermissionsStep(&b, opts, "") + b.WriteString(generateInstallCLISteps(ctx, opts.actionMode, opts.version, opts.actionTag, opts.resolver)) + b.WriteString(` - name: Create missing labels + uses: ` + getCachedActionPinFromResolver("actions/github-script", opts.resolver) + ` + env: + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(opts.actionMode) + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/create_labels.cjs'); + await main(); +`) + return b.String() +} + +func writeMaintenanceIssueReportJobPrefix(b *strings.Builder, opts buildMaintenanceWorkflowYAMLOptions, setupActionRef, jobName, operation string, timeoutMinutes int) { + b.WriteString(` + ` + jobName + `: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition(operation)) + ` }} + runs-on: ` + opts.runsOnValue + ` + timeout-minutes: ` + strconv.Itoa(timeoutMinutes) + ` + permissions: + actions: read + contents: read + issues: write + steps: +`) + writeMaintenanceRepositoryCheckoutStep(b) + writeMaintenanceSetupScriptsStep(b, setupActionRef) + writeMaintenanceAdminPermissionsStep(b, opts, "") +} + +func buildMaintenanceActivityReportJob(ctx context.Context, opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + var b strings.Builder + writeMaintenanceIssueReportJobPrefix(&b, opts, setupActionRef, "activity_report", "activity_report", 120) + b.WriteString(generateInstallCLISteps(ctx, opts.actionMode, opts.version, opts.actionTag, opts.resolver)) + b.WriteString(buildMaintenanceActivityReportCacheAndDownloadSteps(opts)) + b.WriteString(buildMaintenanceActivityReportIssueStep(opts.resolver)) + return b.String() +} + +// buildMaintenanceActivityReportCacheAndDownloadSteps returns the cache-restore, download, and cache-save steps. +func buildMaintenanceActivityReportCacheAndDownloadSteps(opts buildMaintenanceWorkflowYAMLOptions) string { + return ` - name: Restore activity report logs cache + id: activity_report_logs_cache + uses: ` + getActionPin("actions/cache/restore") + ` + with: + path: ./.cache/gh-aw/activity-report-logs + key: ${{ runner.os }}-activity-report-logs-${{ github.repository }}-${{ github.ref_name }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-activity-report-logs-${{ github.repository }}- + ${{ runner.os }}-activity-report-logs- + - name: Download activity report logs + timeout-minutes: 20 + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(opts.actionMode) + ` + run: | + ${GH_AW_CMD_PREFIX} logs \ + --repo "$GITHUB_REPOSITORY" \ + --start-date -1w \ + --count 500 \ + --output ./.cache/gh-aw/activity-report-logs \ + --format markdown \ + --report-file ./.cache/gh-aw/activity-report-logs/report.md + + - name: Save activity report logs cache + if: ${{ always() }} + uses: ` + getActionPin("actions/cache/save") + ` + with: + path: ./.cache/gh-aw/activity-report-logs + key: ${{ steps.activity_report_logs_cache.outputs.cache-primary-key }} + +` +} + +// buildMaintenanceActivityReportIssueStep returns the "Generate activity report issue" step. +func buildMaintenanceActivityReportIssueStep(resolver SHAResolver) string { + return ` - name: Generate activity report issue + uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const fs = require('node:fs'); + const reportPath = './.cache/gh-aw/activity-report-logs/report.md'; + if (!fs.existsSync(reportPath)) { + core.warning('Activity report markdown not found at ' + reportPath + '; skipping issue creation.'); + return; + } + let reportBody = ''; + try { + reportBody = fs.readFileSync(reportPath, 'utf8').trim(); + } catch (error) { + core.warning('Failed to read activity report markdown at ' + reportPath + ': ' + error.message); + return; + } + if (!reportBody) { + core.warning('Activity report markdown is empty at ' + reportPath + '; skipping issue creation.'); + return; + } + const repoSlug = context.repo.owner + '/' + context.repo.repo; + const body = [ + '### Agentic workflow activity report', + '', + 'Repository: ' + repoSlug, + 'Generated at: ' + new Date().toISOString(), + '', + reportBody, + ].join('\n'); + const createdIssue = await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title: '[aw] agentic status report', + body, + labels: ['agentic-workflows'], + }); + core.info('Created issue #' + createdIssue.data.number + ': ' + createdIssue.data.html_url); +` +} + +func buildMaintenanceForecastReportJob(ctx context.Context, opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + var b strings.Builder + writeMaintenanceIssueReportJobPrefix(&b, opts, setupActionRef, "forecast_report", "forecast", 60) + b.WriteString(generateInstallCLISteps(ctx, opts.actionMode, opts.version, opts.actionTag, opts.resolver)) + b.WriteString(buildMaintenanceForecastRunSteps(opts)) + b.WriteString(buildMaintenanceForecastIssueStep(opts.resolver)) + return b.String() +} + +// buildMaintenanceForecastRunSteps returns the cache-restore, run, debug, and cache-save steps. +func buildMaintenanceForecastRunSteps(opts buildMaintenanceWorkflowYAMLOptions) string { + return ` - name: Restore forecast report logs cache + id: forecast_report_logs_cache + uses: ` + getActionPin("actions/cache/restore") + ` + with: + path: ./.github/aw/logs + key: ${{ runner.os }}-forecast-report-logs-${{ github.repository }}-${{ github.ref_name }}-${{ github.run_id }} + restore-keys: | + ${{ runner.os }}-forecast-report-logs-${{ github.repository }}- + ${{ runner.os }}-forecast-report-logs- + + - name: Generate forecast report + id: generate_forecast_report + timeout-minutes: 30 + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DEBUG: "*" + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(opts.actionMode) + ` + run: | + mkdir -p ./.cache/gh-aw/forecast + set +e + ${GH_AW_CMD_PREFIX} forecast --repo "$GITHUB_REPOSITORY" --timeout 30 --verbose --json > ./.cache/gh-aw/forecast/report.json + forecast_exit_code=$? + set -e + if [ "${forecast_exit_code}" -eq 124 ]; then + echo '{"outcome":"timeout","message":"Forecast computation timed out after 30 minutes."}' > ./.cache/gh-aw/forecast/error.json + echo "::error::Forecast computation timed out after 30 minutes." + exit 1 + fi + if [ "${forecast_exit_code}" -ne 0 ]; then + echo '{"outcome":"error","message":"Forecast computation failed before producing a report."}' > ./.cache/gh-aw/forecast/error.json + echo "::error::Forecast computation failed with exit code ${forecast_exit_code}." + exit 1 + fi + + - name: Debug forecast logs folder + if: ${{ always() }} + shell: bash + run: | + if [ ! -d ./.github/aw/logs ]; then + echo "Logs directory not found: ./.github/aw/logs" + exit 0 + fi + echo "Files under ./.github/aw/logs:" + find ./.github/aw/logs -type f | sort + + - name: Save forecast report logs cache + if: ${{ always() }} + uses: ` + getActionPin("actions/cache/save") + ` + with: + path: ./.github/aw/logs + key: ${{ runner.os }}-forecast-report-logs-${{ github.repository }}-${{ github.ref_name }}-${{ github.run_id }} + +` +} + +// buildMaintenanceForecastIssueStep returns the "Generate forecast issue" step. +func buildMaintenanceForecastIssueStep(resolver SHAResolver) string { + return ` - name: Generate forecast issue + if: ${{ always() }} + uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` + env: + FORECAST_STEP_OUTCOME: ${{ steps.generate_forecast_report.outcome }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/create_forecast_issue.cjs'); + await main(); +` +} + +func buildMaintenanceCloseIssuesJob(opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + var b strings.Builder + b.WriteString(` + close_agentic_workflows_issues: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("close_agentic_workflows_issues")) + ` }} + runs-on: ` + opts.runsOnValue + ` + permissions: + issues: write + steps: +`) + writeMaintenanceConditionalActionsCheckoutStep(&b, opts) + writeMaintenanceSetupScriptsStep(&b, setupActionRef) + writeMaintenanceAdminPermissionsStep(&b, opts, "") + b.WriteString(` - name: Close no-repro agentic-workflows issues + uses: ` + getCachedActionPinFromResolver("actions/github-script", opts.resolver) + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/close_agentic_workflows_issues.cjs'); + await main(); +`) + return b.String() +} + +func buildMaintenanceValidateWorkflowsJob(ctx context.Context, opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + var b strings.Builder + b.WriteString(` + validate_workflows: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("validate")) + ` }} + runs-on: ` + FormatRunsOn(opts.configuredRunsOn, "ubuntu-latest") + ` + permissions: + contents: read + issues: write + steps: +`) + writeMaintenanceRepositoryCheckoutStep(&b) + writeMaintenanceSetupScriptsStep(&b, setupActionRef) + writeMaintenanceAdminPermissionsStep(&b, opts, "") + b.WriteString(generateInstallCLISteps(ctx, opts.actionMode, opts.version, opts.actionTag, opts.resolver)) + b.WriteString(` - name: Validate workflows and file issue on findings + uses: ` + getCachedActionPinFromResolver("actions/github-script", opts.resolver) + ` + env: + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(opts.actionMode) + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/run_validate_workflows.cjs'); + await main(); +`) + return b.String() +} + +func buildMaintenanceLabelDisableJob(opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + var b strings.Builder + b.WriteString(` + label_disable_agentic_workflow: + if: ${{ ` + RenderCondition(buildLabeledDisableCondition()) + ` }} + runs-on: ` + opts.runsOnValue + ` + permissions: + actions: write + contents: read + issues: write + steps: +`) + writeMaintenanceActionsFolderCheckoutStep(&b) + writeMaintenanceSetupScriptsStep(&b, setupActionRef) + writeMaintenanceAdminPermissionsStep(&b, opts, "check_permissions") + b.WriteString(` - name: Disable agentic workflow + if: ${{ steps.check_permissions.outcome == 'success' }} + uses: ` + getCachedActionPinFromResolver("actions/github-script", opts.resolver) + ` + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/disable_agentic_workflow.cjs'); + await main(); +`) + return b.String() +} + +func buildMaintenanceLabelApplySafeOutputsJob(opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + var b strings.Builder + b.WriteString(` + label_apply_safe_outputs: + if: ${{ ` + RenderCondition(buildLabeledApplySafeOutputsCondition()) + ` }} + runs-on: ` + opts.runsOnValue + ` + permissions: + actions: read + contents: write + discussions: write + issues: write + pull-requests: write + steps: +`) + writeMaintenanceActionsFolderCheckoutStep(&b) + writeMaintenanceSetupScriptsStep(&b, setupActionRef) + writeMaintenanceAdminPermissionsStep(&b, opts, "check_permissions") + b.WriteString(` - name: Apply safe outputs from referenced run + if: ${{ steps.check_permissions.outcome == 'success' }} + uses: ` + getCachedActionPinFromResolver("actions/github-script", opts.resolver) + ` + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/label_apply_safe_outputs.cjs'); + await main(); +`) + return b.String() +} + +func buildMaintenanceLabelTriggeredJobs(opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + labelDisableJobEnabled := !opts.disableLabelTrigger && !opts.maintenanceConfig.IsJobDisabled("label_disable_agentic_workflow") + labelApplySafeOutputsJobEnabled := !opts.disableLabelTrigger && !opts.maintenanceConfig.IsJobDisabled("label_apply_safe_outputs") + if !labelDisableJobEnabled && !labelApplySafeOutputsJobEnabled { + return "" + } + var b strings.Builder + if labelDisableJobEnabled { + b.WriteString(buildMaintenanceLabelDisableJob(opts, setupActionRef)) + } + if labelApplySafeOutputsJobEnabled { + b.WriteString(buildMaintenanceLabelApplySafeOutputsJob(opts, setupActionRef)) + } + return b.String() +} + +func writeMaintenanceCompileWorkflowsTokenStep(b *strings.Builder, opts buildMaintenanceWorkflowYAMLOptions) { + b.WriteString(` - name: Check for out-of-sync workflows and create issue or pull request if needed + uses: ` + getCachedActionPinFromResolver("actions/github-script", opts.resolver) + ` +`) + if opts.compileGitHubToken != "" { + b.WriteString(` env: + GH_AW_MAINTENANCE_GITHUB_TOKEN: ` + opts.compileGitHubToken + ` +`) + } + b.WriteString(` with: +`) + if opts.compileGitHubToken != "" { + b.WriteString(` github-token: ${{ env.GH_AW_MAINTENANCE_GITHUB_TOKEN }} +`) + } + b.WriteString(` script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_recompile_needed.cjs'); + await main(); +`) +} + +func buildMaintenanceCompileWorkflowsJob(ctx context.Context, opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + var b strings.Builder + b.WriteString(` + compile-workflows: + if: ${{ ` + RenderCondition(buildNotForkAndScheduled()) + ` }} + runs-on: ` + opts.runsOnValue + ` + concurrency: + group: ${{ github.workflow }}-compile-workflows-${{ github.repository }} + cancel-in-progress: true + permissions: + contents: read + issues: write + steps: + - name: Checkout repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + +`) + b.WriteString(generateInstallCLISteps(ctx, opts.actionMode, opts.version, opts.actionTag, opts.resolver)) + b.WriteString(` - name: Pre-compile validation + run: | + ` + getCLICmdPrefix(opts.actionMode) + ` compile --validate --no-emit --verbose + echo "✓ Pre-compile validation passed" + + - name: Compile workflows + run: | + ` + getCLICmdPrefix(opts.actionMode) + ` compile --validate --verbose + echo "✓ All workflows compiled successfully" + +`) + writeMaintenanceSetupScriptsStep(&b, setupActionRef) + writeMaintenanceCompileWorkflowsTokenStep(&b, opts) + return b.String() +} + +func buildMaintenanceSecretValidationJob(opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + copilotOrgBillingLine := "" + if opts.copilotOrgBilling { + maintenanceWorkflowYAMLLog.Print("Copilot org billing mode detected: adding GH_AW_COPILOT_ORG_BILLING=true to secret-validation step") + copilotOrgBillingLine = ` GH_AW_COPILOT_ORG_BILLING: "true" +` + } + var b strings.Builder + b.WriteString(` + secret-validation: + if: ${{ ` + RenderCondition(buildNotForkAndScheduleOnly()) + ` }} + runs-on: ` + opts.runsOnValue + ` + permissions: + contents: read + steps: +`) + writeMaintenanceActionsFolderCheckoutStep(&b) + b.WriteString(` - name: Setup Node.js + uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0 + with: + node-version: '22' + +`) + writeMaintenanceSetupScriptsStep(&b, setupActionRef) + b.WriteString(` - name: Validate Secrets + uses: ` + getCachedActionPinFromResolver("actions/github-script", opts.resolver) + ` + env: + # GitHub tokens + GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + GH_AW_PROJECT_GITHUB_TOKEN: ${{ secrets.GH_AW_PROJECT_GITHUB_TOKEN }} + GH_AW_COPILOT_TOKEN: ${{ secrets.GH_AW_COPILOT_TOKEN }} +` + copilotOrgBillingLine + ` # AI Engine API keys + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + BRAVE_API_KEY: ${{ secrets.BRAVE_API_KEY }} + # Integration tokens + NOTION_API_TOKEN: ${{ secrets.NOTION_API_TOKEN }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/validate_secrets.cjs'); + await main(); + + - name: Upload secret validation report + if: always() + uses: ` + getActionPin("actions/upload-artifact") + ` + with: + name: secret-validation-report + path: secret-validation-report.md + retention-days: 30 + if-no-files-found: warn +`) + return b.String() +} + +func buildMaintenanceDevOnlyJobs(ctx context.Context, opts buildMaintenanceWorkflowYAMLOptions, setupActionRef string) string { + if opts.actionMode != ActionModeDev { + return "" + } + maintenanceWorkflowYAMLLog.Printf("Adding dev-only jobs: compile-workflows and secret-validation") + return buildMaintenanceCompileWorkflowsJob(ctx, opts, setupActionRef) + buildMaintenanceSecretValidationJob(opts, setupActionRef) +} diff --git a/pkg/workflow/mcp_config_custom.go b/pkg/workflow/mcp_config_custom.go index 36bc4a0b4ce..8896a18271d 100644 --- a/pkg/workflow/mcp_config_custom.go +++ b/pkg/workflow/mcp_config_custom.go @@ -85,448 +85,300 @@ func renderCustomMCPEnvVars(env map[string]string, tomlFormat, requiresCopilotFi // This function handles the common logic for rendering MCP configurations across different engines func renderSharedMCPConfig(yaml *strings.Builder, toolName string, toolConfig map[string]any, renderer MCPConfigRenderer) error { mcpCustomLog.Printf("Rendering MCP config for tool: %s, format: %s", toolName, renderer.Format) + mcpConfig, headerSecrets, err := loadSharedMCPConfig(toolConfig, toolName) + if err != nil { + return err + } + propertyOrder, ok := determineMCPPropertyOrder(toolName, mcpConfig, renderer, headerSecrets) + if !ok { + return nil + } + existingProperties := collectExistingMCPProperties(propertyOrder, mcpConfig, renderer, headerSecrets) + if len(existingProperties) == 0 { + return nil + } + renderMCPProperties(yaml, existingProperties, mcpConfig, renderer, headerSecrets) + renderTrailingGuardPolicies(yaml, toolName, renderer) + return nil +} - // Get MCP configuration in the new format +func loadSharedMCPConfig(toolConfig map[string]any, toolName string) (*parser.RegistryMCPServerConfig, map[string]string, error) { mcpConfig, err := getMCPConfig(toolConfig, toolName) if err != nil { mcpCustomLog.Printf("Failed to parse MCP config for tool %s: %v", toolName, err) - return fmt.Errorf("failed to parse MCP config for tool '%s': %w", toolName, err) - } - - // Stdio servers must use Docker containerization. - // If a command is present without a container, the server is not containerized and will - // be rejected by the gateway schema validation at startup (for both TOML and JSON formats). - // For Python/Node/shell servers, use HTTP transport instead: - // mcp-servers: - // my-server: - // type: http - // url: "http://localhost:8765/mcp" - if mcpConfig.Type == "stdio" && mcpConfig.Command != "" && mcpConfig.Command != "docker" { - return fmt.Errorf( - "tool '%s' stdio MCP server uses command %q which is not supported by MCP Gateway. "+ - "Stdio servers must be containerized (use 'container' with 'entrypoint'), "+ - "or switch to HTTP transport for servers that run directly on the runner.\n\n"+ - "Example (container):\ntools:\n %s:\n container: \"my-registry/my-tool:latest\"\n entrypoint: \"my-tool\"\n args: [\"--verbose\"]\n\n"+ - "Example (HTTP — for Python/Node servers installed on the runner):\ntools:\n %s:\n type: http\n url: \"http://localhost:8765/mcp\"", - toolName, mcpConfig.Command, toolName, toolName, - ) - } - - // SECURITY: extract secrets from headers for all HTTP MCP engines so that - // secret values are passed as data through env vars rather than embedded - // directly in the JSON config as syntax. - var headerSecrets map[string]string + return nil, nil, fmt.Errorf("failed to parse MCP config for tool '%s': %w", toolName, err) + } + if err := validateSharedMCPConfig(toolName, mcpConfig); err != nil { + return nil, nil, err + } + headerSecrets := map[string]string(nil) if mcpConfig.Type == "http" { headerSecrets = ExtractSecretsFromMap(mcpConfig.Headers) } + return mcpConfig, headerSecrets, nil +} - // Determine properties based on type - var propertyOrder []string - mcpType := mcpConfig.Type +func validateSharedMCPConfig(toolName string, mcpConfig *parser.RegistryMCPServerConfig) error { + if mcpConfig.Type != "stdio" || mcpConfig.Command == "" || mcpConfig.Command == "docker" { + return nil + } + return fmt.Errorf( + "tool '%s' stdio MCP server uses command %q which is not supported by MCP Gateway. "+ + "Stdio servers must be containerized (use 'container' with 'entrypoint'), "+ + "or switch to HTTP transport for servers that run directly on the runner.\n\n"+ + "Example (container):\ntools:\n %s:\n container: \"my-registry/my-tool:latest\"\n entrypoint: \"my-tool\"\n args: [\"--verbose\"]\n\n"+ + "Example (HTTP — for Python/Node servers installed on the runner):\ntools:\n %s:\n type: http\n url: \"http://localhost:8765/mcp\"", + toolName, mcpConfig.Command, toolName, toolName, + ) +} - switch mcpType { +func determineMCPPropertyOrder(toolName string, mcpConfig *parser.RegistryMCPServerConfig, renderer MCPConfigRenderer, headerSecrets map[string]string) ([]string, bool) { + switch mcpConfig.Type { case "stdio": if renderer.Format == "toml" { - propertyOrder = []string{"container", "entrypoint", "entrypointArgs", "mounts", "command", "args", "env", "proxy-args", "registry"} - } else { - // JSON format - use MCP Gateway schema format (container-based) OR legacy command-based - // Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio servers SHOULD be containerized - // But we also support legacy command-based tools for backwards compatibility - propertyOrder = []string{"type", "container", "entrypoint", "entrypointArgs", "mounts", "command", "args", "tools", "env", "proxy-args", "registry", "required"} + return []string{"container", "entrypoint", "entrypointArgs", "mounts", "command", "args", "env", "proxy-args", "registry"}, true } + return []string{"type", "container", "entrypoint", "entrypointArgs", "mounts", "command", "args", "tools", "env", "proxy-args", "registry", "required"}, true case "http": if renderer.Format == "toml" { - // TOML format for HTTP MCP servers uses url and http_headers - propertyOrder = []string{"url", "http_headers"} - } else { - // JSON format - include tools field for MCP gateway tool filtering (all engines) - // For HTTP MCP with secrets in headers, env passthrough is needed - if len(headerSecrets) > 0 { - propertyOrder = []string{"type", "url", "headers", "auth", "tools", "env", "required"} - } else { - propertyOrder = []string{"type", "url", "headers", "auth", "tools", "required"} - } + return []string{"url", "http_headers"}, true + } + if len(headerSecrets) > 0 { + return []string{"type", "url", "headers", "auth", "tools", "env", "required"}, true } + return []string{"type", "url", "headers", "auth", "tools", "required"}, true default: - fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Custom MCP server '%s' has unsupported type '%s'. Supported types: stdio, http", toolName, mcpType))) - return nil + fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Custom MCP server '%s' has unsupported type '%s'. Supported types: stdio, http", toolName, mcpConfig.Type))) + return nil, false } +} - // Find which properties actually exist in this config - var existingProperties []string +func collectExistingMCPProperties(propertyOrder []string, mcpConfig *parser.RegistryMCPServerConfig, renderer MCPConfigRenderer, headerSecrets map[string]string) []string { + existingProperties := make([]string, 0, len(propertyOrder)) for _, prop := range propertyOrder { - switch prop { - case "type": - // Include type field only for engines that require copilot fields + if shouldRenderMCPProperty(prop, mcpConfig, renderer, headerSecrets) { existingProperties = append(existingProperties, prop) - case "tools": - // Include tools field for JSON format when: - // - RequiresCopilotFields (Copilot always renders it; when Allowed is empty, the - // rendering code below defaults to the "*" wildcard) - // - OR allowed tools are explicitly specified (pass the filter to the MCP gateway) - if renderer.RequiresCopilotFields || len(mcpConfig.Allowed) > 0 { - existingProperties = append(existingProperties, prop) - } - case "container": - if mcpConfig.Container != "" { - existingProperties = append(existingProperties, prop) - } - case "entrypoint": - if mcpConfig.Entrypoint != "" { - existingProperties = append(existingProperties, prop) - } - case "entrypointArgs": - if len(mcpConfig.EntrypointArgs) > 0 { - existingProperties = append(existingProperties, prop) - } - case "mounts": - if len(mcpConfig.Mounts) > 0 { - existingProperties = append(existingProperties, prop) - } - case "command": - if mcpConfig.Command != "" { - existingProperties = append(existingProperties, prop) - } - case "args": - if len(mcpConfig.Args) > 0 { - existingProperties = append(existingProperties, prop) - } - case "env": - // Include env if there are existing env vars OR if there are header secrets to passthrough - if len(mcpConfig.Env) > 0 || len(headerSecrets) > 0 { - existingProperties = append(existingProperties, prop) - } - case "url": - if mcpConfig.URL != "" { - existingProperties = append(existingProperties, prop) - } - case "headers": - if len(mcpConfig.Headers) > 0 { - existingProperties = append(existingProperties, prop) - } - case "auth": - if mcpConfig.Auth != nil { - existingProperties = append(existingProperties, prop) - } - case "http_headers": - if len(mcpConfig.Headers) > 0 { - existingProperties = append(existingProperties, prop) - } - case "proxy-args": - if len(mcpConfig.ProxyArgs) > 0 { - existingProperties = append(existingProperties, prop) - } - case "registry": - if mcpConfig.Registry != "" { - existingProperties = append(existingProperties, prop) - } - case "required": - // Only emit when explicitly set to false (false = optional; default/absent = required) - if mcpConfig.Required != nil && !*mcpConfig.Required { - existingProperties = append(existingProperties, prop) - } } } + return existingProperties +} - // If no valid properties exist, skip rendering - if len(existingProperties) == 0 { - return nil +func shouldRenderMCPProperty(prop string, mcpConfig *parser.RegistryMCPServerConfig, renderer MCPConfigRenderer, headerSecrets map[string]string) bool { + switch prop { + case "type": + return true + case "tools": + return renderer.RequiresCopilotFields || len(mcpConfig.Allowed) > 0 + case "container": + return mcpConfig.Container != "" + case "entrypoint": + return mcpConfig.Entrypoint != "" + case "entrypointArgs": + return len(mcpConfig.EntrypointArgs) > 0 + case "mounts": + return len(mcpConfig.Mounts) > 0 + case "command": + return mcpConfig.Command != "" + case "args": + return len(mcpConfig.Args) > 0 + case "env": + return len(mcpConfig.Env) > 0 || len(headerSecrets) > 0 + case "url": + return mcpConfig.URL != "" + case "headers", "http_headers": + return len(mcpConfig.Headers) > 0 + case "auth": + return mcpConfig.Auth != nil + case "proxy-args": + return len(mcpConfig.ProxyArgs) > 0 + case "registry": + return mcpConfig.Registry != "" + case "required": + return mcpConfig.Required != nil && !*mcpConfig.Required + default: + return false } +} - // When guard policies are present in JSON format, they become the actual last field. - // The last existing property must have a trailing comma to allow appending guard policies. +func renderMCPProperties(yaml *strings.Builder, properties []string, mcpConfig *parser.RegistryMCPServerConfig, renderer MCPConfigRenderer, headerSecrets map[string]string) { hasTrailingGuardPolicies := renderer.Format == "json" && len(renderer.GuardPolicies) > 0 + for propIndex, property := range properties { + isLast := propIndex == len(properties)-1 && !hasTrailingGuardPolicies + renderMCPProperty(yaml, property, isLast, mcpConfig, renderer, headerSecrets) + } +} - // Render properties based on format - for propIndex, property := range existingProperties { - // In JSON format, if guard policies follow, the last existing property is no longer "last" - isLast := (propIndex == len(existingProperties)-1) && !hasTrailingGuardPolicies - - switch property { - case "type": - // Render type field for JSON format (copilot engine) - comma := "," - if isLast { - comma = "" - } - // Type field - per MCP Gateway Specification v1.0.0 - // Use "stdio" for containerized servers, "http" for HTTP servers - typeValue := mcpConfig.Type - fmt.Fprintf(yaml, "%s\"type\": \"%s\"%s\n", renderer.IndentLevel, typeValue, comma) - case "tools": - // Render tools field for JSON format (copilot engine) - default to all tools - comma := "," - if isLast { - comma = "" - } - // Check if allowed tools are specified, otherwise default to "*" - if len(mcpConfig.Allowed) > 0 { - fmt.Fprintf(yaml, "%s\"tools\": [\n", renderer.IndentLevel) - for toolIndex, tool := range mcpConfig.Allowed { - toolComma := "," - if toolIndex == len(mcpConfig.Allowed)-1 { - toolComma = "" - } - fmt.Fprintf(yaml, "%s \"%s\"%s\n", renderer.IndentLevel, tool, toolComma) - } - fmt.Fprintf(yaml, "%s]%s\n", renderer.IndentLevel, comma) - } else { - fmt.Fprintf(yaml, "%s\"tools\": [\n", renderer.IndentLevel) - fmt.Fprintf(yaml, "%s \"*\"\n", renderer.IndentLevel) - fmt.Fprintf(yaml, "%s]%s\n", renderer.IndentLevel, comma) - } - case "container": - // Container field - per MCP Gateway Specification v1.0.0 section 4.1.2 - // Required for stdio servers (containerized servers) - if renderer.Format == "toml" { - fmt.Fprintf(yaml, "%scontainer = \"%s\"\n", renderer.IndentLevel, mcpConfig.Container) - } else { - comma := "," - if isLast { - comma = "" - } - fmt.Fprintf(yaml, "%s\"container\": \"%s\"%s\n", renderer.IndentLevel, mcpConfig.Container, comma) - } - case "entrypoint": - // Entrypoint field - per MCP Gateway Specification v1.0.0 - // Optional entrypoint override for container - if renderer.Format == "toml" { - fmt.Fprintf(yaml, "%sentrypoint = \"%s\"\n", renderer.IndentLevel, mcpConfig.Entrypoint) - } else { - comma := "," - if isLast { - comma = "" - } - fmt.Fprintf(yaml, "%s\"entrypoint\": \"%s\"%s\n", renderer.IndentLevel, mcpConfig.Entrypoint, comma) - } - case "entrypointArgs": - // EntrypointArgs field - per MCP Gateway Specification v1.0.0 - // Arguments passed to the container entrypoint - if renderer.Format == "toml" { - fmt.Fprintf(yaml, "%sentrypointArgs = [", renderer.IndentLevel) - for argIndex, arg := range mcpConfig.EntrypointArgs { - if argIndex > 0 { - yaml.WriteString(", ") - } - fmt.Fprintf(yaml, "\"%s\"", arg) - } - yaml.WriteString("]\n") - } else { - comma := "," - if isLast { - comma = "" - } - fmt.Fprintf(yaml, "%s\"entrypointArgs\": [\n", renderer.IndentLevel) - for argIndex, arg := range mcpConfig.EntrypointArgs { - argComma := "," - if argIndex == len(mcpConfig.EntrypointArgs)-1 { - argComma = "" - } - // Replace template expressions with environment variable references - argValue := arg - if renderer.RequiresCopilotFields { - argValue = ReplaceTemplateExpressionsWithEnvVars(argValue) - } - fmt.Fprintf(yaml, "%s \"%s\"%s\n", renderer.IndentLevel, argValue, argComma) - } - fmt.Fprintf(yaml, "%s]%s\n", renderer.IndentLevel, comma) - } - case "mounts": - // Mounts field - per MCP Gateway Specification v1.0.0 - // Volume mounts for the container - if renderer.Format == "toml" { - fmt.Fprintf(yaml, "%smounts = [", renderer.IndentLevel) - for mountIndex, mount := range mcpConfig.Mounts { - if mountIndex > 0 { - yaml.WriteString(", ") - } - fmt.Fprintf(yaml, "\"%s\"", mount) - } - yaml.WriteString("]\n") - } else { - comma := "," - if isLast { - comma = "" - } - fmt.Fprintf(yaml, "%s\"mounts\": [\n", renderer.IndentLevel) - for mountIndex, mount := range mcpConfig.Mounts { - mountComma := "," - if mountIndex == len(mcpConfig.Mounts)-1 { - mountComma = "" - } - // Replace template expressions with environment variable references - mountValue := mount - if renderer.RequiresCopilotFields { - mountValue = ReplaceTemplateExpressionsWithEnvVars(mountValue) - } - fmt.Fprintf(yaml, "%s \"%s\"%s\n", renderer.IndentLevel, mountValue, mountComma) - } - fmt.Fprintf(yaml, "%s]%s\n", renderer.IndentLevel, comma) - } - case "command": - if renderer.Format == "toml" { - fmt.Fprintf(yaml, "%scommand = \"%s\"\n", renderer.IndentLevel, mcpConfig.Command) - } else { - comma := "," - if isLast { - comma = "" - } - fmt.Fprintf(yaml, "%s\"command\": \"%s\"%s\n", renderer.IndentLevel, mcpConfig.Command, comma) - } - case "args": - if renderer.Format == "toml" { - fmt.Fprintf(yaml, "%sargs = [\n", renderer.IndentLevel) - for _, arg := range mcpConfig.Args { - fmt.Fprintf(yaml, "%s \"%s\",\n", renderer.IndentLevel, arg) - } - fmt.Fprintf(yaml, "%s]\n", renderer.IndentLevel) - } else { - comma := "," - if isLast { - comma = "" - } - fmt.Fprintf(yaml, "%s\"args\": [\n", renderer.IndentLevel) - for argIndex, arg := range mcpConfig.Args { - argComma := "," - if argIndex == len(mcpConfig.Args)-1 { - argComma = "" - } - fmt.Fprintf(yaml, "%s \"%s\"%s\n", renderer.IndentLevel, arg, argComma) - } - fmt.Fprintf(yaml, "%s]%s\n", renderer.IndentLevel, comma) - } - case "env": - renderedEnv := renderCustomMCPEnvVars(mcpConfig.Env, renderer.Format == "toml", renderer.RequiresCopilotFields) - if renderer.Format == "toml" { - writeTOMLInlineStringMapSection(yaml, renderer.IndentLevel, "env", renderedEnv) - } else { - // Add header secrets for passthrough (copilot only) - for varName := range headerSecrets { - // Only add if not already in env - if _, exists := renderedEnv[varName]; !exists { - // SECURITY: use passthrough syntax for all engines so the MCP gateway passes - // the env var value to the MCP server rather than the literal secret expression. - // The lock-file value is \${VAR_NAME} (single backslash); bash collapses \$ → $ - // so the heredoc delivers ${VAR_NAME} to the gateway for env-var expansion. - renderedEnv[varName] = "\\${" + varName + "}" - } - } - // Use raw (non-json.Marshal) writing for env values because they are placed - // inside an unquoted heredoc and may contain pre-escaped shell placeholders - // like \${VAR}. Passing those through json.Marshal would double-escape the - // backslash (\${VAR} → \\${VAR}), producing invalid JSON after bash processing. - writeJSONStringMapSectionRaw(yaml, renderer.IndentLevel, "env", renderedEnv, !isLast) - } - case "url": - // Rewrite localhost URLs to host.docker.internal when running inside firewall container - // This allows MCP servers running on the host to be accessed from the container - urlValue := mcpConfig.URL - if renderer.RewriteLocalhostToDocker { - urlValue = rewriteLocalhostToDockerHost(urlValue) - } - if renderer.Format == "toml" { - fmt.Fprintf(yaml, "%surl = \"%s\"\n", renderer.IndentLevel, urlValue) - } else { - comma := "," - if isLast { - comma = "" - } - fmt.Fprintf(yaml, "%s\"url\": \"%s\"%s\n", renderer.IndentLevel, urlValue, comma) - } - case "http_headers": - // TOML format for HTTP headers (Codex style) - if len(mcpConfig.Headers) > 0 { - writeTOMLInlineStringMapSection(yaml, renderer.IndentLevel, "http_headers", mcpConfig.Headers) - } - case "headers": - renderedHeaders := make(map[string]string, len(mcpConfig.Headers)) - for headerKey, headerValue := range mcpConfig.Headers { - // SECURITY: replace secret expressions with env var references for all engines. - // This prevents the token value from being embedded directly in the script text, - // treating it as data rather than syntax. - if len(headerSecrets) > 0 { - headerValue = ReplaceSecretsWithEnvVars(headerValue, headerSecrets) - } - renderedHeaders[headerKey] = headerValue - } - // Use raw (non-json.Marshal) writing for header values because they are placed - // inside an unquoted heredoc and may contain pre-escaped shell placeholders - // like \${VAR}. Passing those through json.Marshal would double-escape the - // backslash (\${VAR} → \\${VAR}), producing invalid JSON after bash processing. - writeJSONStringMapSectionRaw(yaml, renderer.IndentLevel, "headers", renderedHeaders, !isLast) - case "auth": - // Auth field - upstream OIDC authentication config (HTTP servers only, JSON format only) - // Guard against nil auth (defensive check, existingProperties should have filtered this out) - if mcpConfig.Auth == nil { - continue - } - comma := "," - if isLast { - comma = "" - } - fmt.Fprintf(yaml, "%s\"auth\": {\n", renderer.IndentLevel) - if mcpConfig.Auth.Audience != "" { - fmt.Fprintf(yaml, "%s \"type\": \"%s\",\n", renderer.IndentLevel, mcpConfig.Auth.Type) - fmt.Fprintf(yaml, "%s \"audience\": \"%s\"\n", renderer.IndentLevel, mcpConfig.Auth.Audience) - } else { - fmt.Fprintf(yaml, "%s \"type\": \"%s\"\n", renderer.IndentLevel, mcpConfig.Auth.Type) - } - fmt.Fprintf(yaml, "%s}%s\n", renderer.IndentLevel, comma) - case "proxy-args": - if renderer.Format == "toml" { - fmt.Fprintf(yaml, "%sproxy_args = [\n", renderer.IndentLevel) - for _, arg := range mcpConfig.ProxyArgs { - fmt.Fprintf(yaml, "%s \"%s\",\n", renderer.IndentLevel, arg) - } - fmt.Fprintf(yaml, "%s]\n", renderer.IndentLevel) - } else { - comma := "," - if isLast { - comma = "" - } - fmt.Fprintf(yaml, "%s\"proxy-args\": [\n", renderer.IndentLevel) - for argIndex, arg := range mcpConfig.ProxyArgs { - argComma := "," - if argIndex == len(mcpConfig.ProxyArgs)-1 { - argComma = "" - } - fmt.Fprintf(yaml, "%s \"%s\"%s\n", renderer.IndentLevel, arg, argComma) - } - fmt.Fprintf(yaml, "%s]%s\n", renderer.IndentLevel, comma) - } - case "registry": - if renderer.Format == "toml" { - fmt.Fprintf(yaml, "%sregistry = \"%s\"\n", renderer.IndentLevel, mcpConfig.Registry) - } else { - comma := "," - if isLast { - comma = "" - } - fmt.Fprintf(yaml, "%s\"registry\": \"%s\"%s\n", renderer.IndentLevel, mcpConfig.Registry, comma) - } - case "required": - // Only emitted for JSON format when Required is explicitly false (optional server). - // Absent field means the default (required), so we never emit "required": true. - if renderer.Format == "json" && mcpConfig.Required != nil && !*mcpConfig.Required { - comma := "," - if isLast { - comma = "" - } - fmt.Fprintf(yaml, "%s\"required\": false%s\n", renderer.IndentLevel, comma) - } - } +func renderMCPProperty(yaml *strings.Builder, property string, isLast bool, mcpConfig *parser.RegistryMCPServerConfig, renderer MCPConfigRenderer, headerSecrets map[string]string) { + switch property { + case "type", "container", "entrypoint", "command", "url", "registry", "required": + renderMCPScalarProperty(yaml, property, isLast, mcpConfig, renderer) + case "tools", "entrypointArgs", "mounts", "args", "proxy-args": + renderMCPArrayProperty(yaml, property, isLast, mcpConfig, renderer) + case "env", "http_headers", "headers": + renderMCPMapProperty(yaml, property, isLast, mcpConfig, renderer, headerSecrets) + case "auth": + renderMCPAuthProperty(yaml, isLast, mcpConfig, renderer) } +} - // Render guard policies after all properties - if hasTrailingGuardPolicies { - // JSON format: guard policies are the last field inside the server object +func renderTrailingGuardPolicies(yaml *strings.Builder, toolName string, renderer MCPConfigRenderer) { + if renderer.Format == "json" && len(renderer.GuardPolicies) > 0 { renderGuardPoliciesJSON(yaml, renderer.GuardPolicies, renderer.IndentLevel) } else if renderer.Format == "toml" && len(renderer.GuardPolicies) > 0 { - // TOML format: guard policies are a separate TOML section after the server config renderGuardPoliciesToml(yaml, renderer.GuardPolicies, toolName) } +} - return nil +func renderMCPScalarProperty(yaml *strings.Builder, property string, isLast bool, mcpConfig *parser.RegistryMCPServerConfig, renderer MCPConfigRenderer) { + switch property { + case "type": + renderMCPJSONScalar(yaml, renderer, "type", mcpConfig.Type, isLast) + case "container": + renderMCPStringScalar(yaml, renderer, "container", mcpConfig.Container, isLast) + case "entrypoint": + renderMCPStringScalar(yaml, renderer, "entrypoint", mcpConfig.Entrypoint, isLast) + case "command": + renderMCPStringScalar(yaml, renderer, "command", mcpConfig.Command, isLast) + case "url": + urlValue := mcpConfig.URL + if renderer.RewriteLocalhostToDocker { + urlValue = rewriteLocalhostToDockerHost(urlValue) + } + renderMCPStringScalar(yaml, renderer, "url", urlValue, isLast) + case "registry": + renderMCPStringScalar(yaml, renderer, "registry", mcpConfig.Registry, isLast) + case "required": + if renderer.Format == "json" && mcpConfig.Required != nil && !*mcpConfig.Required { + fmt.Fprintf(yaml, "%s\"required\": false%s\n", renderer.IndentLevel, renderMCPComma(isLast)) + } + } +} + +func renderMCPStringScalar(yaml *strings.Builder, renderer MCPConfigRenderer, key, value string, isLast bool) { + if renderer.Format == "toml" { + fmt.Fprintf(yaml, "%s%s = \"%s\"\n", renderer.IndentLevel, key, value) + return + } + renderMCPJSONScalar(yaml, renderer, key, value, isLast) +} + +func renderMCPJSONScalar(yaml *strings.Builder, renderer MCPConfigRenderer, key, value string, isLast bool) { + fmt.Fprintf(yaml, "%s\"%s\": \"%s\"%s\n", renderer.IndentLevel, key, value, renderMCPComma(isLast)) +} + +func renderMCPArrayProperty(yaml *strings.Builder, property string, isLast bool, mcpConfig *parser.RegistryMCPServerConfig, renderer MCPConfigRenderer) { + switch property { + case "tools": + values := mcpConfig.Allowed + if len(values) == 0 { + values = []string{"*"} + } + renderMCPArray(yaml, renderer, "tools", values, isLast, false) + case "entrypointArgs": + renderMCPArray(yaml, renderer, "entrypointArgs", mcpConfig.EntrypointArgs, isLast, renderer.RequiresCopilotFields) + case "mounts": + renderMCPArray(yaml, renderer, "mounts", mcpConfig.Mounts, isLast, renderer.RequiresCopilotFields) + case "args": + renderMCPArray(yaml, renderer, "args", mcpConfig.Args, isLast, false) + case "proxy-args": + renderMCPArray(yaml, renderer, "proxy-args", mcpConfig.ProxyArgs, isLast, false) + } +} + +func renderMCPArray(yaml *strings.Builder, renderer MCPConfigRenderer, key string, values []string, isLast bool, replaceTemplates bool) { + if renderer.Format == "toml" { + renderMCPTOMLArray(yaml, renderer, key, values) + return + } + jsonKey := key + if key == "proxy-args" { + jsonKey = "proxy-args" + } + fmt.Fprintf(yaml, "%s\"%s\": [\n", renderer.IndentLevel, jsonKey) + for i, value := range values { + if replaceTemplates { + value = ReplaceTemplateExpressionsWithEnvVars(value) + } + fmt.Fprintf(yaml, "%s \"%s\"%s\n", renderer.IndentLevel, value, renderMCPComma(i == len(values)-1)) + } + fmt.Fprintf(yaml, "%s]%s\n", renderer.IndentLevel, renderMCPComma(isLast)) +} + +func renderMCPTOMLArray(yaml *strings.Builder, renderer MCPConfigRenderer, key string, values []string) { + tomlKey := strings.ReplaceAll(key, "-", "_") + if key == "entrypointArgs" || key == "mounts" { + fmt.Fprintf(yaml, "%s%s = [", renderer.IndentLevel, tomlKey) + for i, value := range values { + if i > 0 { + yaml.WriteString(", ") + } + fmt.Fprintf(yaml, "\"%s\"", value) + } + yaml.WriteString("]\n") + return + } + fmt.Fprintf(yaml, "%s%s = [\n", renderer.IndentLevel, tomlKey) + for _, value := range values { + fmt.Fprintf(yaml, "%s \"%s\",\n", renderer.IndentLevel, value) + } + fmt.Fprintf(yaml, "%s]\n", renderer.IndentLevel) +} + +func renderMCPMapProperty(yaml *strings.Builder, property string, isLast bool, mcpConfig *parser.RegistryMCPServerConfig, renderer MCPConfigRenderer, headerSecrets map[string]string) { + switch property { + case "env": + renderMCPEnvMap(yaml, isLast, mcpConfig, renderer, headerSecrets) + case "http_headers": + writeTOMLInlineStringMapSection(yaml, renderer.IndentLevel, "http_headers", mcpConfig.Headers) + case "headers": + renderMCPHeadersMap(yaml, isLast, mcpConfig, renderer, headerSecrets) + } +} + +func renderMCPEnvMap(yaml *strings.Builder, isLast bool, mcpConfig *parser.RegistryMCPServerConfig, renderer MCPConfigRenderer, headerSecrets map[string]string) { + renderedEnv := renderCustomMCPEnvVars(mcpConfig.Env, renderer.Format == "toml", renderer.RequiresCopilotFields) + if renderer.Format == "toml" { + writeTOMLInlineStringMapSection(yaml, renderer.IndentLevel, "env", renderedEnv) + return + } + for varName := range headerSecrets { + if _, exists := renderedEnv[varName]; !exists { + renderedEnv[varName] = "\\${" + varName + "}" + } + } + writeJSONStringMapSectionRaw(yaml, renderer.IndentLevel, "env", renderedEnv, !isLast) +} + +func renderMCPHeadersMap(yaml *strings.Builder, isLast bool, mcpConfig *parser.RegistryMCPServerConfig, renderer MCPConfigRenderer, headerSecrets map[string]string) { + renderedHeaders := make(map[string]string, len(mcpConfig.Headers)) + for headerKey, headerValue := range mcpConfig.Headers { + if len(headerSecrets) > 0 { + headerValue = ReplaceSecretsWithEnvVars(headerValue, headerSecrets) + } + renderedHeaders[headerKey] = headerValue + } + writeJSONStringMapSectionRaw(yaml, renderer.IndentLevel, "headers", renderedHeaders, !isLast) +} + +func renderMCPAuthProperty(yaml *strings.Builder, isLast bool, mcpConfig *parser.RegistryMCPServerConfig, renderer MCPConfigRenderer) { + if mcpConfig.Auth == nil { + return + } + fmt.Fprintf(yaml, "%s\"auth\": {\n", renderer.IndentLevel) + if mcpConfig.Auth.Audience != "" { + fmt.Fprintf(yaml, "%s \"type\": \"%s\",\n", renderer.IndentLevel, mcpConfig.Auth.Type) + fmt.Fprintf(yaml, "%s \"audience\": \"%s\"\n", renderer.IndentLevel, mcpConfig.Auth.Audience) + } else { + fmt.Fprintf(yaml, "%s \"type\": \"%s\"\n", renderer.IndentLevel, mcpConfig.Auth.Type) + } + fmt.Fprintf(yaml, "%s}%s\n", renderer.IndentLevel, renderMCPComma(isLast)) +} + +func renderMCPComma(isLast bool) string { + if isLast { + return "" + } + return "," } // collectHTTPMCPHeaderSecrets collects all secrets from HTTP MCP tool headers @@ -550,24 +402,11 @@ func collectHTTPMCPHeaderSecrets(tools map[string]any) map[string]string { return allSecrets } -// getMCPConfig extracts MCP configuration from a tool config and returns a structured MCPServerConfig -func getMCPConfig(toolConfig map[string]any, toolName string) (*parser.RegistryMCPServerConfig, error) { - mcpCustomLog.Printf("Extracting MCP config for tool: %s", toolName) - - config := MapToolConfig(toolConfig) - result := &parser.RegistryMCPServerConfig{ - BaseMCPServerConfig: types.BaseMCPServerConfig{ - Env: make(map[string]string), - Headers: make(map[string]string), - }, - Name: toolName, - } - - // Validate known properties - fail if unknown properties are found - knownProperties := map[string]struct { - }{ +// validateMCPKnownProperties checks that all keys in toolConfig are in the known set. +func validateMCPKnownProperties(toolConfig map[string]any, toolName string) error { + knownProperties := map[string]struct{}{ "type": {}, - "mode": {}, // Added for MCPServerConfig struct + "mode": {}, "command": {}, "container": {}, "version": {}, @@ -582,184 +421,190 @@ func getMCPConfig(toolConfig map[string]any, toolName string) (*parser.RegistryM "auth": {}, "registry": {}, "allowed": {}, - "toolsets": {}, // Added for MCPServerConfig struct - "required": {}, // Whether the server is required at startup (false = optional, default = required) + "toolsets": {}, + "required": {}, } - for key := range toolConfig { if !setutil.Contains(knownProperties, key) { mcpCustomLog.Printf("Unknown property '%s' in MCP config for tool '%s'", key, toolName) - // Build list of valid properties validProps := sliceutil.SortedKeys(knownProperties) - return nil, fmt.Errorf( + return fmt.Errorf( "unknown property '%s' in MCP configuration for tool '%s'. Valid properties are: %s. "+ - "Example:\n"+ - "mcp-servers:\n"+ - " %s:\n"+ - " command: \"npx @my/tool\"\n"+ - " args: [\"--port\", \"3000\"]", + "Example:\nmcp-servers:\n %s:\n command: \"npx @my/tool\"\n args: [\"--port\", \"3000\"]", key, toolName, strings.Join(validProps, ", "), toolName) } } + return nil +} - // Infer type from fields if not explicitly provided +// resolveMCPType determines the MCP server type from explicit or inferred fields. +func resolveMCPType(config MapToolConfig, toolName string) (string, error) { if typeStr, hasType := config.GetString("type"); hasType { mcpCustomLog.Printf("MCP type explicitly set to: %s", typeStr) - // Normalize "local" to "stdio" if typeStr == "local" { - result.Type = "stdio" - } else { - result.Type = typeStr + return "stdio", nil } + return typeStr, nil + } + mcpCustomLog.Print("No explicit MCP type, inferring from fields") + if _, hasURL := config.GetString("url"); hasURL { + mcpCustomLog.Printf("Inferred MCP type as http (has url field)") + return "http", nil + } + if _, hasCommand := config.GetString("command"); hasCommand { + mcpCustomLog.Printf("Inferred MCP type as stdio (has command field)") + return "stdio", nil + } + if _, hasContainer := config.GetString("container"); hasContainer { + mcpCustomLog.Printf("Inferred MCP type as stdio (has container field)") + return "stdio", nil + } + mcpCustomLog.Printf("Unable to determine MCP type for tool '%s': missing type, url, command, or container", toolName) + return "", fmt.Errorf( + "unable to determine MCP type for tool '%s': missing type, url, command, or container. "+ + "Must specify one of: 'type' (stdio/http), 'url' (for HTTP MCP), 'command' (for command-based), or 'container' (for Docker-based). "+ + "Example:\nmcp-servers:\n %s:\n command: \"npx @my/tool\"\n args: [\"--port\", \"3000\"]", + toolName, toolName) +} + +// extractMCPStdioFields populates stdio-specific fields on result. +func extractMCPStdioFields(config MapToolConfig, result *parser.RegistryMCPServerConfig) { + if command, ok := config.GetString("command"); ok { + result.Command = command + } + if container, ok := config.GetString("container"); ok { + result.Container = container + } + if version, ok := config.GetString("version"); ok { + result.Version = version + } + if args, ok := config.GetStringArray("args"); ok { + result.Args = args + } + if entrypoint, ok := config.GetString("entrypoint"); ok { + result.Entrypoint = entrypoint + } + if entrypointArgs, ok := config.GetStringArray("entrypointArgs"); ok { + result.EntrypointArgs = entrypointArgs + } + if mounts, ok := config.GetStringArray("mounts"); ok { + result.Mounts = mounts + } + if env, ok := config.GetStringMap("env"); ok { + result.Env = env + } + if proxyArgs, ok := config.GetStringArray("proxy-args"); ok { + result.ProxyArgs = proxyArgs + } +} + +// extractMCPHTTPFields populates HTTP-specific fields on result. +func extractMCPHTTPFields(config MapToolConfig, toolName string, result *parser.RegistryMCPServerConfig) error { + if url, hasURL := config.GetString("url"); hasURL { + result.URL = url } else { - mcpCustomLog.Print("No explicit MCP type, inferring from fields") - // Infer type from presence of fields - if _, hasURL := config.GetString("url"); hasURL { - result.Type = "http" - mcpCustomLog.Printf("Inferred MCP type as http (has url field)") - } else if _, hasCommand := config.GetString("command"); hasCommand { - result.Type = "stdio" - mcpCustomLog.Printf("Inferred MCP type as stdio (has command field)") - } else if _, hasContainer := config.GetString("container"); hasContainer { - result.Type = "stdio" - mcpCustomLog.Printf("Inferred MCP type as stdio (has container field)") - } else { - mcpCustomLog.Printf("Unable to determine MCP type for tool '%s': missing type, url, command, or container", toolName) - return nil, fmt.Errorf( - "unable to determine MCP type for tool '%s': missing type, url, command, or container. "+ - "Must specify one of: 'type' (stdio/http), 'url' (for HTTP MCP), 'command' (for command-based), or 'container' (for Docker-based). "+ - "Example:\n"+ - "mcp-servers:\n"+ - " %s:\n"+ - " command: \"npx @my/tool\"\n"+ - " args: [\"--port\", \"3000\"]", - toolName, toolName, - ) + mcpCustomLog.Printf("HTTP MCP tool '%s' missing required 'url' field", toolName) + return fmt.Errorf( + "http MCP tool '%s' missing required 'url' field. HTTP MCP servers must specify a URL endpoint. "+ + "Example:\nmcp-servers:\n %s:\n type: http\n url: \"https://api.example.com/mcp\"\n headers:\n Authorization: \"****** secrets.API_KEY }}\"", + toolName, toolName) + } + if headers, ok := config.GetStringMap("headers"); ok { + result.Headers = headers + } + if authVal, hasAuth := config.GetAny("auth"); hasAuth { + if authMap, ok := authVal.(map[string]any); ok { + authConfig := &types.MCPAuthConfig{} + if authType, ok := authMap["type"].(string); ok { + authConfig.Type = authType + } + if audience, ok := authMap["audience"].(string); ok { + authConfig.Audience = audience + } + if authConfig.Type != "" { + result.Auth = authConfig + } + } else if authCfg, ok := authVal.(*types.MCPAuthConfig); ok { + result.Auth = authCfg } } + return nil +} - // Extract common fields (available for both stdio and http) - if registry, hasRegistry := config.GetString("registry"); hasRegistry { +// postProcessMCPConfig applies auto-container assignment and version merging for stdio configs. +func postProcessMCPConfig(result *parser.RegistryMCPServerConfig) { + if result.Type == "stdio" && result.Container == "" && result.Command != "" { + if containerConfig := getWellKnownContainer(result.Command); containerConfig != nil { + mcpCustomLog.Printf("Auto-assigning container for command '%s': %s", result.Command, containerConfig.Image) + result.Container = containerConfig.Image + result.Entrypoint = containerConfig.Entrypoint + // The command becomes the container entrypoint; original args become entrypointArgs. + // Do NOT prepend the command to entrypointArgs — the entrypoint field already carries it. + result.EntrypointArgs = result.Args + result.Args = nil + result.Command = "" + } + } + // Combine container and version into a single image reference. + if result.Type == "stdio" && result.Container != "" && result.Version != "" { + result.Container = result.Container + ":" + result.Version + result.Version = "" + } +} + +// getMCPConfig extracts MCP configuration from a tool config and returns a structured MCPServerConfig +func getMCPConfig(toolConfig map[string]any, toolName string) (*parser.RegistryMCPServerConfig, error) { + mcpCustomLog.Printf("Extracting MCP config for tool: %s", toolName) + + config := MapToolConfig(toolConfig) + result := &parser.RegistryMCPServerConfig{ + BaseMCPServerConfig: types.BaseMCPServerConfig{ + Env: make(map[string]string), + Headers: make(map[string]string), + }, + Name: toolName, + } + + if err := validateMCPKnownProperties(toolConfig, toolName); err != nil { + return nil, err + } + + mcpType, err := resolveMCPType(config, toolName) + if err != nil { + return nil, err + } + result.Type = mcpType + + if registry, ok := config.GetString("registry"); ok { result.Registry = registry } - // Extract fields based on type mcpCustomLog.Printf("Extracting fields for MCP type: %s", result.Type) switch result.Type { case "stdio": - if command, hasCommand := config.GetString("command"); hasCommand { - result.Command = command - } - if container, hasContainer := config.GetString("container"); hasContainer { - result.Container = container - } - if version, hasVersion := config.GetString("version"); hasVersion { - result.Version = version - } - if args, hasArgs := config.GetStringArray("args"); hasArgs { - result.Args = args - } - if entrypoint, hasEntrypoint := config.GetString("entrypoint"); hasEntrypoint { - result.Entrypoint = entrypoint - } - if entrypointArgs, hasEntrypointArgs := config.GetStringArray("entrypointArgs"); hasEntrypointArgs { - result.EntrypointArgs = entrypointArgs - } - if mounts, hasMounts := config.GetStringArray("mounts"); hasMounts { - result.Mounts = mounts - } - if env, hasEnv := config.GetStringMap("env"); hasEnv { - result.Env = env - } - if proxyArgs, hasProxyArgs := config.GetStringArray("proxy-args"); hasProxyArgs { - result.ProxyArgs = proxyArgs - } + extractMCPStdioFields(config, result) case "http": - if url, hasURL := config.GetString("url"); hasURL { - result.URL = url - } else { - mcpCustomLog.Printf("HTTP MCP tool '%s' missing required 'url' field", toolName) - return nil, fmt.Errorf( - "http MCP tool '%s' missing required 'url' field. HTTP MCP servers must specify a URL endpoint. "+ - "Example:\n"+ - "mcp-servers:\n"+ - " %s:\n"+ - " type: http\n"+ - " url: \"https://api.example.com/mcp\"\n"+ - " headers:\n"+ - " Authorization: \"Bearer ${{ secrets.API_KEY }}\"", - toolName, toolName, - ) - } - if headers, hasHeaders := config.GetStringMap("headers"); hasHeaders { - result.Headers = headers - } - if authVal, hasAuth := config.GetAny("auth"); hasAuth { - if authMap, ok := authVal.(map[string]any); ok { - authConfig := &types.MCPAuthConfig{} - if authType, ok := authMap["type"].(string); ok { - authConfig.Type = authType - } - if audience, ok := authMap["audience"].(string); ok { - authConfig.Audience = audience - } - if authConfig.Type != "" { - result.Auth = authConfig - } - } else if authCfg, ok := authVal.(*types.MCPAuthConfig); ok { - result.Auth = authCfg - } + if err := extractMCPHTTPFields(config, toolName, result); err != nil { + return nil, err } default: mcpCustomLog.Printf("Unsupported MCP type '%s' for tool '%s'", result.Type, toolName) return nil, fmt.Errorf( "unsupported MCP type '%s' for tool '%s'. Valid types are: stdio, http. "+ - "Example:\n"+ - "mcp-servers:\n"+ - " %s:\n"+ - " type: stdio\n"+ - " command: \"npx @my/tool\"\n"+ - " args: [\"--port\", \"3000\"]", + "Example:\nmcp-servers:\n %s:\n type: stdio\n command: \"npx @my/tool\"\n args: [\"--port\", \"3000\"]", result.Type, toolName, toolName) } - // Extract allowed tools - if allowed, hasAllowed := config.GetStringArray("allowed"); hasAllowed { + if allowed, ok := config.GetStringArray("allowed"); ok { result.Allowed = allowed } - - // Extract required field: when explicitly set to false the server is optional at startup - if requiredVal, hasRequired := config.GetAny("required"); hasRequired { + if requiredVal, ok := config.GetAny("required"); ok { if requiredBool, ok := requiredVal.(bool); ok { result.Required = &requiredBool } } - // Automatically assign well-known containers for stdio MCP servers based on command - // This ensures all stdio servers work with the MCP Gateway which requires containerization - if result.Type == "stdio" && result.Container == "" && result.Command != "" { - containerConfig := getWellKnownContainer(result.Command) - if containerConfig != nil { - mcpCustomLog.Printf("Auto-assigning container for command '%s': %s", result.Command, containerConfig.Image) - result.Container = containerConfig.Image - result.Entrypoint = containerConfig.Entrypoint - // The command becomes the container entrypoint; original args become entrypointArgs. - // Do NOT prepend the command to entrypointArgs — the entrypoint field already carries it, - // and prepending would cause it to appear twice (e.g. "npx npx @sentry/mcp-server"). - result.EntrypointArgs = result.Args - result.Args = nil // Clear args since they're now in entrypointArgs - result.Command = "" // Clear command since it's now the entrypoint - } - } - - // Combine container and version fields into a single container image string - // Per MCP Gateway Specification, the container field should include the full image reference - // including the tag (e.g., "mcp/ast-grep:latest" instead of separate container + version fields) - if result.Type == "stdio" && result.Container != "" && result.Version != "" { - result.Container = result.Container + ":" + result.Version - result.Version = "" // Clear version since it's now part of container - } - + postProcessMCPConfig(result) return result, nil } diff --git a/pkg/workflow/repo_memory.go b/pkg/workflow/repo_memory.go index bce79c255c6..7d2291f0d59 100644 --- a/pkg/workflow/repo_memory.go +++ b/pkg/workflow/repo_memory.go @@ -76,421 +76,257 @@ func generateDefaultBranchName(memoryID string, branchPrefix string) string { // extractRepoMemoryConfig extracts repo-memory configuration from tools section. // workflowID is used to qualify the default branch name (e.g. "memory/{workflowID}"). func (c *Compiler) extractRepoMemoryConfig(toolsConfig *ToolsConfig, workflowID string) (*RepoMemoryConfig, error) { - // Check if repo-memory tool is configured if toolsConfig == nil || toolsConfig.RepoMemory == nil { return nil, nil } - repoMemoryLog.Print("Extracting repo-memory configuration from ToolsConfig") - config := &RepoMemoryConfig{ - BranchPrefix: "memory", // Default branch prefix + BranchPrefix: "memory", } repoMemoryValue := toolsConfig.RepoMemory.Raw - - // defaultMemoryBranchID returns workflowID when set, otherwise "default". - // This qualifies the default branch name by workflow, e.g. "memory/repo-assist". - defaultMemoryBranchID := func() string { - if workflowID != "" { - return workflowID - } - return "default" - } - - // Handle nil value (simple enable with defaults) - same as true if repoMemoryValue == nil { repoMemoryLog.Print("Using default repo-memory configuration (nil value)") - config.Memories = []RepoMemoryEntry{ - { - ID: "default", - BranchName: generateDefaultBranchName(defaultMemoryBranchID(), config.BranchPrefix), - MaxFileSize: defaultRepoMemoryMaxFileSize, // 100KB - MaxFileCount: 100, - MaxPatchSize: defaultRepoMemoryMaxPatchSize, // 10KB - CreateOrphan: true, - AllowedExtensions: constants.DefaultAllowedMemoryExtensions, - }, - } + config.Memories = []RepoMemoryEntry{newDefaultRepoMemoryEntry(workflowID, config.BranchPrefix)} return config, nil } - - // Handle boolean value (simple enable/disable) if boolValue, ok := repoMemoryValue.(bool); ok { if boolValue { repoMemoryLog.Print("Using default repo-memory configuration (boolean true)") - // Create a single default memory entry - config.Memories = []RepoMemoryEntry{ - { - ID: "default", - BranchName: generateDefaultBranchName(defaultMemoryBranchID(), config.BranchPrefix), - MaxFileSize: defaultRepoMemoryMaxFileSize, // 100KB - MaxFileCount: 100, - MaxPatchSize: defaultRepoMemoryMaxPatchSize, // 10KB - CreateOrphan: true, - AllowedExtensions: constants.DefaultAllowedMemoryExtensions, - }, - } + config.Memories = []RepoMemoryEntry{newDefaultRepoMemoryEntry(workflowID, config.BranchPrefix)} } else { repoMemoryLog.Print("Repo-memory disabled (boolean false)") } - // If false, return empty config (empty array means disabled) return config, nil } - - // Handle array of memory configurations if memoryArray, ok := repoMemoryValue.([]any); ok { - repoMemoryLog.Printf("Processing memory array with %d entries", len(memoryArray)) - config.Memories = make([]RepoMemoryEntry, 0, len(memoryArray)) - - // Parse branch-prefix from first item if it's a map with branch-prefix key - // This allows branch-prefix to be set at the top level for all memories - if len(memoryArray) > 0 { - if firstItem, ok := memoryArray[0].(map[string]any); ok { - if branchPrefix, exists := firstItem["branch-prefix"]; exists { - if prefixStr, ok := branchPrefix.(string); ok { - if err := validateBranchPrefix(prefixStr); err != nil { - return nil, err - } - config.BranchPrefix = prefixStr - repoMemoryLog.Printf("Using custom branch-prefix: %s", prefixStr) - } - } - } - } - - for _, item := range memoryArray { - if memoryMap, ok := item.(map[string]any); ok { - entry := RepoMemoryEntry{ - MaxFileSize: defaultRepoMemoryMaxFileSize, // 100KB default - MaxFileCount: 100, // 100 files default - MaxPatchSize: defaultRepoMemoryMaxPatchSize, // 10KB default - CreateOrphan: true, // create orphan by default - } - - // ID is required for array notation - explicitID := false - if id, exists := memoryMap["id"]; exists { - if idStr, ok := id.(string); ok { - entry.ID = idStr - explicitID = true - } - } - // Use "default" if no ID specified - if entry.ID == "" { - entry.ID = "default" - } - - // Parse target-repo - if targetRepo, exists := memoryMap["target-repo"]; exists { - if repoStr, ok := targetRepo.(string); ok { - entry.TargetRepo = repoStr - } - } - - // Parse branch-name - explicitBranchName := false - if branchName, exists := memoryMap["branch-name"]; exists { - if branchStr, ok := branchName.(string); ok { - entry.BranchName = branchStr - explicitBranchName = true - } - } - // Set default branch name if not specified. - // When no explicit ID was provided (defaulted to "default"), qualify the branch by workflow ID. - if entry.BranchName == "" { - branchID := entry.ID - if !explicitID { - branchID = defaultMemoryBranchID() - } - entry.BranchName = generateDefaultBranchName(branchID, config.BranchPrefix) - } - - // Parse file-glob - if fileGlob, exists := memoryMap["file-glob"]; exists { - if globArray, ok := fileGlob.([]any); ok { - entry.FileGlob = make([]string, 0, len(globArray)) - for _, item := range globArray { - if str, ok := item.(string); ok { - entry.FileGlob = append(entry.FileGlob, str) - } - } - } else if globStr, ok := fileGlob.(string); ok { - // Allow single string to be treated as array of one - entry.FileGlob = []string{globStr} - } - if err := validateFileGlobPatterns(entry.FileGlob); err != nil { - return nil, err - } - } - - // Parse max-file-size - if maxFileSize, exists := memoryMap["max-file-size"]; exists { - if sizeInt, ok := maxFileSize.(int); ok { - entry.MaxFileSize = sizeInt - } else if sizeFloat, ok := maxFileSize.(float64); ok { - entry.MaxFileSize = int(sizeFloat) - } else if sizeUint64, ok := maxFileSize.(uint64); ok { - entry.MaxFileSize = int(sizeUint64) - } - // Validate max-file-size bounds - if err := validateIntRange(entry.MaxFileSize, 1, 104857600, "max-file-size"); err != nil { - return nil, err - } - } - - // Parse max-file-count - if maxFileCount, exists := memoryMap["max-file-count"]; exists { - if countInt, ok := maxFileCount.(int); ok { - entry.MaxFileCount = countInt - } else if countFloat, ok := maxFileCount.(float64); ok { - entry.MaxFileCount = int(countFloat) - } else if countUint64, ok := maxFileCount.(uint64); ok { - entry.MaxFileCount = int(countUint64) - } - // Validate max-file-count bounds - if err := validateIntRange(entry.MaxFileCount, 1, 1000, "max-file-count"); err != nil { - return nil, err - } - } - - // Parse max-patch-size - if maxPatchSize, exists := memoryMap["max-patch-size"]; exists { - if sizeInt, ok := maxPatchSize.(int); ok { - entry.MaxPatchSize = sizeInt - } else if sizeFloat, ok := maxPatchSize.(float64); ok { - entry.MaxPatchSize = int(sizeFloat) - } else if sizeUint64, ok := maxPatchSize.(uint64); ok { - entry.MaxPatchSize = int(sizeUint64) - } - // Validate max-patch-size bounds - if err := validateIntRange(entry.MaxPatchSize, 1, maxRepoMemoryPatchSize, "max-patch-size"); err != nil { - return nil, err - } - } - - // Parse description - if description, exists := memoryMap["description"]; exists { - if descStr, ok := description.(string); ok { - entry.Description = descStr - } - } - - // Parse create-orphan - if createOrphan, exists := memoryMap["create-orphan"]; exists { - if orphanBool, ok := createOrphan.(bool); ok { - entry.CreateOrphan = orphanBool - } - } - - // Parse wiki field - if wiki, exists := memoryMap["wiki"]; exists { - if wikiBool, ok := wiki.(bool); ok { - entry.Wiki = wikiBool - } - } - // Apply wiki-mode defaults: wikis use master branch and never need orphan creation - if entry.Wiki { - if !explicitBranchName { - entry.BranchName = "master" - } - entry.CreateOrphan = false - } - - // Parse allowed-extensions field - if allowedExts, exists := memoryMap["allowed-extensions"]; exists { - if extArray, ok := allowedExts.([]any); ok { - entry.AllowedExtensions = make([]string, 0, len(extArray)) - for _, ext := range extArray { - if extStr, ok := ext.(string); ok { - entry.AllowedExtensions = append(entry.AllowedExtensions, extStr) - } - } - } - } - // Default to standard allowed extensions if not specified - if len(entry.AllowedExtensions) == 0 { - entry.AllowedExtensions = constants.DefaultAllowedMemoryExtensions - } - - // Parse format-json field - if formatJSON, exists := memoryMap["format-json"]; exists { - if formatJSONBool, ok := formatJSON.(bool); ok { - entry.FormatJSON = formatJSONBool - } - } - - config.Memories = append(config.Memories, entry) - } - } - - // Check for duplicate memory IDs - if err := validateNoDuplicateMemoryIDs(config.Memories); err != nil { + memories, err := parseRepoMemoryArray(memoryArray, workflowID, config) + if err != nil { return nil, err } - + config.Memories = memories return config, nil } - - // Handle object configuration (single memory, backward compatible) - // Convert to array with single entry if configMap, ok := repoMemoryValue.(map[string]any); ok { - repoMemoryLog.Print("Processing object-style repo-memory configuration (backward compatible)") - - // Parse branch-prefix if provided - if branchPrefix, exists := configMap["branch-prefix"]; exists { - if prefixStr, ok := branchPrefix.(string); ok { - if err := validateBranchPrefix(prefixStr); err != nil { - return nil, err - } - config.BranchPrefix = prefixStr - repoMemoryLog.Printf("Using custom branch-prefix: %s", prefixStr) - } + if err := applyRepoMemoryBranchPrefix(configMap, config); err != nil { + return nil, err } - - entry := RepoMemoryEntry{ - ID: "default", - BranchName: generateDefaultBranchName(defaultMemoryBranchID(), config.BranchPrefix), - MaxFileSize: defaultRepoMemoryMaxFileSize, // 100KB default - MaxFileCount: 100, // 100 files default - MaxPatchSize: defaultRepoMemoryMaxPatchSize, // 10KB default - CreateOrphan: true, // create orphan by default + entry, err := parseRepoMemoryEntry(configMap, workflowID, config.BranchPrefix, false) + if err != nil { + return nil, err } + config.Memories = []RepoMemoryEntry{entry} + return config, nil + } + return nil, nil +} - // Parse target-repo - if targetRepo, exists := configMap["target-repo"]; exists { - if repoStr, ok := targetRepo.(string); ok { - entry.TargetRepo = repoStr - } - } +func newDefaultRepoMemoryEntry(workflowID, branchPrefix string) RepoMemoryEntry { + return RepoMemoryEntry{ + ID: "default", + BranchName: generateDefaultBranchName(defaultRepoMemoryBranchID(workflowID), branchPrefix), + MaxFileSize: defaultRepoMemoryMaxFileSize, + MaxFileCount: 100, + MaxPatchSize: defaultRepoMemoryMaxPatchSize, + CreateOrphan: true, + AllowedExtensions: constants.DefaultAllowedMemoryExtensions, + } +} - // Parse branch-name - explicitBranchName := false - if branchName, exists := configMap["branch-name"]; exists { - if branchStr, ok := branchName.(string); ok { - entry.BranchName = branchStr - explicitBranchName = true - } - } +func defaultRepoMemoryBranchID(workflowID string) string { + if workflowID != "" { + return workflowID + } + return "default" +} - // Parse file-glob - if fileGlob, exists := configMap["file-glob"]; exists { - if globArray, ok := fileGlob.([]any); ok { - entry.FileGlob = make([]string, 0, len(globArray)) - for _, item := range globArray { - if str, ok := item.(string); ok { - entry.FileGlob = append(entry.FileGlob, str) - } - } - } else if globStr, ok := fileGlob.(string); ok { - // Allow single string to be treated as array of one - entry.FileGlob = []string{globStr} - } - if err := validateFileGlobPatterns(entry.FileGlob); err != nil { +func parseRepoMemoryArray(memoryArray []any, workflowID string, config *RepoMemoryConfig) ([]RepoMemoryEntry, error) { + repoMemoryLog.Printf("Processing memory array with %d entries", len(memoryArray)) + if len(memoryArray) > 0 { + if firstItem, ok := memoryArray[0].(map[string]any); ok { + if err := applyRepoMemoryBranchPrefix(firstItem, config); err != nil { return nil, err } } - - // Parse max-file-size - if maxFileSize, exists := configMap["max-file-size"]; exists { - if sizeInt, ok := maxFileSize.(int); ok { - entry.MaxFileSize = sizeInt - } else if sizeFloat, ok := maxFileSize.(float64); ok { - entry.MaxFileSize = int(sizeFloat) - } else if sizeUint64, ok := maxFileSize.(uint64); ok { - entry.MaxFileSize = int(sizeUint64) - } - // Validate max-file-size bounds - if err := validateIntRange(entry.MaxFileSize, 1, 104857600, "max-file-size"); err != nil { - return nil, err - } + } + memories := make([]RepoMemoryEntry, 0, len(memoryArray)) + for _, item := range memoryArray { + memoryMap, ok := item.(map[string]any) + if !ok { + continue + } + entry, err := parseRepoMemoryEntry(memoryMap, workflowID, config.BranchPrefix, true) + if err != nil { + return nil, err } + memories = append(memories, entry) + } + if err := validateNoDuplicateMemoryIDs(memories); err != nil { + return nil, err + } + return memories, nil +} - // Parse max-file-count - if maxFileCount, exists := configMap["max-file-count"]; exists { - if countInt, ok := maxFileCount.(int); ok { - entry.MaxFileCount = countInt - } else if countFloat, ok := maxFileCount.(float64); ok { - entry.MaxFileCount = int(countFloat) - } else if countUint64, ok := maxFileCount.(uint64); ok { - entry.MaxFileCount = int(countUint64) - } - // Validate max-file-count bounds - if err := validateIntRange(entry.MaxFileCount, 1, 1000, "max-file-count"); err != nil { - return nil, err - } - } +func applyRepoMemoryBranchPrefix(configMap map[string]any, config *RepoMemoryConfig) error { + branchPrefix, exists := configMap["branch-prefix"] + if !exists { + return nil + } + prefixStr, ok := branchPrefix.(string) + if !ok { + return nil + } + if err := validateBranchPrefix(prefixStr); err != nil { + return err + } + config.BranchPrefix = prefixStr + repoMemoryLog.Printf("Using custom branch-prefix: %s", prefixStr) + return nil +} - // Parse max-patch-size - if maxPatchSize, exists := configMap["max-patch-size"]; exists { - if sizeInt, ok := maxPatchSize.(int); ok { - entry.MaxPatchSize = sizeInt - } else if sizeFloat, ok := maxPatchSize.(float64); ok { - entry.MaxPatchSize = int(sizeFloat) - } else if sizeUint64, ok := maxPatchSize.(uint64); ok { - entry.MaxPatchSize = int(sizeUint64) - } - // Validate max-patch-size bounds - if err := validateIntRange(entry.MaxPatchSize, 1, maxRepoMemoryPatchSize, "max-patch-size"); err != nil { - return nil, err - } - } +func parseRepoMemoryEntry(memoryMap map[string]any, workflowID, branchPrefix string, requireID bool) (RepoMemoryEntry, error) { + entry := newDefaultRepoMemoryEntry(workflowID, branchPrefix) + explicitID, explicitBranchName := applyRepoMemoryIdentityFields(&entry, memoryMap) + if requireID && entry.ID == "" { + entry.ID = "default" + } + if entry.ID == "" { + entry.ID = "default" + } + if !explicitBranchName { + entry.BranchName = "" // let applyRepoMemoryDefaultBranch derive from ID + } + applyRepoMemoryDefaultBranch(&entry, workflowID, branchPrefix, explicitID) + if err := applyRepoMemoryFileFields(&entry, memoryMap); err != nil { + return RepoMemoryEntry{}, err + } + if err := applyRepoMemoryLimits(&entry, memoryMap); err != nil { + return RepoMemoryEntry{}, err + } + applyRepoMemoryOptionalFields(&entry, memoryMap) + finalizeRepoMemoryEntry(&entry, explicitBranchName) + return entry, nil +} - // Parse description - if description, exists := configMap["description"]; exists { - if descStr, ok := description.(string); ok { - entry.Description = descStr - } - } +func applyRepoMemoryIdentityFields(entry *RepoMemoryEntry, memoryMap map[string]any) (bool, bool) { + explicitID := false + if id, ok := memoryMap["id"].(string); ok { + entry.ID = id + explicitID = true + } + if targetRepo, ok := memoryMap["target-repo"].(string); ok { + entry.TargetRepo = targetRepo + } + explicitBranchName := false + if branchName, ok := memoryMap["branch-name"].(string); ok { + entry.BranchName = branchName + explicitBranchName = true + } + return explicitID, explicitBranchName +} - // Parse create-orphan - if createOrphan, exists := configMap["create-orphan"]; exists { - if orphanBool, ok := createOrphan.(bool); ok { - entry.CreateOrphan = orphanBool - } - } +func applyRepoMemoryDefaultBranch(entry *RepoMemoryEntry, workflowID, branchPrefix string, explicitID bool) { + if entry.BranchName != "" { + return + } + branchID := entry.ID + if !explicitID { + branchID = defaultRepoMemoryBranchID(workflowID) + } + entry.BranchName = generateDefaultBranchName(branchID, branchPrefix) +} - // Parse wiki field - if wiki, exists := configMap["wiki"]; exists { - if wikiBool, ok := wiki.(bool); ok { - entry.Wiki = wikiBool - } - } - // Apply wiki-mode defaults: wikis use master branch and never need orphan creation - if entry.Wiki { - if !explicitBranchName { - entry.BranchName = "master" - } - entry.CreateOrphan = false +func applyRepoMemoryFileFields(entry *RepoMemoryEntry, memoryMap map[string]any) error { + entry.FileGlob = parseRepoMemoryStringList(memoryMap["file-glob"]) + if len(entry.FileGlob) > 0 { + if err := validateFileGlobPatterns(entry.FileGlob); err != nil { + return err } + } + allowedExtensions := parseRepoMemoryStringList(memoryMap["allowed-extensions"]) + if len(allowedExtensions) > 0 { + entry.AllowedExtensions = allowedExtensions + } + if len(entry.AllowedExtensions) == 0 { + entry.AllowedExtensions = constants.DefaultAllowedMemoryExtensions + } + return nil +} - // Parse allowed-extensions field - if allowedExts, exists := configMap["allowed-extensions"]; exists { - if extArray, ok := allowedExts.([]any); ok { - entry.AllowedExtensions = make([]string, 0, len(extArray)) - for _, ext := range extArray { - if extStr, ok := ext.(string); ok { - entry.AllowedExtensions = append(entry.AllowedExtensions, extStr) - } - } +func applyRepoMemoryLimits(entry *RepoMemoryEntry, memoryMap map[string]any) error { + limits := []struct { + key string + min int + max int + field *int + }{ + {key: "max-file-size", min: 1, max: 104857600, field: &entry.MaxFileSize}, + {key: "max-file-count", min: 1, max: 1000, field: &entry.MaxFileCount}, + {key: "max-patch-size", min: 1, max: maxRepoMemoryPatchSize, field: &entry.MaxPatchSize}, + } + for _, limit := range limits { + if value, ok := parseRepoMemoryInt(memoryMap[limit.key]); ok { + *limit.field = value + if err := validateIntRange(value, limit.min, limit.max, limit.key); err != nil { + return err } } - // Default to standard allowed extensions if not specified - if len(entry.AllowedExtensions) == 0 { - entry.AllowedExtensions = constants.DefaultAllowedMemoryExtensions - } + } + return nil +} + +func applyRepoMemoryOptionalFields(entry *RepoMemoryEntry, memoryMap map[string]any) { + if description, ok := memoryMap["description"].(string); ok { + entry.Description = description + } + if createOrphan, ok := memoryMap["create-orphan"].(bool); ok { + entry.CreateOrphan = createOrphan + } + if wiki, ok := memoryMap["wiki"].(bool); ok { + entry.Wiki = wiki + } + if formatJSON, ok := memoryMap["format-json"].(bool); ok { + entry.FormatJSON = formatJSON + } +} + +func finalizeRepoMemoryEntry(entry *RepoMemoryEntry, explicitBranchName bool) { + if !entry.Wiki { + return + } + if !explicitBranchName { + entry.BranchName = "master" + } + entry.CreateOrphan = false +} - // Parse format-json field - if formatJSON, exists := configMap["format-json"]; exists { - if formatJSONBool, ok := formatJSON.(bool); ok { - entry.FormatJSON = formatJSONBool +func parseRepoMemoryStringList(value any) []string { + switch v := value.(type) { + case []any: + result := make([]string, 0, len(v)) + for _, item := range v { + if str, ok := item.(string); ok { + result = append(result, str) } } - - config.Memories = []RepoMemoryEntry{entry} - return config, nil + return result + case string: + return []string{v} + default: + return nil } +} - return nil, nil +func parseRepoMemoryInt(value any) (int, bool) { + switch v := value.(type) { + case int: + return v, true + case float64: + return int(v), true + case uint64: + return int(v), true + default: + return 0, false + } } // generateRepoMemoryArtifactUpload generates steps to upload repo-memory directories as artifacts. @@ -634,23 +470,45 @@ func (c *Compiler) buildPushRepoMemoryJob(data *WorkflowData, threatDetectionEna repoMemoryLog.Printf("Building push_repo_memory job for %d memories (threatDetectionEnabled=%v)", len(data.RepoMemoryConfig.Memories), threatDetectionEnabled) - var steps []string - - // Add setup step to copy scripts setupActionRef := c.resolveActionReference("./actions/setup", data) + steps := c.buildPushRepoMemorySetupAndCheckoutSteps(data, setupActionRef) + steps = append(steps, c.buildPushRepoMemoryDownloadSteps(data)...) + + useRequire := setupActionRef != "" + for _, memory := range data.RepoMemoryConfig.Memories { + steps = append(steps, c.buildSinglePushRepoMemoryStep(data, memory, useRequire)) + } + if c.actionMode.IsDev() { + steps = append(steps, c.generateRestoreActionsSetupStep()) + } + + jobCondition, jobNeeds := c.buildPushRepoMemoryJobCondition(threatDetectionEnabled) + outputs := buildPushRepoMemoryOutputs(data.RepoMemoryConfig.Memories) + concurrencyGroup := buildPushRepoMemoryConcurrencyGroup(data.RepoMemoryConfig.Memories) + concurrency := c.indentYAMLLines(fmt.Sprintf("concurrency:\n group: %q\n cancel-in-progress: false", concurrencyGroup), " ") + + return &Job{ + Name: pushRepoMemoryJobName, + DisplayName: "", + RunsOn: c.formatFrameworkJobRunsOn(data), + If: jobCondition, + Permissions: "permissions:\n contents: write", + Concurrency: concurrency, + Needs: jobNeeds, + Steps: steps, + Outputs: outputs, + }, nil +} + +// buildPushRepoMemorySetupAndCheckoutSteps builds setup, checkout, and git configuration steps. +func (c *Compiler) buildPushRepoMemorySetupAndCheckoutSteps(data *WorkflowData, setupActionRef string) []string { + var steps []string if setupActionRef != "" || c.actionMode.IsScript() { - // For dev mode (local action path), checkout the actions folder first steps = append(steps, c.generateCheckoutActionsFolder(data)...) - - // Repo memory job doesn't need project support - // Repo memory job depends on agent job; reuse the agent's trace ID so all jobs share one OTLP trace repoMemoryTraceID := fmt.Sprintf("${{ needs.%s.outputs.setup-trace-id }}", constants.ActivationJobName) repoMemoryParentSpanID := setupParentSpanNeedsExpr(constants.ActivationJobName) steps = append(steps, c.generateSetupStep(data, setupActionRef, SetupActionDestination, false, repoMemoryTraceID, repoMemoryParentSpanID)...) } - - // Add checkout step to configure git (without checking out files) - // We use sparse-checkout to avoid downloading files since we'll checkout the memory branch var checkoutStep strings.Builder checkoutStep.WriteString(" - name: Checkout repository\n") fmt.Fprintf(&checkoutStep, " uses: %s\n", getActionPin("actions/checkout")) @@ -658,20 +516,15 @@ func (c *Compiler) buildPushRepoMemoryJob(data *WorkflowData, threatDetectionEna checkoutStep.WriteString(" persist-credentials: false\n") checkoutStep.WriteString(" sparse-checkout: .\n") steps = append(steps, checkoutStep.String()) + return append(steps, c.generateGitConfigurationSteps()...) +} - // Add git configuration step - gitConfigSteps := c.generateGitConfigurationSteps() - steps = append(steps, gitConfigSteps...) - - // Build steps as complete YAML strings. - // In workflow_call context, use the per-invocation prefix from the agent job. +// buildPushRepoMemoryDownloadSteps builds download-artifact steps for all memory entries. +func (c *Compiler) buildPushRepoMemoryDownloadSteps(data *WorkflowData) []string { repoMemoryPrefix := artifactPrefixExprForAgentDownstreamJob(data) - + var steps []string for _, memory := range data.RepoMemoryConfig.Memories { - // Sanitize memory ID for artifact naming (remove hyphens, lowercase) sanitizedID := SanitizeWorkflowIDForCacheKey(memory.ID) - - // Download artifact step var step strings.Builder if memory.Wiki { fmt.Fprintf(&step, " - name: Download wiki-memory artifact (%s)\n", memory.ID) @@ -685,108 +538,71 @@ func (c *Compiler) buildPushRepoMemoryJob(data *WorkflowData, threatDetectionEna fmt.Fprintf(&step, " path: /tmp/gh-aw/repo-memory/%s\n", memory.ID) steps = append(steps, step.String()) } + return steps +} - // Determine script loading method based on action mode - useRequire := setupActionRef != "" - - // Add push steps for each memory - for _, memory := range data.RepoMemoryConfig.Memories { - targetRepo := memory.TargetRepo - if targetRepo == "" { - targetRepo = "${{ github.repository }}" - } - // For wiki mode, append .wiki to the repo path so the push script uses the wiki git URL - if memory.Wiki { - targetRepo = targetRepo + ".wiki" - } - - artifactDir := constants.TmpRepoMemoryDir + memory.ID - - // Build file glob filter string - fileGlobFilter := "" - if len(memory.FileGlob) > 0 { - fileGlobFilter = strings.Join(memory.FileGlob, " ") - } - - // Build step with github-script action - var step strings.Builder - if memory.Wiki { - fmt.Fprintf(&step, " - name: Push wiki-memory changes (%s)\n", memory.ID) - } else { - fmt.Fprintf(&step, " - name: Push repo-memory changes (%s)\n", memory.ID) - } - fmt.Fprintf(&step, " id: push_repo_memory_%s\n", memory.ID) - step.WriteString(" if: always()\n") - fmt.Fprintf(&step, " uses: %s\n", getCachedActionPin("actions/github-script", data)) - step.WriteString(" env:\n") - step.WriteString(" GH_TOKEN: ${{ github.token }}\n") - step.WriteString(" GITHUB_RUN_ID: ${{ github.run_id }}\n") - step.WriteString(" GITHUB_SERVER_URL: ${{ github.server_url }}\n") - fmt.Fprintf(&step, " ARTIFACT_DIR: %s\n", artifactDir) - fmt.Fprintf(&step, " MEMORY_ID: %s\n", memory.ID) - fmt.Fprintf(&step, " TARGET_REPO: %s\n", targetRepo) - fmt.Fprintf(&step, " BRANCH_NAME: %s\n", memory.BranchName) - // For wiki mode, pre-populate the allowed-repos list with the wiki repo so the push - // script accepts it (defaultRepo is always the plain github.repository, not .wiki) - if memory.Wiki { - fmt.Fprintf(&step, " REPO_MEMORY_ALLOWED_REPOS: %s\n", targetRepo) - } - fmt.Fprintf(&step, " MAX_FILE_SIZE: %d\n", memory.MaxFileSize) - fmt.Fprintf(&step, " MAX_FILE_COUNT: %d\n", memory.MaxFileCount) - fmt.Fprintf(&step, " MAX_PATCH_SIZE: %d\n", memory.MaxPatchSize) - // Pass allowed extensions as JSON array - allowedExtsJSON, _ := json.Marshal(memory.AllowedExtensions) //nolint:jsonmarshalignoredeerror // marshaling a string slice cannot fail - fmt.Fprintf(&step, " ALLOWED_EXTENSIONS: '%s'\n", allowedExtsJSON) - if fileGlobFilter != "" { - // Quote the value to prevent YAML alias interpretation of patterns like *.md - fmt.Fprintf(&step, " FILE_GLOB_FILTER: \"%s\"\n", fileGlobFilter) - } - if memory.FormatJSON { - step.WriteString(" FORMAT_JSON: 'true'\n") - } - step.WriteString(" with:\n") - step.WriteString(" script: |\n") - - if useRequire { - // Use require() to load script from copied files using setup_globals helper - step.WriteString(" const { setupGlobals } = require('" + SetupActionDestination + "/setup_globals.cjs');\n") - step.WriteString(" setupGlobals(core, github, context, exec, io, getOctokit);\n") - step.WriteString(" const { main } = require('" + SetupActionDestination + "/push_repo_memory.cjs');\n") - step.WriteString(" await main();\n") - } else { - // Inline JavaScript: Attach GitHub Actions builtin objects to global scope before script execution - step.WriteString(" const { setupGlobals } = require('" + SetupActionDestination + "/setup_globals.cjs');\n") - step.WriteString(" setupGlobals(core, github, context, exec, io, getOctokit);\n") - // Add the JavaScript script with proper indentation - formattedScript := FormatJavaScriptForYAML("const { main } = require('${{ runner.temp }}/gh-aw/actions/push_repo_memory.cjs'); await main();") - for _, line := range formattedScript { - step.WriteString(line) - } - } - - steps = append(steps, step.String()) +// buildSinglePushRepoMemoryStep builds a single push-repo-memory step for one memory entry. +func (c *Compiler) buildSinglePushRepoMemoryStep(data *WorkflowData, memory RepoMemoryEntry, useRequire bool) string { + targetRepo := memory.TargetRepo + if targetRepo == "" { + targetRepo = "${{ github.repository }}" } - - // In dev mode the setup action is referenced via a local path (./actions/setup), so its files - // live in the workspace. The push_repo_memory.cjs script internally checks out the memory - // branch, which replaces the workspace content and removes the actions/setup directory. - // Without restoring it, the runner's post-step for Setup Scripts would fail with - // "Can't find 'action.yml', 'action.yaml' or 'Dockerfile' under .../actions/setup". - // We add a restore checkout step (if: always()) after all push steps so the post-step - // can always find action.yml and complete its /tmp/gh-aw cleanup. - // Note: no ref is specified in dev mode — use the repository default branch (same pattern - // as generateCheckoutActionsFolder in dev mode). - if c.actionMode.IsDev() { - steps = append(steps, c.generateRestoreActionsSetupStep()) + if memory.Wiki { + targetRepo = targetRepo + ".wiki" + } + artifactDir := constants.TmpRepoMemoryDir + memory.ID + fileGlobFilter := "" + if len(memory.FileGlob) > 0 { + fileGlobFilter = strings.Join(memory.FileGlob, " ") + } + var step strings.Builder + if memory.Wiki { + fmt.Fprintf(&step, " - name: Push wiki-memory changes (%s)\n", memory.ID) + } else { + fmt.Fprintf(&step, " - name: Push repo-memory changes (%s)\n", memory.ID) + } + fmt.Fprintf(&step, " id: push_repo_memory_%s\n", memory.ID) + step.WriteString(" if: always()\n") + fmt.Fprintf(&step, " uses: %s\n", getCachedActionPin("actions/github-script", data)) + step.WriteString(" env:\n") + step.WriteString(" GH_TOKEN: ${{ github.token }}\n") + step.WriteString(" GITHUB_RUN_ID: ${{ github.run_id }}\n") + step.WriteString(" GITHUB_SERVER_URL: ${{ github.server_url }}\n") + fmt.Fprintf(&step, " ARTIFACT_DIR: %s\n", artifactDir) + fmt.Fprintf(&step, " MEMORY_ID: %s\n", memory.ID) + fmt.Fprintf(&step, " TARGET_REPO: %s\n", targetRepo) + fmt.Fprintf(&step, " BRANCH_NAME: %s\n", memory.BranchName) + if memory.Wiki { + fmt.Fprintf(&step, " REPO_MEMORY_ALLOWED_REPOS: %s\n", targetRepo) + } + fmt.Fprintf(&step, " MAX_FILE_SIZE: %d\n", memory.MaxFileSize) + fmt.Fprintf(&step, " MAX_FILE_COUNT: %d\n", memory.MaxFileCount) + fmt.Fprintf(&step, " MAX_PATCH_SIZE: %d\n", memory.MaxPatchSize) + allowedExtsJSON, _ := json.Marshal(memory.AllowedExtensions) //nolint:jsonmarshalignoredeerror // marshaling a string slice cannot fail + fmt.Fprintf(&step, " ALLOWED_EXTENSIONS: '%s'\n", allowedExtsJSON) + if fileGlobFilter != "" { + fmt.Fprintf(&step, " FILE_GLOB_FILTER: \"%s\"\n", fileGlobFilter) + } + if memory.FormatJSON { + step.WriteString(" FORMAT_JSON: 'true'\n") + } + step.WriteString(" with:\n") + step.WriteString(" script: |\n") + step.WriteString(" const { setupGlobals } = require('" + SetupActionDestination + "/setup_globals.cjs');\n") + step.WriteString(" setupGlobals(core, github, context, exec, io, getOctokit);\n") + if useRequire { + step.WriteString(" const { main } = require('" + SetupActionDestination + "/push_repo_memory.cjs');\n") + step.WriteString(" await main();\n") + } else { + for _, line := range FormatJavaScriptForYAML("const { main } = require('${{ runner.temp }}/gh-aw/actions/push_repo_memory.cjs'); await main();") { + step.WriteString(line) + } } + return step.String() +} - // Job condition: only run when the agent succeeded and the workflow was not - // cancelled. Using always() so the job still runs even when upstream jobs are - // skipped (e.g. detection is skipped when agent produces no outputs). - // We check == 'success' so that repo-memory is only pushed on a successful - // agent run — pushing memory from a timed-out or failed agent would pollute - // the stored memory with incomplete state. Adding !cancelled() prevents the - // job from running after workflow cancellation. +// buildPushRepoMemoryJobCondition computes the job condition and needs list. +func (c *Compiler) buildPushRepoMemoryJobCondition(threatDetectionEnabled bool) (string, []string) { agentSucceeded := BuildEquals( BuildPropertyAccess(fmt.Sprintf("needs.%s.result", constants.AgentJobName)), BuildStringLiteral("success"), @@ -795,41 +611,22 @@ func (c *Compiler) buildPushRepoMemoryJob(data *WorkflowData, threatDetectionEna jobNeeds := []string{string(constants.AgentJobName), string(constants.ActivationJobName)} var jobCondition string if threatDetectionEnabled { - // When threat detection is enabled, also require detection passed (succeeded or skipped). jobCondition = RenderCondition(BuildAnd(BuildAnd(BuildAnd(BuildFunctionCall("always"), notCancelled), buildDetectionPassedCondition()), agentSucceeded)) jobNeeds = append(jobNeeds, string(constants.DetectionJobName)) } else { jobCondition = RenderCondition(BuildAnd(BuildAnd(BuildFunctionCall("always"), notCancelled), agentSucceeded)) } + return jobCondition, jobNeeds +} - // Build outputs map for validation failures from all memory steps +// buildPushRepoMemoryOutputs builds the outputs map for validation failures from all memory steps. +func buildPushRepoMemoryOutputs(memories []RepoMemoryEntry) map[string]string { outputs := make(map[string]string) - for _, memory := range data.RepoMemoryConfig.Memories { + for _, memory := range memories { stepID := "push_repo_memory_" + memory.ID - // Add outputs for each memory's validation status outputs["validation_failed_"+memory.ID] = fmt.Sprintf("${{ steps.%s.outputs.validation_failed }}", stepID) outputs["validation_error_"+memory.ID] = fmt.Sprintf("${{ steps.%s.outputs.validation_error }}", stepID) outputs["patch_size_exceeded_"+memory.ID] = fmt.Sprintf("${{ steps.%s.outputs.patch_size_exceeded }}", stepID) } - - // Build a concurrency key scoped to the actual branches being written. - // This prevents false serialisation between workflows that push to different memory - // branches while still serialising concurrent pushes to the *same* branch. - // cancel-in-progress is false so queued pushes are not dropped. - concurrencyGroup := buildPushRepoMemoryConcurrencyGroup(data.RepoMemoryConfig.Memories) - concurrency := c.indentYAMLLines(fmt.Sprintf("concurrency:\n group: %q\n cancel-in-progress: false", concurrencyGroup), " ") - - job := &Job{ - Name: pushRepoMemoryJobName, - DisplayName: "", // No display name - job ID is sufficient - RunsOn: c.formatFrameworkJobRunsOn(data), - If: jobCondition, - Permissions: "permissions:\n contents: write", - Concurrency: concurrency, - Needs: jobNeeds, - Steps: steps, - Outputs: outputs, - } - - return job, nil + return outputs } diff --git a/pkg/workflow/side_repo_maintenance.go b/pkg/workflow/side_repo_maintenance.go index b0b72856e93..265e6cd82e2 100644 --- a/pkg/workflow/side_repo_maintenance.go +++ b/pkg/workflow/side_repo_maintenance.go @@ -172,74 +172,65 @@ func generateAllSideRepoMaintenanceWorkflows( ctx context.Context, opts generateAllSideRepoMaintenanceWorkflowsOptions, ) error { - workflowDataList := opts.workflowDataList - workflowDir := opts.workflowDir - version := opts.version - actionMode := opts.actionMode - actionTag := opts.actionTag - runsOnValue := opts.runsOnValue - resolver := opts.resolver - hasExpires := opts.hasExpires - minExpiresDays := opts.minExpiresDays - targets := collectSideRepoTargets(workflowDataList) - maintenanceLog.Printf("Generating maintenance workflows for %d side-repo target(s): hasExpires=%t, minExpiresDays=%d", len(targets), hasExpires, minExpiresDays) - - // Track which side-repo maintenance files we (re-)generate so we can identify - // and remove stale files from previous runs when target repos are renamed or removed. - generatedFiles := make(map[string]struct { - }) + targets := collectSideRepoTargets(opts.workflowDataList) + maintenanceLog.Printf("Generating maintenance workflows for %d side-repo target(s): hasExpires=%t, minExpiresDays=%d", len(targets), opts.hasExpires, opts.minExpiresDays) + generatedFiles, err := generateSideRepoMaintenanceFiles(ctx, targets, opts) + if err != nil { + return err + } + return removeStaleSideRepoMaintenanceFiles(opts.workflowDir, generatedFiles) +} +func generateSideRepoMaintenanceFiles(ctx context.Context, targets []SideRepoTarget, opts generateAllSideRepoMaintenanceWorkflowsOptions) (map[string]struct{}, error) { + generatedFiles := make(map[string]struct{}) for _, target := range targets { slug := stringutil.SanitizeForFilename(target.Repository) filename := "agentics-maintenance-" + slug + ".yml" - generatedFiles[filename] = struct { - }{} - outPath := filepath.Join(workflowDir, filename) - + generatedFiles[filename] = struct{}{} + outPath := filepath.Join(opts.workflowDir, filename) maintenanceLog.Printf("Generating side-repo maintenance workflow: %s → %s", target.Repository, filename) - if err := generateSideRepoMaintenanceWorkflow(ctx, generateSideRepoMaintenanceWorkflowOptions{ + err := generateSideRepoMaintenanceWorkflow(ctx, generateSideRepoMaintenanceWorkflowOptions{ target: target, outPath: outPath, - version: version, - actionMode: actionMode, - actionTag: actionTag, - runsOnValue: runsOnValue, - resolver: resolver, - hasExpires: hasExpires, - minExpiresDays: minExpiresDays, - }); err != nil { - return fmt.Errorf("failed to generate side-repo maintenance workflow for %s: %w", target.Repository, err) + version: opts.version, + actionMode: opts.actionMode, + actionTag: opts.actionTag, + runsOnValue: opts.runsOnValue, + resolver: opts.resolver, + hasExpires: opts.hasExpires, + minExpiresDays: opts.minExpiresDays, + }) + if err != nil { + return nil, fmt.Errorf("failed to generate side-repo maintenance workflow for %s: %w", target.Repository, err) } fmt.Fprintf(os.Stderr, " Generated side-repo maintenance workflow: %s\n", filename) } + return generatedFiles, nil +} - // Remove stale side-repo maintenance workflows that are no longer referenced. +func removeStaleSideRepoMaintenanceFiles(workflowDir string, generatedFiles map[string]struct{}) error { entries, err := os.ReadDir(workflowDir) if err != nil { return fmt.Errorf("failed to read workflow directory %s for stale side-repo maintenance workflow cleanup: %w", workflowDir, err) } for _, entry := range entries { - if entry.IsDir() { - continue - } - name := entry.Name() - if !strings.HasPrefix(name, "agentics-maintenance-") || !strings.HasSuffix(name, ".yml") { - continue - } - if setutil.Contains(generatedFiles, name) { + if entry.IsDir() || !isSideRepoMaintenanceWorkflowFile(entry.Name()) || setutil.Contains(generatedFiles, entry.Name()) { continue } - stalePath := filepath.Join(workflowDir, name) - maintenanceLog.Printf("Removing stale side-repo maintenance workflow: %s", name) + stalePath := filepath.Join(workflowDir, entry.Name()) + maintenanceLog.Printf("Removing stale side-repo maintenance workflow: %s", entry.Name()) if err := os.Remove(stalePath); err != nil { return fmt.Errorf("failed to remove stale side-repo maintenance workflow %s: %w", stalePath, err) } - fmt.Fprintf(os.Stderr, " Removed stale side-repo maintenance workflow: %s\n", name) + fmt.Fprintf(os.Stderr, " Removed stale side-repo maintenance workflow: %s\n", entry.Name()) } - return nil } +func isSideRepoMaintenanceWorkflowFile(name string) bool { + return strings.HasPrefix(name, "agentics-maintenance-") && strings.HasSuffix(name, ".yml") +} + // generateSideRepoMaintenanceWorkflowOptions configures generation of a single side-repo // maintenance workflow. type generateSideRepoMaintenanceWorkflowOptions struct { @@ -263,55 +254,89 @@ func generateSideRepoMaintenanceWorkflow( ctx context.Context, opts generateSideRepoMaintenanceWorkflowOptions, ) error { - target := opts.target - outPath := opts.outPath - version := opts.version - actionMode := opts.actionMode - actionTag := opts.actionTag - runsOnValue := opts.runsOnValue - resolver := opts.resolver - hasExpires := opts.hasExpires - minExpiresDays := opts.minExpiresDays - token := effectiveSideRepoToken(target) - repoSlug := target.Repository - maintenanceLog.Printf("Building side-repo workflow content: repo=%s, actionMode=%s, hasExpires=%t", repoSlug, actionMode, hasExpires) - - // Compute the GitHub App token mint step YAML once; it is inserted as the - // first step of every cross-repo job when app-based auth is configured. - var mintStepYAML string - if target.GitHubApp != nil && target.GitHubToken == "" { - mintStepYAML = sideRepoAppTokenMintStepYAML(target.GitHubApp, target.Repository) - maintenanceLog.Printf("GitHub App auth configured for %s; will emit mint step in cross-repo jobs", repoSlug) - } else if target.GitHubApp != nil { - maintenanceLog.Printf("SideRepoTarget %s has both GitHubToken and GitHubApp configured; skipping app token mint step", repoSlug) + renderCtx := newSideRepoMaintenanceRenderContext(ctx, opts) + content := buildSideRepoMaintenanceWorkflowYAML(renderCtx) + maintenanceLog.Printf("Writing side-repo maintenance workflow to %s", renderCtx.outPath) + if err := os.WriteFile(renderCtx.outPath, []byte(content), constants.FilePermPublic); err != nil { + return fmt.Errorf("failed to write side-repo maintenance workflow: %w", err) } + return nil +} - var yaml strings.Builder - - customInstructions := strings.ReplaceAll(sideRepoMaintenanceHeaderTemplate, "{REPO_SLUG}", repoSlug) +type sideRepoMaintenanceRenderContext struct { + ctx context.Context + repoSlug string + token string + outPath string + version string + actionMode ActionMode + actionTag string + runsOnValue string + resolver SHAResolver + hasExpires bool + minExpiresDays int + mintStepYAML string + setupActionRef string + cronSchedule string + scheduleDesc string + formattedRunsOn string +} - header := GenerateWorkflowHeader("", "pkg/workflow/side_repo_maintenance.go", customInstructions) - yaml.WriteString(header) - - // Pre-compute cron schedule values (needed in both the on: section and the - // close-expired-entities job comment when hasExpires is true). - // Uses fuzzy scheduling: minute and hour offsets are derived from the repo - // slug hash so that multiple side-repo workflows are scattered across the - // clock face instead of all firing at the same time. - var cronSchedule, scheduleDesc string - if hasExpires { - effectiveDays := minExpiresDays +func newSideRepoMaintenanceRenderContext(ctx context.Context, opts generateSideRepoMaintenanceWorkflowOptions) sideRepoMaintenanceRenderContext { + renderCtx := sideRepoMaintenanceRenderContext{ + ctx: ctx, + repoSlug: opts.target.Repository, + token: effectiveSideRepoToken(opts.target), + outPath: opts.outPath, + version: opts.version, + actionMode: opts.actionMode, + actionTag: opts.actionTag, + runsOnValue: opts.runsOnValue, + resolver: opts.resolver, + hasExpires: opts.hasExpires, + minExpiresDays: opts.minExpiresDays, + setupActionRef: ResolveSetupActionReference(ctx, opts.actionMode, opts.version, opts.actionTag, opts.resolver), + formattedRunsOn: FormatRunsOn(nil, "ubuntu-latest"), + } + maintenanceLog.Printf("Building side-repo workflow content: repo=%s, actionMode=%s, hasExpires=%t", renderCtx.repoSlug, renderCtx.actionMode, renderCtx.hasExpires) + if opts.target.GitHubApp != nil && opts.target.GitHubToken == "" { + renderCtx.mintStepYAML = sideRepoAppTokenMintStepYAML(opts.target.GitHubApp, opts.target.Repository) + maintenanceLog.Printf("GitHub App auth configured for %s; will emit mint step in cross-repo jobs", renderCtx.repoSlug) + } else if opts.target.GitHubApp != nil { + maintenanceLog.Printf("SideRepoTarget %s has both GitHubToken and GitHubApp configured; skipping app token mint step", renderCtx.repoSlug) + } + if renderCtx.hasExpires { + effectiveDays := renderCtx.minExpiresDays if effectiveDays == 0 { - // minExpiresDays == 0 means expiry < 1 day; use a conservative daily default. effectiveDays = 5 } - cronSchedule, scheduleDesc = generateSideRepoMaintenanceCron(repoSlug, effectiveDays) + renderCtx.cronSchedule, renderCtx.scheduleDesc = generateSideRepoMaintenanceCron(renderCtx.repoSlug, effectiveDays) + } + return renderCtx +} + +func buildSideRepoMaintenanceWorkflowYAML(renderCtx sideRepoMaintenanceRenderContext) string { + var yaml strings.Builder + yaml.WriteString(buildSideRepoMaintenanceHeader(renderCtx.repoSlug)) + yaml.WriteString(buildSideRepoMaintenanceOnSection(renderCtx)) + if renderCtx.hasExpires { + maintenanceLog.Printf("Including close-expired-entities job for %s (cron=%s)", renderCtx.repoSlug, renderCtx.cronSchedule) + yaml.WriteString(buildCloseExpiredEntitiesJob(renderCtx)) } + yaml.WriteString(buildApplySafeOutputsJob(renderCtx)) + yaml.WriteString(buildCreateLabelsJob(renderCtx)) + yaml.WriteString(buildActivityReportJob(renderCtx)) + yaml.WriteString(buildValidateWorkflowsJob(renderCtx)) + return yaml.String() +} - // Build the `on:` triggers. A schedule trigger is added when at least one - // workflow uses `expires`, because the close-expired-entities job's condition - // (`buildNotForkAndScheduled`) also matches scheduled runs. - onSection := `name: Agentic Maintenance (` + repoSlug + `) +func buildSideRepoMaintenanceHeader(repoSlug string) string { + customInstructions := strings.ReplaceAll(sideRepoMaintenanceHeaderTemplate, "{REPO_SLUG}", repoSlug) + return GenerateWorkflowHeader("", "pkg/workflow/side_repo_maintenance.go", customInstructions) +} + +func buildSideRepoMaintenanceOnSection(renderCtx sideRepoMaintenanceRenderContext) string { + onSection := `name: Agentic Maintenance (` + renderCtx.repoSlug + `) on: workflow_dispatch: @@ -349,9 +374,9 @@ on: description: 'The run URL that safe outputs were applied from' value: ${{ jobs.apply_safe_outputs.outputs.run_url }} ` - if hasExpires { + if renderCtx.hasExpires { onSection += ` schedule: - - cron: "` + cronSchedule + `" # ` + scheduleDesc + ` (based on minimum expires: ` + strconv.Itoa(minExpiresDays) + ` days) + - cron: "` + renderCtx.cronSchedule + `" # ` + renderCtx.scheduleDesc + ` (based on minimum expires: ` + strconv.Itoa(renderCtx.minExpiresDays) + ` days) ` } onSection += ` @@ -359,84 +384,37 @@ permissions: {} jobs: ` - yaml.WriteString(onSection) - - setupActionRef := ResolveSetupActionReference(ctx, actionMode, version, actionTag, resolver) + return onSection +} - // Add close-expired-entities job only when any workflow uses expires. - if hasExpires { - maintenanceLog.Printf("Including close-expired-entities job for %s (cron=%s)", repoSlug, cronSchedule) - closeExpiredCondition := buildNotForkAndScheduled() - yaml.WriteString(` close-expired-entities: +func buildCloseExpiredEntitiesJob(renderCtx sideRepoMaintenanceRenderContext) string { + closeExpiredCondition := buildNotForkAndScheduled() + var b strings.Builder + b.WriteString(` close-expired-entities: if: ${{ ` + RenderCondition(closeExpiredCondition) + ` }} - runs-on: ` + runsOnValue + ` + runs-on: ` + renderCtx.runsOnValue + ` permissions: discussions: write issues: write pull-requests: write - # Runs on schedule: ` + cronSchedule + ` (` + scheduleDesc + `) + # Runs on schedule: ` + renderCtx.cronSchedule + ` (` + renderCtx.scheduleDesc + `) steps: `) - yaml.WriteString(mintStepYAML) - - if actionMode == ActionModeDev || actionMode == ActionModeScript { - yaml.WriteString(" - name: Checkout actions folder\n") - yaml.WriteString(" uses: " + getActionPin("actions/checkout") + "\n") - yaml.WriteString(" with:\n") - yaml.WriteString(" sparse-checkout: |\n") - yaml.WriteString(" actions\n") - yaml.WriteString(" clean: false\n") - yaml.WriteString(" persist-credentials: false\n\n") - } - - yaml.WriteString(` - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Close expired discussions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_AW_TARGET_REPO_SLUG: "` + repoSlug + `" - with: - github-token: ` + token + ` - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/close_expired_discussions.cjs'); - await main(); - - - name: Close expired issues - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_AW_TARGET_REPO_SLUG: "` + repoSlug + `" - with: - github-token: ` + token + ` - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/close_expired_issues.cjs'); - await main(); - - - name: Close expired pull requests - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_AW_TARGET_REPO_SLUG: "` + repoSlug + `" - with: - github-token: ` + token + ` - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/close_expired_pull_requests.cjs'); - await main(); -`) - } + b.WriteString(renderCtx.mintStepYAML) + b.WriteString(buildSideRepoCheckoutActionsStep(renderCtx.actionMode)) + b.WriteString(buildSideRepoSetupScriptsStep(renderCtx.setupActionRef)) + b.WriteString(buildSideRepoTargetScriptStep("Close expired discussions", "close_expired_discussions.cjs", renderCtx)) + b.WriteString(buildSideRepoTargetScriptStep("Close expired issues", "close_expired_issues.cjs", renderCtx)) + b.WriteString(buildSideRepoTargetScriptStep("Close expired pull requests", "close_expired_pull_requests.cjs", renderCtx)) + return b.String() +} - // Add apply_safe_outputs job for workflow_dispatch/workflow_call with operation == 'safe_outputs' - yaml.WriteString(` +func buildApplySafeOutputsJob(renderCtx sideRepoMaintenanceRenderContext) string { + var b strings.Builder + b.WriteString(` apply_safe_outputs: if: ${{ ` + RenderCondition(buildDispatchOperationCondition("safe_outputs")) + ` }} - runs-on: ` + runsOnValue + ` + runs-on: ` + renderCtx.runsOnValue + ` permissions: actions: read contents: write @@ -447,76 +425,112 @@ jobs: run_url: ${{ steps.record.outputs.run_url }} steps: `) - yaml.WriteString(mintStepYAML) - - if actionMode == ActionModeDev || actionMode == ActionModeScript { - yaml.WriteString(" - name: Checkout actions folder\n") - yaml.WriteString(" uses: " + getActionPin("actions/checkout") + "\n") - yaml.WriteString(" with:\n") - yaml.WriteString(" sparse-checkout: |\n") - yaml.WriteString(" actions\n") - yaml.WriteString(" clean: false\n") - yaml.WriteString(" persist-credentials: false\n\n") - } - - yaml.WriteString(` - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Check admin/maintainer permissions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); - await main(); + b.WriteString(renderCtx.mintStepYAML) + b.WriteString(buildSideRepoCheckoutActionsStep(renderCtx.actionMode)) + b.WriteString(buildSideRepoSetupScriptsStep(renderCtx.setupActionRef)) + b.WriteString(buildSideRepoCheckAdminPermissionsStep(renderCtx.resolver)) + b.WriteString(buildApplySafeOutputsStep(renderCtx)) + b.WriteString(buildApplySafeOutputsRecordStep()) + return b.String() +} - - name: Apply Safe Outputs - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - env: - GH_TOKEN: ` + token + ` - GH_AW_RUN_URL: ${{ inputs.run_url }} - GH_AW_TARGET_REPO_SLUG: "` + repoSlug + `" - with: - github-token: ` + token + ` - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/apply_safe_outputs_replay.cjs'); - await main(); +func buildCreateLabelsJob(renderCtx sideRepoMaintenanceRenderContext) string { + var b strings.Builder + b.WriteString(` + create_labels: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("create_labels")) + ` }} + runs-on: ` + renderCtx.runsOnValue + ` + permissions: + contents: read + issues: write + steps: +`) + b.WriteString(renderCtx.mintStepYAML) + b.WriteString(buildSideRepoRepositoryCheckoutStep()) + b.WriteString(buildSideRepoSetupScriptsStep(renderCtx.setupActionRef)) + b.WriteString(buildSideRepoCheckAdminPermissionsStep(renderCtx.resolver)) + b.WriteString(generateInstallCLISteps(renderCtx.ctx, renderCtx.actionMode, renderCtx.version, renderCtx.actionTag, renderCtx.resolver)) + b.WriteString(buildCreateLabelsStep(renderCtx)) + return b.String() +} - - name: Record outputs - id: record - env: - GH_AW_RUN_URL: ${{ inputs.run_url }} - run: echo "run_url=$GH_AW_RUN_URL" >> "$GITHUB_OUTPUT" +func buildActivityReportJob(renderCtx sideRepoMaintenanceRenderContext) string { + var b strings.Builder + b.WriteString(` + activity_report: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("activity_report")) + ` }} + runs-on: ` + renderCtx.runsOnValue + ` + timeout-minutes: 120 + permissions: + actions: read + contents: read + issues: write + steps: `) + b.WriteString(renderCtx.mintStepYAML) + b.WriteString(buildSideRepoRepositoryCheckoutStep()) + b.WriteString(buildSideRepoSetupScriptsStep(renderCtx.setupActionRef)) + b.WriteString(buildSideRepoCheckAdminPermissionsStep(renderCtx.resolver)) + b.WriteString(generateInstallCLISteps(renderCtx.ctx, renderCtx.actionMode, renderCtx.version, renderCtx.actionTag, renderCtx.resolver)) + b.WriteString(buildActivityReportCacheSteps(renderCtx)) + b.WriteString(buildActivityReportIssueStep(renderCtx)) + return b.String() +} - // Add create_labels job for workflow_dispatch/workflow_call with operation == 'create_labels' - yaml.WriteString(` - create_labels: - if: ${{ ` + RenderCondition(buildDispatchOperationCondition("create_labels")) + ` }} - runs-on: ` + runsOnValue + ` +func buildValidateWorkflowsJob(renderCtx sideRepoMaintenanceRenderContext) string { + var b strings.Builder + b.WriteString(` + validate_workflows: + if: ${{ ` + RenderCondition(buildDispatchOperationCondition("validate")) + ` }} + runs-on: ` + renderCtx.formattedRunsOn + ` permissions: contents: read issues: write steps: `) - yaml.WriteString(mintStepYAML) - yaml.WriteString(` - name: Checkout repository + b.WriteString(buildSideRepoRepositoryCheckoutStep()) + b.WriteString(buildSideRepoSetupScriptsStep(renderCtx.setupActionRef)) + b.WriteString(buildSideRepoCheckAdminPermissionsStep(renderCtx.resolver)) + b.WriteString(generateInstallCLISteps(renderCtx.ctx, renderCtx.actionMode, renderCtx.version, renderCtx.actionTag, renderCtx.resolver)) + b.WriteString(buildValidateWorkflowsStep(renderCtx)) + return b.String() +} + +func buildSideRepoCheckoutActionsStep(actionMode ActionMode) string { + if actionMode != ActionModeDev && actionMode != ActionModeScript { + return "" + } + return ` - name: Checkout actions folder uses: ` + getActionPin("actions/checkout") + ` with: + sparse-checkout: | + actions + clean: false persist-credentials: false - - name: Setup Scripts +` +} + +func buildSideRepoRepositoryCheckoutStep() string { + return ` - name: Checkout repository + uses: ` + getActionPin("actions/checkout") + ` + with: + persist-credentials: false + +` +} + +func buildSideRepoSetupScriptsStep(setupActionRef string) string { + return ` - name: Setup Scripts uses: ` + setupActionRef + ` with: destination: ${{ runner.temp }}/gh-aw/actions - - name: Check admin/maintainer permissions +` +} + +func buildSideRepoCheckAdminPermissionsStep(resolver SHAResolver) string { + return ` - name: Check admin/maintainer permissions uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` with: github-token: ${{ secrets.GITHUB_TOKEN }} @@ -526,84 +540,87 @@ jobs: const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); await main(); -`) +` +} - yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) - yaml.WriteString(` - name: Create missing labels in target repository - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` +func buildSideRepoTargetScriptStep(name, scriptFile string, renderCtx sideRepoMaintenanceRenderContext) string { + return ` - name: ` + name + ` + uses: ` + getCachedActionPinFromResolver("actions/github-script", renderCtx.resolver) + ` env: - GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` - GH_AW_TARGET_REPO_SLUG: "` + repoSlug + `" + GH_AW_TARGET_REPO_SLUG: "` + renderCtx.repoSlug + `" with: - github-token: ` + token + ` + github-token: ` + renderCtx.token + ` script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/create_labels.cjs'); + const { main } = require('${{ runner.temp }}/gh-aw/actions/` + scriptFile + `'); await main(); -`) - // Add activity_report job for workflow_dispatch/workflow_call with operation == 'activity_report' - yaml.WriteString(` - activity_report: - if: ${{ ` + RenderCondition(buildDispatchOperationCondition("activity_report")) + ` }} - runs-on: ` + runsOnValue + ` - timeout-minutes: 120 - permissions: - actions: read - contents: read - issues: write - steps: -`) - yaml.WriteString(mintStepYAML) - yaml.WriteString(` - name: Checkout repository - uses: ` + getActionPin("actions/checkout") + ` - with: - persist-credentials: false +` +} - - name: Setup Scripts - uses: ` + setupActionRef + ` +func buildApplySafeOutputsStep(renderCtx sideRepoMaintenanceRenderContext) string { + return ` - name: Apply Safe Outputs + uses: ` + getCachedActionPinFromResolver("actions/github-script", renderCtx.resolver) + ` + env: + GH_TOKEN: ` + renderCtx.token + ` + GH_AW_RUN_URL: ${{ inputs.run_url }} + GH_AW_TARGET_REPO_SLUG: "` + renderCtx.repoSlug + `" with: - destination: ${{ runner.temp }}/gh-aw/actions + github-token: ` + renderCtx.token + ` + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/apply_safe_outputs_replay.cjs'); + await main(); - - name: Check admin/maintainer permissions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` +` +} + +func buildApplySafeOutputsRecordStep() string { + return ` - name: Record outputs + id: record + env: + GH_AW_RUN_URL: ${{ inputs.run_url }} + run: echo "run_url=$GH_AW_RUN_URL" >> "$GITHUB_OUTPUT" +` +} + +func buildCreateLabelsStep(renderCtx sideRepoMaintenanceRenderContext) string { + return ` - name: Create missing labels in target repository + uses: ` + getCachedActionPinFromResolver("actions/github-script", renderCtx.resolver) + ` + env: + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(renderCtx.actionMode) + ` + GH_AW_TARGET_REPO_SLUG: "` + renderCtx.repoSlug + `" with: - github-token: ${{ secrets.GITHUB_TOKEN }} + github-token: ` + renderCtx.token + ` script: | const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); + const { main } = require('${{ runner.temp }}/gh-aw/actions/create_labels.cjs'); await main(); +` +} -`) - - yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) - yaml.WriteString(` - name: Restore activity report logs cache +func buildActivityReportCacheSteps(renderCtx sideRepoMaintenanceRenderContext) string { + return ` - name: Restore activity report logs cache id: activity_report_logs_cache uses: ` + getActionPin("actions/cache/restore") + ` with: path: ./.cache/gh-aw/activity-report-logs - key: ${{ runner.os }}-activity-report-logs-` + repoSlug + `-${{ github.ref_name }}-${{ github.run_id }} + key: ${{ runner.os }}-activity-report-logs-` + renderCtx.repoSlug + `-${{ github.ref_name }}-${{ github.run_id }} restore-keys: | - ${{ runner.os }}-activity-report-logs-` + repoSlug + `- + ${{ runner.os }}-activity-report-logs-` + renderCtx.repoSlug + `- ${{ runner.os }}-activity-report-logs- -`) - yaml.WriteString(` - name: Download activity report logs in target repository + - name: Download activity report logs in target repository timeout-minutes: 20 shell: bash env: - GH_TOKEN: ` + token + ` - GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` - GH_AW_TARGET_REPO_SLUG: "` + repoSlug + `" + GH_TOKEN: ` + renderCtx.token + ` + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(renderCtx.actionMode) + ` + GH_AW_TARGET_REPO_SLUG: "` + renderCtx.repoSlug + `" run: | - ${GH_AW_CMD_PREFIX} logs \ - --repo "${GH_AW_TARGET_REPO_SLUG}" \ - --start-date -1w \ - --count 500 \ - --output ./.cache/gh-aw/activity-report-logs \ - --format markdown \ - --report-file ./.cache/gh-aw/activity-report-logs/report.md + ${GH_AW_CMD_PREFIX} logs --repo "${GH_AW_TARGET_REPO_SLUG}" --start-date -1w --count 500 --output ./.cache/gh-aw/activity-report-logs --format markdown --report-file ./.cache/gh-aw/activity-report-logs/report.md - name: Save activity report logs cache if: ${{ always() }} @@ -611,11 +628,14 @@ jobs: with: path: ./.cache/gh-aw/activity-report-logs key: ${{ steps.activity_report_logs_cache.outputs.cache-primary-key }} +` +} - - name: Generate activity report issue in target repository - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` +func buildActivityReportIssueStep(renderCtx sideRepoMaintenanceRenderContext) string { + return ` - name: Generate activity report issue in target repository + uses: ` + getCachedActionPinFromResolver("actions/github-script", renderCtx.resolver) + ` with: - github-token: ` + token + ` + github-token: ` + renderCtx.token + ` script: | const fs = require('node:fs'); const reportPath = './.cache/gh-aw/activity-report-logs/report.md'; @@ -656,45 +676,14 @@ jobs: labels: ['agentic-workflows'], }); core.info('Created issue #' + createdIssue.data.number + ': ' + createdIssue.data.html_url); -`) - - // Add validate_workflows job for workflow_dispatch/workflow_call with operation == 'validate' - formattedRunsOn := FormatRunsOn(nil, "ubuntu-latest") - yaml.WriteString(` - validate_workflows: - if: ${{ ` + RenderCondition(buildDispatchOperationCondition("validate")) + ` }} - runs-on: ` + formattedRunsOn + ` - permissions: - contents: read - issues: write - steps: - - name: Checkout repository - uses: ` + getActionPin("actions/checkout") + ` - with: - persist-credentials: false - - - name: Setup Scripts - uses: ` + setupActionRef + ` - with: - destination: ${{ runner.temp }}/gh-aw/actions - - - name: Check admin/maintainer permissions - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); - setupGlobals(core, github, context, exec, io, getOctokit); - const { main } = require('${{ runner.temp }}/gh-aw/actions/check_team_member.cjs'); - await main(); - -`) +` +} - yaml.WriteString(generateInstallCLISteps(ctx, actionMode, version, actionTag, resolver)) - yaml.WriteString(` - name: Validate workflows and file issue on findings - uses: ` + getCachedActionPinFromResolver("actions/github-script", resolver) + ` +func buildValidateWorkflowsStep(renderCtx sideRepoMaintenanceRenderContext) string { + return ` - name: Validate workflows and file issue on findings + uses: ` + getCachedActionPinFromResolver("actions/github-script", renderCtx.resolver) + ` env: - GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(actionMode) + ` + GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(renderCtx.actionMode) + ` with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -702,12 +691,5 @@ jobs: setupGlobals(core, github, context, exec, io, getOctokit); const { main } = require('${{ runner.temp }}/gh-aw/actions/run_validate_workflows.cjs'); await main(); -`) - - content := yaml.String() - maintenanceLog.Printf("Writing side-repo maintenance workflow to %s", outPath) - if err := os.WriteFile(outPath, []byte(content), constants.FilePermPublic); err != nil { - return fmt.Errorf("failed to write side-repo maintenance workflow: %w", err) - } - return nil +` } From 4d82901ec6993f603aefa18adf456bb2114f34a2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:02:46 +0000 Subject: [PATCH 3/4] docs(adr): add draft ADR-47061 for function-length lint compliance via helper extraction --- ...h-lint-compliance-via-helper-extraction.md | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 docs/adr/47061-function-length-lint-compliance-via-helper-extraction.md diff --git a/docs/adr/47061-function-length-lint-compliance-via-helper-extraction.md b/docs/adr/47061-function-length-lint-compliance-via-helper-extraction.md new file mode 100644 index 00000000000..ce9cc140b42 --- /dev/null +++ b/docs/adr/47061-function-length-lint-compliance-via-helper-extraction.md @@ -0,0 +1,51 @@ +# ADR-47061: Function-Length Lint Compliance via Private Helper Extraction + +**Date**: 2026-07-21 +**Status**: Draft +**Deciders**: Unknown (automated refactoring by copilot-swe-agent) + +--- + +### Context + +`make golint-custom` reported 680 long-function findings (functions exceeding 60 lines) across `pkg/workflow` and `pkg/cli`. The worst offenders ranged from 200 to 961 lines (e.g., `buildMaintenanceWorkflowYAML` at 961 lines, `commentOutProcessedFieldsInOnSection` at 697 lines). The existing lint rule enforces a maintainability threshold; at this scale the violations blocked the codebase from passing lint checks cleanly and made individual functions difficult to read or test in isolation. + +### Decision + +We will eliminate function-length lint violations by extracting cohesive logical blocks into focused private helper functions and, where appropriate, splitting large source files into companion files (e.g., `maintenance_workflow_yaml_jobs.go`). Public interfaces and observable behavior are preserved unchanged. This approach removes all 680 findings without modifying the lint threshold or suppressing rules. + +### Alternatives Considered + +#### Alternative 1: Per-function `//nolint:funlen` suppression directives + +Add inline suppression comments at each offending function to silence the lint rule without changing code structure. This is faster to apply but permanently exempts those functions from the length rule, masks future growth, and provides no readability or testability benefit. + +#### Alternative 2: Raise or remove the function-length threshold in lint configuration + +Increase the allowed line count (e.g., from 60 to 200) or disable the rule entirely across the package. This eliminates all current findings instantly but weakens the quality gate for the entire codebase going forward and does not address the underlying complexity in those functions. + +#### Alternative 3: Struct-based decomposition using methods on new types + +Introduce new intermediate types (structs or interfaces) and attach the extracted logic as methods. This provides stronger encapsulation and enables dependency injection for testing but requires deeper structural changes, risks altering the API surface, and is disproportionate effort for a mechanical lint-compliance refactor. + +### Consequences + +#### Positive +- All 680 function-length lint findings are eliminated; `make golint-custom` passes cleanly. +- Individual helper functions are shorter and independently testable. +- Companion files (`maintenance_workflow_yaml_jobs.go`) group related job-builder logic, improving discoverability. +- A pre-existing bug in `applyRepoMemoryDefaultBranch` (early-exit due to `BranchName` being pre-populated) is fixed as a side effect of the decomposition. + +#### Negative +- Execution flow is now distributed across more call frames, increasing the cognitive load needed to trace end-to-end behavior. +- The number of private functions and, in one case, source files increases, adding navigation overhead. +- Helper functions are private and closely coupled to their single caller; they carry no reuse benefit today. + +#### Neutral +- No changes to public function signatures, exported types, or test files are required. +- The lint threshold itself (60 lines) remains unchanged — this ADR addresses compliance rather than the threshold value. +- Future growth in the refactored functions will still be caught by the same lint rule. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* From 097e555cf3dfd83b4568bd601de88b10da8ebae5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:43:56 +0000 Subject: [PATCH 4/4] fix: address PR finisher review regressions Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/cli/run_workflow_execution.go | 33 ++++++++++++------- pkg/cli/run_workflow_execution_test.go | 29 ++++++++++++++++ pkg/workflow/repo_memory_test.go | 21 ++++++++++++ pkg/workflow/side_repo_maintenance.go | 8 ++++- .../side_repo_maintenance_integration_test.go | 3 ++ 5 files changed, 82 insertions(+), 12 deletions(-) diff --git a/pkg/cli/run_workflow_execution.go b/pkg/cli/run_workflow_execution.go index 0792a737d64..aeabf7561d5 100644 --- a/pkg/cli/run_workflow_execution.go +++ b/pkg/cli/run_workflow_execution.go @@ -67,6 +67,11 @@ type workflowRunPreparation struct { lockFilePath string } +type workflowRunExecutionResult struct { + runInfo *WorkflowRunInfo + runInfoErr error +} + // RunWorkflowOnGitHub runs an agentic workflow on GitHub Actions func RunWorkflowOnGitHub(ctx context.Context, workflowIdOrName string, opts RunOptions) error { executionLog.Printf("Starting workflow run: workflow=%s, enable=%v, engineOverride=%s, repo=%s, ref=%s, push=%v, wait=%v, inputs=%v", workflowIdOrName, opts.Enable, opts.EngineOverride, opts.RepoOverride, opts.RefOverride, opts.Push, opts.WaitForCompletion, opts.Inputs) @@ -92,12 +97,12 @@ func RunWorkflowOnGitHub(ctx context.Context, workflowIdOrName string, opts RunO if opts.DryRun { return handleWorkflowDryRun(prep.lockFileName, args, opts) } - output, runInfo, runErr, err := executeWorkflowRun(ctx, prep.lockFileName, args, opts) + runResult, err := executeWorkflowRun(ctx, prep.lockFileName, args, opts) if err != nil { return err } - handleWorkflowRunInfo(output, runInfo, runErr, opts) - return waitForWorkflowRunCompletion(ctx, opts, runInfo, runErr, workflowStartTime, ref) + handleWorkflowRunInfo(runResult.runInfo, runResult.runInfoErr, opts) + return waitForWorkflowRunCompletion(ctx, opts, runResult.runInfo, runResult.runInfoErr, workflowStartTime, ref) } func checkWorkflowRunContext(ctx context.Context, workflowIdOrName string) error { @@ -151,14 +156,17 @@ func prepareWorkflowRun(ctx context.Context, workflowIdOrName string, opts RunOp func validateWorkflowForRun(workflowIdOrName string, opts RunOptions) error { if opts.RepoOverride != "" { executionLog.Printf("Validating remote workflow: %s in repo %s", workflowIdOrName, opts.RepoOverride) - return validateRemoteWorkflow(workflowIdOrName, opts.RepoOverride, opts.Verbose) + if err := validateRemoteWorkflow(workflowIdOrName, opts.RepoOverride, opts.Verbose); err != nil { + return fmt.Errorf("failed to validate remote workflow: %w", err) + } + return nil } - return validateLocalWorkflowForRun(workflowIdOrName, opts.Inputs) + return validateLocalWorkflowForRun(workflowIdOrName, opts.Inputs, opts.Verbose) } -func validateLocalWorkflowForRun(workflowIdOrName string, inputs []string) error { +func validateLocalWorkflowForRun(workflowIdOrName string, inputs []string, verbose bool) error { executionLog.Printf("Validating local workflow: %s", workflowIdOrName) - workflowFile, err := resolveWorkflowFile(workflowIdOrName, false) + workflowFile, err := resolveWorkflowFile(workflowIdOrName, verbose) if err != nil { return err } @@ -389,13 +397,13 @@ func handleWorkflowDryRun(lockFileName string, args []string, opts RunOptions) e return nil } -func executeWorkflowRun(ctx context.Context, lockFileName string, args []string, opts RunOptions) (string, *WorkflowRunInfo, error, error) { +func executeWorkflowRun(ctx context.Context, lockFileName string, args []string, opts RunOptions) (*workflowRunExecutionResult, error) { if opts.Verbose { fmt.Fprintln(os.Stderr, console.FormatCommandMessage("gh "+strings.Join(args, " "))) } stdout, err := workflow.ExecGHContext(ctx, args...).Output() if err != nil { - return "", nil, nil, formatWorkflowRunError(err) + return nil, formatWorkflowRunError(err) } output := strings.TrimSpace(string(stdout)) if output != "" { @@ -404,7 +412,10 @@ func executeWorkflowRun(ctx context.Context, lockFileName string, args []string, fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Successfully triggered workflow: "+lockFileName)) executionLog.Printf("Workflow triggered successfully: %s", lockFileName) runInfo, runErr := resolveWorkflowRunInfo(lockFileName, output, opts) - return output, runInfo, runErr, nil + return &workflowRunExecutionResult{ + runInfo: runInfo, + runInfoErr: runErr, + }, nil } func formatWorkflowRunError(err error) error { @@ -430,7 +441,7 @@ func resolveWorkflowRunInfo(lockFileName, output string, opts RunOptions) (*Work return getLatestWorkflowRunWithRetry(lockFileName, opts.RepoOverride, opts.Verbose) } -func handleWorkflowRunInfo(output string, runInfo *WorkflowRunInfo, runErr error, opts RunOptions) { +func handleWorkflowRunInfo(runInfo *WorkflowRunInfo, runErr error, opts RunOptions) { if runErr == nil && runInfo != nil && runInfo.URL != "" { fmt.Fprintln(os.Stderr, console.FormatInfoMessage("🔗 View workflow run: "+runInfo.URL)) executionLog.Printf("Workflow run URL: %s (ID: %d)", runInfo.URL, runInfo.DatabaseID) diff --git a/pkg/cli/run_workflow_execution_test.go b/pkg/cli/run_workflow_execution_test.go index d0160d2e6b8..35fef0ecc5b 100644 --- a/pkg/cli/run_workflow_execution_test.go +++ b/pkg/cli/run_workflow_execution_test.go @@ -3,10 +3,16 @@ package cli import ( + "bytes" "context" "errors" + "os" + "path/filepath" "strings" "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestRunWorkflowOnGitHub_InputValidation tests input validation in RunWorkflowOnGitHub @@ -187,6 +193,29 @@ func TestRunWorkflowsOnGitHub_ContextCancellation(t *testing.T) { } } +func TestValidateLocalWorkflowForRun_PropagatesVerbose(t *testing.T) { + workflowPath, err := filepath.Abs(filepath.Join("..", "..", ".github", "workflows", "smoke-call-workflow.md")) + require.NoError(t, err, "should resolve workflow path") + + oldStderr := os.Stderr + r, w, err := os.Pipe() + require.NoError(t, err, "should create stderr pipe") + os.Stderr = w + + runErr := validateLocalWorkflowForRun(workflowPath, nil, true) + + require.NoError(t, w.Close(), "should close write end of stderr pipe") + os.Stderr = oldStderr + + var buf bytes.Buffer + _, err = buf.ReadFrom(r) + require.NoError(t, err, "should read captured stderr") + + require.NoError(t, runErr, "local workflow validation should succeed for a runnable direct path") + assert.Contains(t, buf.String(), "Found workflow file at path", + "verbose workflow validation should preserve resolveWorkflowFile diagnostics") +} + // TestRunWorkflowOnGitHub_FlagCombinations tests various flag combinations func TestRunWorkflowOnGitHub_FlagCombinations(t *testing.T) { ctx := context.Background() diff --git a/pkg/workflow/repo_memory_test.go b/pkg/workflow/repo_memory_test.go index d12f68534b6..d75d91d5b9a 100644 --- a/pkg/workflow/repo_memory_test.go +++ b/pkg/workflow/repo_memory_test.go @@ -1007,6 +1007,27 @@ func TestBranchPrefixInArrayConfig(t *testing.T) { assert.Equal(t, "my-prefix/logs", config.Memories[1].BranchName, "Expected branch name 'my-prefix/logs'") } +func TestRepoMemoryCustomIDDerivesBranchName(t *testing.T) { + toolsMap := map[string]any{ + "repo-memory": map[string]any{ + "id": "my-agent", + }, + } + + toolsConfig, err := ParseToolsConfig(toolsMap) + require.NoError(t, err, "Failed to parse tools config") + + compiler := NewCompiler() + config, err := compiler.extractRepoMemoryConfig(toolsConfig, "my-workflow") + require.NoError(t, err, "Failed to extract repo-memory config") + require.NotNil(t, config, "Expected non-nil config") + require.Len(t, config.Memories, 1, "Expected a single repo memory entry") + + memory := config.Memories[0] + assert.Equal(t, "my-agent", memory.ID, "Expected explicit id to be preserved") + assert.Equal(t, "memory/my-agent", memory.BranchName, "Expected branch name to derive from explicit id") +} + // TestBranchPrefixWithExplicitBranchName tests that explicit branch-name overrides prefix func TestBranchPrefixWithExplicitBranchName(t *testing.T) { toolsMap := map[string]any{ diff --git a/pkg/workflow/side_repo_maintenance.go b/pkg/workflow/side_repo_maintenance.go index 265e6cd82e2..e79a9d29bb8 100644 --- a/pkg/workflow/side_repo_maintenance.go +++ b/pkg/workflow/side_repo_maintenance.go @@ -620,7 +620,13 @@ func buildActivityReportCacheSteps(renderCtx sideRepoMaintenanceRenderContext) s GH_AW_CMD_PREFIX: ` + getCLICmdPrefix(renderCtx.actionMode) + ` GH_AW_TARGET_REPO_SLUG: "` + renderCtx.repoSlug + `" run: | - ${GH_AW_CMD_PREFIX} logs --repo "${GH_AW_TARGET_REPO_SLUG}" --start-date -1w --count 500 --output ./.cache/gh-aw/activity-report-logs --format markdown --report-file ./.cache/gh-aw/activity-report-logs/report.md + ${GH_AW_CMD_PREFIX} logs \ + --repo "${GH_AW_TARGET_REPO_SLUG}" \ + --start-date -1w \ + --count 500 \ + --output ./.cache/gh-aw/activity-report-logs \ + --format markdown \ + --report-file ./.cache/gh-aw/activity-report-logs/report.md - name: Save activity report logs cache if: ${{ always() }} diff --git a/pkg/workflow/side_repo_maintenance_integration_test.go b/pkg/workflow/side_repo_maintenance_integration_test.go index d8fdd7b0b16..e13cdbe3f9f 100644 --- a/pkg/workflow/side_repo_maintenance_integration_test.go +++ b/pkg/workflow/side_repo_maintenance_integration_test.go @@ -114,6 +114,9 @@ This workflow operates on a separate repository. "generated workflow should set a 20-minute timeout for the activity_report logs download step") assert.Contains(t, contentStr, "${GH_AW_CMD_PREFIX} logs", "generated workflow should run gh aw logs directly") + assert.Contains(t, contentStr, `${GH_AW_CMD_PREFIX} logs \ + --repo "my-org/target-repo" \`, + "generated workflow should preserve the multi-line gh aw logs command formatting") assert.Contains(t, contentStr, "--start-date -1w", "generated workflow should download 7 days of logs for activity_report") assert.Contains(t, contentStr, "--count 500",