From b20104215a57c29e8c8fcc993edd5cd037db0775 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 04:37:25 +0000 Subject: [PATCH 1/2] Initial plan From 226e29511a9b29b6dd29ee9c432c1a2d7f77d904 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 20 Mar 2026 05:04:08 +0000 Subject: [PATCH 2/2] refactor: semantic function clustering - move functions to better-aligned files Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> Agent-Logs-Url: https://github.com/github/gh-aw/sessions/0f8a030d-0bd2-4db8-8ade-b0129504633e --- ..._helpers.go => compile_file_operations.go} | 2 +- ...e_orchestration.go => compile_pipeline.go} | 2 +- ...aml_utils.go => yaml_frontmatter_utils.go} | 2 +- ...test.go => yaml_frontmatter_utils_test.go} | 0 pkg/workflow/compiler_github_actions_steps.go | 122 +++++++++++++++ pkg/workflow/compiler_github_mcp_steps.go | 140 +++++++++++++++++ pkg/workflow/compiler_safe_outputs_steps.go | 142 ------------------ pkg/workflow/compiler_yaml_helpers.go | 111 +------------- pkg/workflow/copilot_engine.go | 23 ++- pkg/workflow/copilot_logs.go | 23 --- pkg/workflow/mcp_github_config.go | 132 ---------------- pkg/workflow/metrics.go | 65 -------- pkg/workflow/package_extraction.go | 65 ++++++++ pkg/workflow/runtime_validation.go | 65 -------- pkg/workflow/safe_outputs_config_helpers.go | 142 ++++++++++++++++++ pkg/workflow/strings.go | 65 ++++++++ 16 files changed, 559 insertions(+), 542 deletions(-) rename pkg/cli/{compile_helpers.go => compile_file_operations.go} (99%) rename pkg/cli/{compile_orchestration.go => compile_pipeline.go} (99%) rename pkg/cli/{codemod_yaml_utils.go => yaml_frontmatter_utils.go} (98%) rename pkg/cli/{codemod_yaml_utils_test.go => yaml_frontmatter_utils_test.go} (100%) create mode 100644 pkg/workflow/compiler_github_actions_steps.go create mode 100644 pkg/workflow/compiler_github_mcp_steps.go diff --git a/pkg/cli/compile_helpers.go b/pkg/cli/compile_file_operations.go similarity index 99% rename from pkg/cli/compile_helpers.go rename to pkg/cli/compile_file_operations.go index d418d26accb..a6a7351b429 100644 --- a/pkg/cli/compile_helpers.go +++ b/pkg/cli/compile_file_operations.go @@ -46,7 +46,7 @@ import ( "github.com/github/gh-aw/pkg/workflow" ) -var compileHelpersLog = logger.New("cli:compile_helpers") +var compileHelpersLog = logger.New("cli:compile_file_operations") // getRepositoryRelativePath converts an absolute file path to a repository-relative path // This ensures stable workflow identifiers regardless of where the repository is cloned diff --git a/pkg/cli/compile_orchestration.go b/pkg/cli/compile_pipeline.go similarity index 99% rename from pkg/cli/compile_orchestration.go rename to pkg/cli/compile_pipeline.go index 4dfefe402e8..502e89ed28e 100644 --- a/pkg/cli/compile_orchestration.go +++ b/pkg/cli/compile_pipeline.go @@ -34,7 +34,7 @@ import ( "github.com/github/gh-aw/pkg/workflow" ) -var compileOrchestrationLog = logger.New("cli:compile_orchestration") +var compileOrchestrationLog = logger.New("cli:compile_pipeline") // compileSpecificFiles compiles a specific list of workflow files func compileSpecificFiles( diff --git a/pkg/cli/codemod_yaml_utils.go b/pkg/cli/yaml_frontmatter_utils.go similarity index 98% rename from pkg/cli/codemod_yaml_utils.go rename to pkg/cli/yaml_frontmatter_utils.go index 2a818d75d60..10eea04be60 100644 --- a/pkg/cli/codemod_yaml_utils.go +++ b/pkg/cli/yaml_frontmatter_utils.go @@ -8,7 +8,7 @@ import ( "github.com/github/gh-aw/pkg/parser" ) -var yamlUtilsLog = logger.New("cli:codemod_yaml_utils") +var yamlUtilsLog = logger.New("cli:yaml_frontmatter_utils") // reconstructContent rebuilds the full markdown content from frontmatter lines and body func reconstructContent(frontmatterLines []string, markdown string) string { diff --git a/pkg/cli/codemod_yaml_utils_test.go b/pkg/cli/yaml_frontmatter_utils_test.go similarity index 100% rename from pkg/cli/codemod_yaml_utils_test.go rename to pkg/cli/yaml_frontmatter_utils_test.go diff --git a/pkg/workflow/compiler_github_actions_steps.go b/pkg/workflow/compiler_github_actions_steps.go new file mode 100644 index 00000000000..d32fd069b99 --- /dev/null +++ b/pkg/workflow/compiler_github_actions_steps.go @@ -0,0 +1,122 @@ +package workflow + +import ( + "fmt" + "strings" + + "github.com/github/gh-aw/pkg/logger" +) + +var compilerGitHubActionsStepsLog = logger.New("workflow:compiler_github_actions_steps") + +// generateGitHubScriptWithRequire generates a github-script step that loads a module using require(). +// Instead of repeating the global variable assignments inline, it uses the setup_globals helper function. +// +// Parameters: +// - scriptPath: The path to the .cjs file to require (e.g., "check_stop_time.cjs") +// +// Returns a string containing the complete script content to be used in a github-script action's "script:" field. +func generateGitHubScriptWithRequire(scriptPath string) string { + var script strings.Builder + + // Use the setup_globals helper to store GitHub Actions objects in global scope + script.WriteString(" const { setupGlobals } = require('" + SetupActionDestination + "/setup_globals.cjs');\n") + script.WriteString(" setupGlobals(core, github, context, exec, io);\n") + script.WriteString(" const { main } = require('" + SetupActionDestination + "/" + scriptPath + "');\n") + script.WriteString(" await main();\n") + + return script.String() +} + +// generateInlineGitHubScriptStep generates a simple inline github-script step +// for validation or utility operations that don't require artifact downloads. +// +// Parameters: +// - stepName: The name of the step (e.g., "Validate cache-memory file types") +// - script: The JavaScript code to execute (pre-formatted with proper indentation) +// - condition: Optional if condition (e.g., "always()"). Empty string means no condition. +// +// Returns a string containing the complete YAML for the github-script step. +func generateInlineGitHubScriptStep(stepName, script, condition string) string { + var step strings.Builder + + step.WriteString(" - name: " + stepName + "\n") + if condition != "" { + step.WriteString(" if: " + condition + "\n") + } + step.WriteString(" uses: " + GetActionPin("actions/github-script") + "\n") + step.WriteString(" with:\n") + step.WriteString(" script: |\n") + step.WriteString(script) + + return step.String() +} + +// generatePlaceholderSubstitutionStep generates a JavaScript-based step that performs +// safe placeholder substitution using the substitute_placeholders script. +// This replaces the multiple sed commands with a single JavaScript step. +func generatePlaceholderSubstitutionStep(yaml *strings.Builder, expressionMappings []*ExpressionMapping, indent string) { + if len(expressionMappings) == 0 { + return + } + + compilerGitHubActionsStepsLog.Printf("Generating placeholder substitution step with %d mappings", len(expressionMappings)) + + // Use actions/github-script to perform the substitutions + yaml.WriteString(indent + "- name: Substitute placeholders\n") + fmt.Fprintf(yaml, indent+" uses: %s\n", GetActionPin("actions/github-script")) + yaml.WriteString(indent + " env:\n") + yaml.WriteString(indent + " GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt\n") + + // Add all environment variables + // For static values (wrapped in quotes), output them directly without ${{ }} + // For GitHub expressions, wrap them in ${{ }} + for _, mapping := range expressionMappings { + content := mapping.Content + // Check if this is a static quoted value (starts and ends with quotes) + if (strings.HasPrefix(content, "'") && strings.HasSuffix(content, "'")) || + (strings.HasPrefix(content, "\"") && strings.HasSuffix(content, "\"")) { + // Static value - output directly without ${{ }} wrapper + // Check if inner value is multi-line; if so use a YAML double-quoted scalar + // with escaped newlines to avoid invalid YAML. + innerValue := content[1 : len(content)-1] + if strings.Contains(innerValue, "\n") { + escaped := strings.ReplaceAll(innerValue, `\`, `\\`) + escaped = strings.ReplaceAll(escaped, `"`, `\"`) + escaped = strings.ReplaceAll(escaped, "\n", `\n`) + fmt.Fprintf(yaml, indent+" %s: \"%s\"\n", mapping.EnvVar, escaped) + } else { + fmt.Fprintf(yaml, indent+" %s: %s\n", mapping.EnvVar, content) + } + } else { + // GitHub expression - wrap in ${{ }} + fmt.Fprintf(yaml, indent+" %s: ${{ %s }}\n", mapping.EnvVar, content) + } + } + + yaml.WriteString(indent + " with:\n") + yaml.WriteString(indent + " script: |\n") + + // Use setup_globals helper to make GitHub Actions objects available globally + yaml.WriteString(indent + " const { setupGlobals } = require('" + SetupActionDestination + "/setup_globals.cjs');\n") + yaml.WriteString(indent + " setupGlobals(core, github, context, exec, io);\n") + yaml.WriteString(indent + " \n") + // Use require() to load script from copied files + yaml.WriteString(indent + " const substitutePlaceholders = require('" + SetupActionDestination + "/substitute_placeholders.cjs');\n") + yaml.WriteString(indent + " \n") + yaml.WriteString(indent + " // Call the substitution function\n") + yaml.WriteString(indent + " return await substitutePlaceholders({\n") + yaml.WriteString(indent + " file: process.env.GH_AW_PROMPT,\n") + yaml.WriteString(indent + " substitutions: {\n") + + for i, mapping := range expressionMappings { + comma := "," + if i == len(expressionMappings)-1 { + comma = "" + } + fmt.Fprintf(yaml, indent+" %s: process.env.%s%s\n", mapping.EnvVar, mapping.EnvVar, comma) + } + + yaml.WriteString(indent + " }\n") + yaml.WriteString(indent + " });\n") +} diff --git a/pkg/workflow/compiler_github_mcp_steps.go b/pkg/workflow/compiler_github_mcp_steps.go new file mode 100644 index 00000000000..cf6b0e00cba --- /dev/null +++ b/pkg/workflow/compiler_github_mcp_steps.go @@ -0,0 +1,140 @@ +package workflow + +import ( + "fmt" + "strings" + + "github.com/github/gh-aw/pkg/constants" +) + +// generateGitHubMCPLockdownDetectionStep generates a step to determine automatic guard policy +// for GitHub MCP server based on repository visibility. +// This step is added when: +// - GitHub tool is enabled AND +// - guard policy (repos/min-integrity) is not fully configured in the workflow AND +// - tools.github.app is NOT configured (GitHub App tokens are already repo-scoped, so +// automatic guard policy detection is unnecessary and skipped) +// +// For public repositories, the step automatically sets min-integrity to "approved" and +// repos to "all" if they are not already configured. +func (c *Compiler) generateGitHubMCPLockdownDetectionStep(yaml *strings.Builder, data *WorkflowData) { + // Check if GitHub tool is present + githubTool, hasGitHub := data.Tools["github"] + if !hasGitHub || githubTool == false { + return + } + + // Skip when guard policy is already fully configured in the workflow. + // The step is only needed to auto-configure guard policies for public repos. + if len(getGitHubGuardPolicies(githubTool)) > 0 { + githubConfigLog.Print("Guard policy already configured in workflow, skipping automatic guard policy determination") + return + } + + // Skip automatic guard policy detection when a GitHub App is configured. + // GitHub App tokens are already scoped to specific repositories, so automatic + // guard policy detection is not needed — the token's access is inherently bounded + // by the app installation and the listed repositories. + if hasGitHubApp(githubTool) { + githubConfigLog.Print("GitHub App configured, skipping automatic guard policy determination (app tokens are already repo-scoped)") + return + } + + githubConfigLog.Print("Generating automatic guard policy determination step for GitHub MCP server") + + // Resolve the latest version of actions/github-script + actionRepo := "actions/github-script" + actionVersion := string(constants.DefaultGitHubScriptVersion) + pinnedAction, err := GetActionPinWithData(actionRepo, actionVersion, data) + if err != nil { + githubConfigLog.Printf("Failed to resolve %s@%s: %v", actionRepo, actionVersion, err) + // In strict mode, this error would have been returned by GetActionPinWithData + // In normal mode, we fall back to using the version tag without pinning + pinnedAction = fmt.Sprintf("%s@%s", actionRepo, actionVersion) + } + + // Extract current guard policy configuration to pass as env vars so the step can + // detect whether each field is already configured and avoid overriding it. + configuredMinIntegrity := "" + configuredRepos := "" + if toolConfig, ok := githubTool.(map[string]any); ok { + if v, exists := toolConfig["min-integrity"]; exists { + configuredMinIntegrity = fmt.Sprintf("%v", v) + } + if v, exists := toolConfig["repos"]; exists { + configuredRepos = fmt.Sprintf("%v", v) + } + } + + // Generate the step using the determine_automatic_lockdown.cjs action + yaml.WriteString(" - name: Determine automatic lockdown mode for GitHub MCP Server\n") + yaml.WriteString(" id: determine-automatic-lockdown\n") + fmt.Fprintf(yaml, " uses: %s\n", pinnedAction) + yaml.WriteString(" env:\n") + yaml.WriteString(" GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}\n") + yaml.WriteString(" GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}\n") + if configuredMinIntegrity != "" { + fmt.Fprintf(yaml, " GH_AW_GITHUB_MIN_INTEGRITY: %s\n", configuredMinIntegrity) + } + if configuredRepos != "" { + fmt.Fprintf(yaml, " GH_AW_GITHUB_REPOS: %s\n", configuredRepos) + } + yaml.WriteString(" with:\n") + yaml.WriteString(" script: |\n") + yaml.WriteString(" const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs');\n") + yaml.WriteString(" await determineAutomaticLockdown(github, context, core);\n") +} + +// generateGitHubMCPAppTokenMintingStep generates a step to mint a GitHub App token for GitHub MCP server +// This step is added when: +// - GitHub tool is enabled with app configuration +// The step mints an installation access token with permissions matching the agent job permissions +func (c *Compiler) generateGitHubMCPAppTokenMintingStep(yaml *strings.Builder, data *WorkflowData) { + // Check if GitHub tool has app configuration + if data.ParsedTools == nil || data.ParsedTools.GitHub == nil || data.ParsedTools.GitHub.GitHubApp == nil { + return + } + + app := data.ParsedTools.GitHub.GitHubApp + githubConfigLog.Printf("Generating GitHub App token minting step for GitHub MCP server: app-id=%s", app.AppID) + + // Get permissions from the agent job - parse from YAML string + var permissions *Permissions + if data.Permissions != "" { + parser := NewPermissionsParser(data.Permissions) + permissions = parser.ToPermissions() + } else { + githubConfigLog.Print("No permissions specified, using empty permissions") + permissions = NewPermissions() + } + + // Generate the token minting step using the existing helper from safe_outputs_app.go + steps := c.buildGitHubAppTokenMintStep(app, permissions, "") + + // Modify the step ID to differentiate from safe-outputs app token + // Replace "safe-outputs-app-token" with "github-mcp-app-token" + for _, step := range steps { + modifiedStep := strings.ReplaceAll(step, "id: safe-outputs-app-token", "id: github-mcp-app-token") + yaml.WriteString(modifiedStep) + } +} + +// generateGitHubMCPAppTokenInvalidationStep generates a step to invalidate the GitHub App token for GitHub MCP server +// This step always runs (even on failure) to ensure tokens are properly cleaned up +func (c *Compiler) generateGitHubMCPAppTokenInvalidationStep(yaml *strings.Builder, data *WorkflowData) { + // Check if GitHub tool has app configuration + if data.ParsedTools == nil || data.ParsedTools.GitHub == nil || data.ParsedTools.GitHub.GitHubApp == nil { + return + } + + githubConfigLog.Print("Generating GitHub App token invalidation step for GitHub MCP server") + + // Generate the token invalidation step using the existing helper from safe_outputs_app.go + steps := c.buildGitHubAppTokenInvalidationStep() + + // Modify the step references to use github-mcp-app-token instead of safe-outputs-app-token + for _, step := range steps { + modifiedStep := strings.ReplaceAll(step, "steps.safe-outputs-app-token.outputs.token", "steps.github-mcp-app-token.outputs.token") + yaml.WriteString(modifiedStep) + } +} diff --git a/pkg/workflow/compiler_safe_outputs_steps.go b/pkg/workflow/compiler_safe_outputs_steps.go index 3ebd8727162..fbe81dfd054 100644 --- a/pkg/workflow/compiler_safe_outputs_steps.go +++ b/pkg/workflow/compiler_safe_outputs_steps.go @@ -1,119 +1,13 @@ package workflow import ( - "encoding/json" "fmt" - "sort" "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/stringutil" ) var consolidatedSafeOutputsStepsLog = logger.New("workflow:compiler_safe_outputs_steps") -// computeEffectivePRCheckoutToken returns the token to use for PR checkout and git operations. -// Applies the following precedence (highest to lowest): -// 1. Per-config PAT: create-pull-request.github-token -// 2. Per-config PAT: push-to-pull-request-branch.github-token -// 3. GitHub App minted token (if a github-app is configured) -// 4. safe-outputs level PAT: safe-outputs.github-token -// 5. Default fallback via getEffectiveSafeOutputGitHubToken() -// -// Per-config tokens take precedence over the GitHub App so that individual operations -// can override the app-wide authentication with a dedicated PAT when needed. -// -// This is used by buildSharedPRCheckoutSteps and buildHandlerManagerStep to ensure consistent token handling. -// -// Returns: -// - token: the effective GitHub Actions token expression to use for git operations -// - isCustom: true when a custom non-default token was explicitly configured (per-config PAT, app, or safe-outputs PAT) -func computeEffectivePRCheckoutToken(safeOutputs *SafeOutputsConfig) (token string, isCustom bool) { - if safeOutputs == nil { - return getEffectiveSafeOutputGitHubToken(""), false - } - - // Per-config PAT tokens take highest precedence (overrides GitHub App) - var createPRToken string - if safeOutputs.CreatePullRequests != nil { - createPRToken = safeOutputs.CreatePullRequests.GitHubToken - } - var pushToPRBranchToken string - if safeOutputs.PushToPullRequestBranch != nil { - pushToPRBranchToken = safeOutputs.PushToPullRequestBranch.GitHubToken - } - perConfigToken := createPRToken - if perConfigToken == "" { - perConfigToken = pushToPRBranchToken - } - if perConfigToken != "" { - return getEffectiveSafeOutputGitHubToken(perConfigToken), true - } - - // GitHub App token takes precedence over the safe-outputs level PAT - if safeOutputs.GitHubApp != nil { - //nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential - return "${{ steps.safe-outputs-app-token.outputs.token }}", true - } - - // safe-outputs level PAT as final custom option - if safeOutputs.GitHubToken != "" { - return getEffectiveSafeOutputGitHubToken(safeOutputs.GitHubToken), true - } - - // No custom token - fall back to default - return getEffectiveSafeOutputGitHubToken(""), false -} - -// computeEffectiveProjectToken computes the effective project token using the precedence: -// 1. Per-config token (e.g., from update-project, create-project-status-update) -// 2. Safe-outputs level token -// 3. Magic secret fallback via getEffectiveProjectGitHubToken() -func computeEffectiveProjectToken(perConfigToken string, safeOutputsToken string) string { - token := perConfigToken - if token == "" { - token = safeOutputsToken - } - return getEffectiveProjectGitHubToken(token) -} - -// computeProjectURLAndToken computes the project URL and token from the various project-related -// safe-output configurations. Priority order: update-project > create-project-status-update > create-project. -// Returns the project URL (may be empty for create-project) and the effective token. -func computeProjectURLAndToken(safeOutputs *SafeOutputsConfig) (projectURL, projectToken string) { - if safeOutputs == nil { - return "", "" - } - - safeOutputsToken := safeOutputs.GitHubToken - - // Check update-project first (highest priority) - if safeOutputs.UpdateProjects != nil && safeOutputs.UpdateProjects.Project != "" { - projectURL = safeOutputs.UpdateProjects.Project - projectToken = computeEffectiveProjectToken(safeOutputs.UpdateProjects.GitHubToken, safeOutputsToken) - consolidatedSafeOutputsStepsLog.Printf("Setting GH_AW_PROJECT_URL from update-project config: %s", projectURL) - consolidatedSafeOutputsStepsLog.Printf("Setting GH_AW_PROJECT_GITHUB_TOKEN from update-project config") - return - } - - // Check create-project-status-update second - if safeOutputs.CreateProjectStatusUpdates != nil && safeOutputs.CreateProjectStatusUpdates.Project != "" { - projectURL = safeOutputs.CreateProjectStatusUpdates.Project - projectToken = computeEffectiveProjectToken(safeOutputs.CreateProjectStatusUpdates.GitHubToken, safeOutputsToken) - consolidatedSafeOutputsStepsLog.Printf("Setting GH_AW_PROJECT_URL from create-project-status-update config: %s", projectURL) - consolidatedSafeOutputsStepsLog.Printf("Setting GH_AW_PROJECT_GITHUB_TOKEN from create-project-status-update config") - return - } - - // Check create-project for token even if no URL is set (create-project doesn't have a project URL field) - // This ensures GH_AW_PROJECT_GITHUB_TOKEN is set when create-project is configured - if safeOutputs.CreateProjects != nil { - projectToken = computeEffectiveProjectToken(safeOutputs.CreateProjects.GitHubToken, safeOutputsToken) - consolidatedSafeOutputsStepsLog.Printf("Setting GH_AW_PROJECT_GITHUB_TOKEN from create-project config") - } - - return -} - // buildConsolidatedSafeOutputStep builds a single step for a safe output operation // within the consolidated safe-outputs job. This function handles both inline script // mode and file mode (requiring from local filesystem). @@ -458,39 +352,3 @@ func (c *Compiler) buildHandlerManagerStep(data *WorkflowData) []string { return steps } - -// buildCustomSafeOutputJobsJSON builds a JSON mapping of custom safe output job names to empty -// strings, for use in the GH_AW_SAFE_OUTPUT_JOBS env var of the handler manager step. -// This allows the handler manager to silently skip messages handled by custom safe-output job -// steps rather than reporting them as "No handler loaded for message type '...'". -func buildCustomSafeOutputJobsJSON(data *WorkflowData) string { - if data.SafeOutputs == nil || len(data.SafeOutputs.Jobs) == 0 { - return "" - } - - // Build mapping of normalized job names to empty strings (no URL output for custom jobs) - jobMapping := make(map[string]string, len(data.SafeOutputs.Jobs)) - for jobName := range data.SafeOutputs.Jobs { - normalizedName := stringutil.NormalizeSafeOutputIdentifier(jobName) - jobMapping[normalizedName] = "" - } - - // Sort keys for deterministic output - keys := make([]string, 0, len(jobMapping)) - for k := range jobMapping { - keys = append(keys, k) - } - sort.Strings(keys) - - ordered := make(map[string]string, len(keys)) - for _, k := range keys { - ordered[k] = jobMapping[k] - } - - jsonBytes, err := json.Marshal(ordered) - if err != nil { - consolidatedSafeOutputsStepsLog.Printf("Warning: failed to marshal custom safe output jobs: %v", err) - return "" - } - return string(jsonBytes) -} diff --git a/pkg/workflow/compiler_yaml_helpers.go b/pkg/workflow/compiler_yaml_helpers.go index f57e8a31d23..196e7ceb12b 100644 --- a/pkg/workflow/compiler_yaml_helpers.go +++ b/pkg/workflow/compiler_yaml_helpers.go @@ -172,75 +172,6 @@ func getInstallationVersion(data *WorkflowData, engine CodingAgentEngine) string } } -// generatePlaceholderSubstitutionStep generates a JavaScript-based step that performs -// safe placeholder substitution using the substitute_placeholders script. -// This replaces the multiple sed commands with a single JavaScript step. -func generatePlaceholderSubstitutionStep(yaml *strings.Builder, expressionMappings []*ExpressionMapping, indent string) { - if len(expressionMappings) == 0 { - return - } - - compilerYamlHelpersLog.Printf("Generating placeholder substitution step with %d mappings", len(expressionMappings)) - - // Use actions/github-script to perform the substitutions - yaml.WriteString(indent + "- name: Substitute placeholders\n") - fmt.Fprintf(yaml, indent+" uses: %s\n", GetActionPin("actions/github-script")) - yaml.WriteString(indent + " env:\n") - yaml.WriteString(indent + " GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt\n") - - // Add all environment variables - // For static values (wrapped in quotes), output them directly without ${{ }} - // For GitHub expressions, wrap them in ${{ }} - for _, mapping := range expressionMappings { - content := mapping.Content - // Check if this is a static quoted value (starts and ends with quotes) - if (strings.HasPrefix(content, "'") && strings.HasSuffix(content, "'")) || - (strings.HasPrefix(content, "\"") && strings.HasSuffix(content, "\"")) { - // Static value - output directly without ${{ }} wrapper - // Check if inner value is multi-line; if so use a YAML double-quoted scalar - // with escaped newlines to avoid invalid YAML. - innerValue := content[1 : len(content)-1] - if strings.Contains(innerValue, "\n") { - escaped := strings.ReplaceAll(innerValue, `\`, `\\`) - escaped = strings.ReplaceAll(escaped, `"`, `\"`) - escaped = strings.ReplaceAll(escaped, "\n", `\n`) - fmt.Fprintf(yaml, indent+" %s: \"%s\"\n", mapping.EnvVar, escaped) - } else { - fmt.Fprintf(yaml, indent+" %s: %s\n", mapping.EnvVar, content) - } - } else { - // GitHub expression - wrap in ${{ }} - fmt.Fprintf(yaml, indent+" %s: ${{ %s }}\n", mapping.EnvVar, content) - } - } - - yaml.WriteString(indent + " with:\n") - yaml.WriteString(indent + " script: |\n") - - // Use setup_globals helper to make GitHub Actions objects available globally - yaml.WriteString(indent + " const { setupGlobals } = require('" + SetupActionDestination + "/setup_globals.cjs');\n") - yaml.WriteString(indent + " setupGlobals(core, github, context, exec, io);\n") - yaml.WriteString(indent + " \n") - // Use require() to load script from copied files - yaml.WriteString(indent + " const substitutePlaceholders = require('" + SetupActionDestination + "/substitute_placeholders.cjs');\n") - yaml.WriteString(indent + " \n") - yaml.WriteString(indent + " // Call the substitution function\n") - yaml.WriteString(indent + " return await substitutePlaceholders({\n") - yaml.WriteString(indent + " file: process.env.GH_AW_PROMPT,\n") - yaml.WriteString(indent + " substitutions: {\n") - - for i, mapping := range expressionMappings { - comma := "," - if i == len(expressionMappings)-1 { - comma = "" - } - fmt.Fprintf(yaml, indent+" %s: process.env.%s%s\n", mapping.EnvVar, mapping.EnvVar, comma) - } - - yaml.WriteString(indent + " }\n") - yaml.WriteString(indent + " });\n") -} - // versionToGitRef converts a compiler version string to a valid git ref for use // in actions/checkout ref: fields. // @@ -347,48 +278,10 @@ func (c *Compiler) generateCheckoutActionsFolder(data *WorkflowData) []string { // // Returns a slice of strings that can be appended to a steps array, where each // string represents a line of YAML for the checkout step. Returns nil if: -// generateGitHubScriptWithRequire generates a github-script step that loads a module using require(). -// Instead of repeating the global variable assignments inline, it uses the setup_globals helper function. -// -// Parameters: -// - scriptPath: The path to the .cjs file to require (e.g., "check_stop_time.cjs") -// -// Returns a string containing the complete script content to be used in a github-script action's "script:" field. -func generateGitHubScriptWithRequire(scriptPath string) string { - var script strings.Builder - - // Use the setup_globals helper to store GitHub Actions objects in global scope - script.WriteString(" const { setupGlobals } = require('" + SetupActionDestination + "/setup_globals.cjs');\n") - script.WriteString(" setupGlobals(core, github, context, exec, io);\n") - script.WriteString(" const { main } = require('" + SetupActionDestination + "/" + scriptPath + "');\n") - script.WriteString(" await main();\n") - - return script.String() -} -// generateInlineGitHubScriptStep generates a simple inline github-script step -// for validation or utility operations that don't require artifact downloads. -// -// Parameters: -// - stepName: The name of the step (e.g., "Validate cache-memory file types") -// - script: The JavaScript code to execute (pre-formatted with proper indentation) -// - condition: Optional if condition (e.g., "always()"). Empty string means no condition. -// -// Returns a string containing the complete YAML for the github-script step. -func generateInlineGitHubScriptStep(stepName, script, condition string) string { - var step strings.Builder +// generateGitHubScriptWithRequire is implemented in compiler_github_actions_steps.go - step.WriteString(" - name: " + stepName + "\n") - if condition != "" { - step.WriteString(" if: " + condition + "\n") - } - step.WriteString(" uses: " + GetActionPin("actions/github-script") + "\n") - step.WriteString(" with:\n") - step.WriteString(" script: |\n") - step.WriteString(script) - - return step.String() -} +// generateInlineGitHubScriptStep is implemented in compiler_github_actions_steps.go // generateSetupStep generates the setup step based on the action mode. // In script mode, it runs the setup.sh script directly from the checked-out source. diff --git a/pkg/workflow/copilot_engine.go b/pkg/workflow/copilot_engine.go index 1331cc011f3..f32e9e9198f 100644 --- a/pkg/workflow/copilot_engine.go +++ b/pkg/workflow/copilot_engine.go @@ -138,11 +138,28 @@ func (e *CopilotEngine) GetAgentManifestFiles() []string { // GetLogFileForParsing is implemented in copilot_logs.go -// GetFirewallLogsCollectionStep is implemented in copilot_logs.go +// GetFirewallLogsCollectionStep returns steps for collecting firewall logs and copying session state files +func (e *CopilotEngine) GetFirewallLogsCollectionStep(workflowData *WorkflowData) []GitHubActionStep { + var steps []GitHubActionStep -// GetSquidLogsSteps is implemented in copilot_logs.go + // Add step to copy Copilot session state files to logs folder + // This ensures session files are in /tmp/gh-aw/ where secret redaction can scan them + sessionCopyStep := generateCopilotSessionFileCopyStep() + steps = append(steps, sessionCopyStep) -// GetCleanupStep is implemented in copilot_logs.go + return steps +} + +// GetSquidLogsSteps returns the steps for uploading and parsing Squid logs (after secret redaction) +func (e *CopilotEngine) GetSquidLogsSteps(workflowData *WorkflowData) []GitHubActionStep { + return defaultGetSquidLogsSteps(workflowData, copilotLog) +} + +// GetCleanupStep returns the post-execution cleanup step (currently empty) +func (e *CopilotEngine) GetCleanupStep(workflowData *WorkflowData) GitHubActionStep { + // Return empty step - cleanup steps have been removed + return GitHubActionStep([]string{}) +} // computeCopilotToolArguments is implemented in copilot_engine_tools.go diff --git a/pkg/workflow/copilot_logs.go b/pkg/workflow/copilot_logs.go index 242148b301a..2db03d3088b 100644 --- a/pkg/workflow/copilot_logs.go +++ b/pkg/workflow/copilot_logs.go @@ -433,26 +433,3 @@ func (e *CopilotEngine) GetLogParserScriptId() string { func (e *CopilotEngine) GetLogFileForParsing() string { return "/tmp/gh-aw/sandbox/agent/logs/" } - -// GetFirewallLogsCollectionStep returns steps for collecting firewall logs and copying session state files -func (e *CopilotEngine) GetFirewallLogsCollectionStep(workflowData *WorkflowData) []GitHubActionStep { - var steps []GitHubActionStep - - // Add step to copy Copilot session state files to logs folder - // This ensures session files are in /tmp/gh-aw/ where secret redaction can scan them - sessionCopyStep := generateCopilotSessionFileCopyStep() - steps = append(steps, sessionCopyStep) - - return steps -} - -// GetSquidLogsSteps returns the steps for uploading and parsing Squid logs (after secret redaction) -func (e *CopilotEngine) GetSquidLogsSteps(workflowData *WorkflowData) []GitHubActionStep { - return defaultGetSquidLogsSteps(workflowData, copilotLogsLog) -} - -// GetCleanupStep returns the post-execution cleanup step (currently empty) -func (e *CopilotEngine) GetCleanupStep(workflowData *WorkflowData) GitHubActionStep { - // Return empty step - cleanup steps have been removed - return GitHubActionStep([]string{}) -} diff --git a/pkg/workflow/mcp_github_config.go b/pkg/workflow/mcp_github_config.go index 07ff4230e3c..ed209182f9c 100644 --- a/pkg/workflow/mcp_github_config.go +++ b/pkg/workflow/mcp_github_config.go @@ -417,135 +417,3 @@ func getGitHubDockerImageVersion(githubTool any) string { } return githubDockerImageVersion } - -// generateGitHubMCPLockdownDetectionStep generates a step to determine automatic guard policy -// for GitHub MCP server based on repository visibility. -// This step is added when: -// - GitHub tool is enabled AND -// - guard policy (repos/min-integrity) is not fully configured in the workflow AND -// - tools.github.app is NOT configured (GitHub App tokens are already repo-scoped, so -// automatic guard policy detection is unnecessary and skipped) -// -// For public repositories, the step automatically sets min-integrity to "approved" and -// repos to "all" if they are not already configured. -func (c *Compiler) generateGitHubMCPLockdownDetectionStep(yaml *strings.Builder, data *WorkflowData) { - // Check if GitHub tool is present - githubTool, hasGitHub := data.Tools["github"] - if !hasGitHub || githubTool == false { - return - } - - // Skip when guard policy is already fully configured in the workflow. - // The step is only needed to auto-configure guard policies for public repos. - if len(getGitHubGuardPolicies(githubTool)) > 0 { - githubConfigLog.Print("Guard policy already configured in workflow, skipping automatic guard policy determination") - return - } - - // Skip automatic guard policy detection when a GitHub App is configured. - // GitHub App tokens are already scoped to specific repositories, so automatic - // guard policy detection is not needed — the token's access is inherently bounded - // by the app installation and the listed repositories. - if hasGitHubApp(githubTool) { - githubConfigLog.Print("GitHub App configured, skipping automatic guard policy determination (app tokens are already repo-scoped)") - return - } - - githubConfigLog.Print("Generating automatic guard policy determination step for GitHub MCP server") - - // Resolve the latest version of actions/github-script - actionRepo := "actions/github-script" - actionVersion := string(constants.DefaultGitHubScriptVersion) - pinnedAction, err := GetActionPinWithData(actionRepo, actionVersion, data) - if err != nil { - githubConfigLog.Printf("Failed to resolve %s@%s: %v", actionRepo, actionVersion, err) - // In strict mode, this error would have been returned by GetActionPinWithData - // In normal mode, we fall back to using the version tag without pinning - pinnedAction = fmt.Sprintf("%s@%s", actionRepo, actionVersion) - } - - // Extract current guard policy configuration to pass as env vars so the step can - // detect whether each field is already configured and avoid overriding it. - configuredMinIntegrity := "" - configuredRepos := "" - if toolConfig, ok := githubTool.(map[string]any); ok { - if v, exists := toolConfig["min-integrity"]; exists { - configuredMinIntegrity = fmt.Sprintf("%v", v) - } - if v, exists := toolConfig["repos"]; exists { - configuredRepos = fmt.Sprintf("%v", v) - } - } - - // Generate the step using the determine_automatic_lockdown.cjs action - yaml.WriteString(" - name: Determine automatic lockdown mode for GitHub MCP Server\n") - yaml.WriteString(" id: determine-automatic-lockdown\n") - fmt.Fprintf(yaml, " uses: %s\n", pinnedAction) - yaml.WriteString(" env:\n") - yaml.WriteString(" GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}\n") - yaml.WriteString(" GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}\n") - if configuredMinIntegrity != "" { - fmt.Fprintf(yaml, " GH_AW_GITHUB_MIN_INTEGRITY: %s\n", configuredMinIntegrity) - } - if configuredRepos != "" { - fmt.Fprintf(yaml, " GH_AW_GITHUB_REPOS: %s\n", configuredRepos) - } - yaml.WriteString(" with:\n") - yaml.WriteString(" script: |\n") - yaml.WriteString(" const determineAutomaticLockdown = require('${{ runner.temp }}/gh-aw/actions/determine_automatic_lockdown.cjs');\n") - yaml.WriteString(" await determineAutomaticLockdown(github, context, core);\n") -} - -// generateGitHubMCPAppTokenMintingStep generates a step to mint a GitHub App token for GitHub MCP server -// This step is added when: -// - GitHub tool is enabled with app configuration -// The step mints an installation access token with permissions matching the agent job permissions -func (c *Compiler) generateGitHubMCPAppTokenMintingStep(yaml *strings.Builder, data *WorkflowData) { - // Check if GitHub tool has app configuration - if data.ParsedTools == nil || data.ParsedTools.GitHub == nil || data.ParsedTools.GitHub.GitHubApp == nil { - return - } - - app := data.ParsedTools.GitHub.GitHubApp - githubConfigLog.Printf("Generating GitHub App token minting step for GitHub MCP server: app-id=%s", app.AppID) - - // Get permissions from the agent job - parse from YAML string - var permissions *Permissions - if data.Permissions != "" { - parser := NewPermissionsParser(data.Permissions) - permissions = parser.ToPermissions() - } else { - githubConfigLog.Print("No permissions specified, using empty permissions") - permissions = NewPermissions() - } - - // Generate the token minting step using the existing helper from safe_outputs_app.go - steps := c.buildGitHubAppTokenMintStep(app, permissions, "") - - // Modify the step ID to differentiate from safe-outputs app token - // Replace "safe-outputs-app-token" with "github-mcp-app-token" - for _, step := range steps { - modifiedStep := strings.ReplaceAll(step, "id: safe-outputs-app-token", "id: github-mcp-app-token") - yaml.WriteString(modifiedStep) - } -} - -// generateGitHubMCPAppTokenInvalidationStep generates a step to invalidate the GitHub App token for GitHub MCP server -// This step always runs (even on failure) to ensure tokens are properly cleaned up -func (c *Compiler) generateGitHubMCPAppTokenInvalidationStep(yaml *strings.Builder, data *WorkflowData) { - // Check if GitHub tool has app configuration - if data.ParsedTools == nil || data.ParsedTools.GitHub == nil || data.ParsedTools.GitHub.GitHubApp == nil { - return - } - - githubConfigLog.Print("Generating GitHub App token invalidation step for GitHub MCP server") - - // Generate the token invalidation step using the existing helper from safe_outputs_app.go - steps := c.buildGitHubAppTokenInvalidationStep() - - // Modify the step references to use github-mcp-app-token instead of safe-outputs-app-token - for _, step := range steps { - modifiedStep := strings.ReplaceAll(step, "steps.safe-outputs-app-token.outputs.token", "steps.github-mcp-app-token.outputs.token") - yaml.WriteString(modifiedStep) - } -} diff --git a/pkg/workflow/metrics.go b/pkg/workflow/metrics.go index 0c44b198ac1..78074e8b17a 100644 --- a/pkg/workflow/metrics.go +++ b/pkg/workflow/metrics.go @@ -2,9 +2,7 @@ package workflow import ( "encoding/json" - "fmt" "sort" - "strconv" "strings" "time" @@ -194,69 +192,6 @@ func ExtractJSONCost(data map[string]any) float64 { return 0 } -// ConvertToInt safely converts any to int -func ConvertToInt(val any) int { - switch v := val.(type) { - case int: - return v - case int64: - return int(v) - case float64: - intVal := int(v) - // Warn if truncation occurs (value has fractional part) - if v != float64(intVal) { - metricsLog.Printf("Float value %.2f truncated to integer %d", v, intVal) - } - return intVal - case string: - if i, err := strconv.Atoi(v); err == nil { - return i - } - } - return 0 -} - -// ConvertToFloat safely converts any to float64 -func ConvertToFloat(val any) float64 { - switch v := val.(type) { - case float64: - return v - case int: - return float64(v) - case int64: - return float64(v) - case string: - if f, err := strconv.ParseFloat(v, 64); err == nil { - return f - } - } - return 0 -} - -// PrettifyToolName removes "mcp__" prefix and formats tool names nicely -func PrettifyToolName(toolName string) string { - // Handle MCP tools: "mcp__github__search_issues" -> "github_search_issues" - // Avoid colons and leave underscores as-is - if strings.HasPrefix(toolName, "mcp__") { - parts := strings.Split(toolName, "__") - if len(parts) >= 3 { - provider := parts[1] - method := strings.Join(parts[2:], "_") - return fmt.Sprintf("%s_%s", provider, method) - } - // If format is unexpected, just remove the mcp__ prefix - return strings.TrimPrefix(toolName, "mcp__") - } - - // Handle bash specially - keep as "bash" - if strings.ToLower(toolName) == "bash" { - return "bash" - } - - // Return other tool names as-is - return toolName -} - // FinalizeToolMetricsOptions holds the options for FinalizeToolMetrics type FinalizeToolMetricsOptions struct { Metrics *LogMetrics diff --git a/pkg/workflow/package_extraction.go b/pkg/workflow/package_extraction.go index ced416490ec..ad5098c035e 100644 --- a/pkg/workflow/package_extraction.go +++ b/pkg/workflow/package_extraction.go @@ -340,3 +340,68 @@ func (pe *PackageExtractor) findPackageName(words []string, startIndex int) stri } return "" } + +// collectPackagesFromWorkflow is a generic helper that collects packages from workflow data +// using the provided extractor function. It deduplicates packages and optionally extracts +// from MCP tool configurations when toolCommand is provided. +func collectPackagesFromWorkflow( + workflowData *WorkflowData, + extractor func(string) []string, + toolCommand string, +) []string { + pkgLog.Printf("Collecting packages from workflow: toolCommand=%s", toolCommand) + var packages []string + seen := make(map[string]bool) + + // Extract from custom steps + if workflowData.CustomSteps != "" { + pkgs := extractor(workflowData.CustomSteps) + for _, pkg := range pkgs { + if !seen[pkg] { + packages = append(packages, pkg) + seen[pkg] = true + } + } + } + + // Extract from MCP server configurations (if toolCommand is provided) + if toolCommand != "" && workflowData.Tools != nil { + for _, toolConfig := range workflowData.Tools { + // Handle structured MCP config with command and args fields + if config, ok := toolConfig.(map[string]any); ok { + if command, hasCommand := config["command"]; hasCommand { + if cmdStr, ok := command.(string); ok && cmdStr == toolCommand { + // Extract package from args, skipping flags + if args, hasArgs := config["args"]; hasArgs { + if argsSlice, ok := args.([]any); ok { + for _, arg := range argsSlice { + if pkgStr, ok := arg.(string); ok { + // Skip flags (arguments starting with - or --) + if !strings.HasPrefix(pkgStr, "-") && !seen[pkgStr] { + packages = append(packages, pkgStr) + seen[pkgStr] = true + break // Only take the first non-flag argument + } + } + } + } + } + } + } + } else if cmdStr, ok := toolConfig.(string); ok { + // Handle string-format MCP tool (e.g., "npx -y package") + // Use the extractor function to parse the command string + pkgs := extractor(cmdStr) + for _, pkg := range pkgs { + if !seen[pkg] { + packages = append(packages, pkg) + seen[pkg] = true + } + } + } + } + } + + pkgLog.Printf("Collected %d unique packages", len(packages)) + return packages +} diff --git a/pkg/workflow/runtime_validation.go b/pkg/workflow/runtime_validation.go index f725af5e0b7..468e00ac518 100644 --- a/pkg/workflow/runtime_validation.go +++ b/pkg/workflow/runtime_validation.go @@ -288,68 +288,3 @@ func (c *Compiler) validateRuntimePackages(workflowData *WorkflowData) error { runtimeValidationLog.Print("Runtime package validation passed") return nil } - -// collectPackagesFromWorkflow is a generic helper that collects packages from workflow data -// using the provided extractor function. It deduplicates packages and optionally extracts -// from MCP tool configurations when toolCommand is provided. -func collectPackagesFromWorkflow( - workflowData *WorkflowData, - extractor func(string) []string, - toolCommand string, -) []string { - runtimeValidationLog.Printf("Collecting packages from workflow: toolCommand=%s", toolCommand) - var packages []string - seen := make(map[string]bool) - - // Extract from custom steps - if workflowData.CustomSteps != "" { - pkgs := extractor(workflowData.CustomSteps) - for _, pkg := range pkgs { - if !seen[pkg] { - packages = append(packages, pkg) - seen[pkg] = true - } - } - } - - // Extract from MCP server configurations (if toolCommand is provided) - if toolCommand != "" && workflowData.Tools != nil { - for _, toolConfig := range workflowData.Tools { - // Handle structured MCP config with command and args fields - if config, ok := toolConfig.(map[string]any); ok { - if command, hasCommand := config["command"]; hasCommand { - if cmdStr, ok := command.(string); ok && cmdStr == toolCommand { - // Extract package from args, skipping flags - if args, hasArgs := config["args"]; hasArgs { - if argsSlice, ok := args.([]any); ok { - for _, arg := range argsSlice { - if pkgStr, ok := arg.(string); ok { - // Skip flags (arguments starting with - or --) - if !strings.HasPrefix(pkgStr, "-") && !seen[pkgStr] { - packages = append(packages, pkgStr) - seen[pkgStr] = true - break // Only take the first non-flag argument - } - } - } - } - } - } - } - } else if cmdStr, ok := toolConfig.(string); ok { - // Handle string-format MCP tool (e.g., "npx -y package") - // Use the extractor function to parse the command string - pkgs := extractor(cmdStr) - for _, pkg := range pkgs { - if !seen[pkg] { - packages = append(packages, pkg) - seen[pkg] = true - } - } - } - } - } - - runtimeValidationLog.Printf("Collected %d unique packages", len(packages)) - return packages -} diff --git a/pkg/workflow/safe_outputs_config_helpers.go b/pkg/workflow/safe_outputs_config_helpers.go index e2a2b2cb3ad..c76d4ddfdb6 100644 --- a/pkg/workflow/safe_outputs_config_helpers.go +++ b/pkg/workflow/safe_outputs_config_helpers.go @@ -1,10 +1,13 @@ package workflow import ( + "encoding/json" "maps" + "sort" "strings" "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/stringutil" ) // ======================================== @@ -224,3 +227,142 @@ func generateTargetConfigWithRepos(targetConfig SafeOutputTargetConfig, max *str return config } + +// computeEffectivePRCheckoutToken returns the token to use for PR checkout and git operations. +// Applies the following precedence (highest to lowest): +// 1. Per-config PAT: create-pull-request.github-token +// 2. Per-config PAT: push-to-pull-request-branch.github-token +// 3. GitHub App minted token (if a github-app is configured) +// 4. safe-outputs level PAT: safe-outputs.github-token +// 5. Default fallback via getEffectiveSafeOutputGitHubToken() +// +// Per-config tokens take precedence over the GitHub App so that individual operations +// can override the app-wide authentication with a dedicated PAT when needed. +// +// This is used by buildSharedPRCheckoutSteps and buildHandlerManagerStep to ensure consistent token handling. +// +// Returns: +// - token: the effective GitHub Actions token expression to use for git operations +// - isCustom: true when a custom non-default token was explicitly configured (per-config PAT, app, or safe-outputs PAT) +func computeEffectivePRCheckoutToken(safeOutputs *SafeOutputsConfig) (token string, isCustom bool) { + if safeOutputs == nil { + return getEffectiveSafeOutputGitHubToken(""), false + } + + // Per-config PAT tokens take highest precedence (overrides GitHub App) + var createPRToken string + if safeOutputs.CreatePullRequests != nil { + createPRToken = safeOutputs.CreatePullRequests.GitHubToken + } + var pushToPRBranchToken string + if safeOutputs.PushToPullRequestBranch != nil { + pushToPRBranchToken = safeOutputs.PushToPullRequestBranch.GitHubToken + } + perConfigToken := createPRToken + if perConfigToken == "" { + perConfigToken = pushToPRBranchToken + } + if perConfigToken != "" { + return getEffectiveSafeOutputGitHubToken(perConfigToken), true + } + + // GitHub App token takes precedence over the safe-outputs level PAT + if safeOutputs.GitHubApp != nil { + //nolint:gosec // G101: False positive - this is a GitHub Actions expression template placeholder, not a hardcoded credential + return "${{ steps.safe-outputs-app-token.outputs.token }}", true + } + + // safe-outputs level PAT as final custom option + if safeOutputs.GitHubToken != "" { + return getEffectiveSafeOutputGitHubToken(safeOutputs.GitHubToken), true + } + + // No custom token - fall back to default + return getEffectiveSafeOutputGitHubToken(""), false +} + +// computeEffectiveProjectToken computes the effective project token using the precedence: +// 1. Per-config token (e.g., from update-project, create-project-status-update) +// 2. Safe-outputs level token +// 3. Magic secret fallback via getEffectiveProjectGitHubToken() +func computeEffectiveProjectToken(perConfigToken string, safeOutputsToken string) string { + token := perConfigToken + if token == "" { + token = safeOutputsToken + } + return getEffectiveProjectGitHubToken(token) +} + +// computeProjectURLAndToken computes the project URL and token from the various project-related +// safe-output configurations. Priority order: update-project > create-project-status-update > create-project. +// Returns the project URL (may be empty for create-project) and the effective token. +func computeProjectURLAndToken(safeOutputs *SafeOutputsConfig) (projectURL, projectToken string) { + if safeOutputs == nil { + return "", "" + } + + safeOutputsToken := safeOutputs.GitHubToken + + // Check update-project first (highest priority) + if safeOutputs.UpdateProjects != nil && safeOutputs.UpdateProjects.Project != "" { + projectURL = safeOutputs.UpdateProjects.Project + projectToken = computeEffectiveProjectToken(safeOutputs.UpdateProjects.GitHubToken, safeOutputsToken) + safeOutputsConfigGenLog.Printf("Setting GH_AW_PROJECT_URL from update-project config: %s", projectURL) + safeOutputsConfigGenLog.Printf("Setting GH_AW_PROJECT_GITHUB_TOKEN from update-project config") + return + } + + // Check create-project-status-update second + if safeOutputs.CreateProjectStatusUpdates != nil && safeOutputs.CreateProjectStatusUpdates.Project != "" { + projectURL = safeOutputs.CreateProjectStatusUpdates.Project + projectToken = computeEffectiveProjectToken(safeOutputs.CreateProjectStatusUpdates.GitHubToken, safeOutputsToken) + safeOutputsConfigGenLog.Printf("Setting GH_AW_PROJECT_URL from create-project-status-update config: %s", projectURL) + safeOutputsConfigGenLog.Printf("Setting GH_AW_PROJECT_GITHUB_TOKEN from create-project-status-update config") + return + } + + // Check create-project for token even if no URL is set (create-project doesn't have a project URL field) + // This ensures GH_AW_PROJECT_GITHUB_TOKEN is set when create-project is configured + if safeOutputs.CreateProjects != nil { + projectToken = computeEffectiveProjectToken(safeOutputs.CreateProjects.GitHubToken, safeOutputsToken) + safeOutputsConfigGenLog.Printf("Setting GH_AW_PROJECT_GITHUB_TOKEN from create-project config") + } + + return +} + +// buildCustomSafeOutputJobsJSON builds a JSON mapping of custom safe output job names to empty +// strings, for use in the GH_AW_SAFE_OUTPUT_JOBS env var of the handler manager step. +// This allows the handler manager to silently skip messages handled by custom safe-output job +// steps rather than reporting them as "No handler loaded for message type '...'". +func buildCustomSafeOutputJobsJSON(data *WorkflowData) string { + if data.SafeOutputs == nil || len(data.SafeOutputs.Jobs) == 0 { + return "" + } + + // Build mapping of normalized job names to empty strings (no URL output for custom jobs) + jobMapping := make(map[string]string, len(data.SafeOutputs.Jobs)) + for jobName := range data.SafeOutputs.Jobs { + normalizedName := stringutil.NormalizeSafeOutputIdentifier(jobName) + jobMapping[normalizedName] = "" + } + + // Sort keys for deterministic output + keys := make([]string, 0, len(jobMapping)) + for k := range jobMapping { + keys = append(keys, k) + } + sort.Strings(keys) + + ordered := make(map[string]string, len(keys)) + for _, k := range keys { + ordered[k] = jobMapping[k] + } + + jsonBytes, err := json.Marshal(ordered) + if err != nil { + safeOutputsConfigGenLog.Printf("Warning: failed to marshal custom safe output jobs: %v", err) + return "" + } + return string(jsonBytes) +} diff --git a/pkg/workflow/strings.go b/pkg/workflow/strings.go index a157f687e50..27b63d68ac1 100644 --- a/pkg/workflow/strings.go +++ b/pkg/workflow/strings.go @@ -76,8 +76,10 @@ package workflow import ( + "fmt" "regexp" "slices" + "strconv" "strings" "github.com/github/gh-aw/pkg/logger" @@ -285,3 +287,66 @@ func GenerateHeredocDelimiter(name string) string { } return "GH_AW_" + strings.ToUpper(name) + "_EOF" } + +// ConvertToInt safely converts any to int +func ConvertToInt(val any) int { + switch v := val.(type) { + case int: + return v + case int64: + return int(v) + case float64: + intVal := int(v) + // Warn if truncation occurs (value has fractional part) + if v != float64(intVal) { + stringsLog.Printf("Float value %.2f truncated to integer %d", v, intVal) + } + return intVal + case string: + if i, err := strconv.Atoi(v); err == nil { + return i + } + } + return 0 +} + +// ConvertToFloat safely converts any to float64 +func ConvertToFloat(val any) float64 { + switch v := val.(type) { + case float64: + return v + case int: + return float64(v) + case int64: + return float64(v) + case string: + if f, err := strconv.ParseFloat(v, 64); err == nil { + return f + } + } + return 0 +} + +// PrettifyToolName removes "mcp__" prefix and formats tool names nicely +func PrettifyToolName(toolName string) string { + // Handle MCP tools: "mcp__github__search_issues" -> "github_search_issues" + // Avoid colons and leave underscores as-is + if strings.HasPrefix(toolName, "mcp__") { + parts := strings.Split(toolName, "__") + if len(parts) >= 3 { + provider := parts[1] + method := strings.Join(parts[2:], "_") + return fmt.Sprintf("%s_%s", provider, method) + } + // If format is unexpected, just remove the mcp__ prefix + return strings.TrimPrefix(toolName, "mcp__") + } + + // Handle bash specially - keep as "bash" + if strings.ToLower(toolName) == "bash" { + return "bash" + } + + // Return other tool names as-is + return toolName +}