diff --git a/docs/adr/35091-reorganize-workflow-and-parser-packages-by-semantic-clustering.md b/docs/adr/35091-reorganize-workflow-and-parser-packages-by-semantic-clustering.md new file mode 100644 index 00000000000..adc3ebac094 --- /dev/null +++ b/docs/adr/35091-reorganize-workflow-and-parser-packages-by-semantic-clustering.md @@ -0,0 +1,93 @@ +# ADR-35091: Reorganize Workflow and Parser Packages by Semantic Clustering + +**Date**: 2026-05-27 +**Status**: Draft +**Deciders**: pelikhan, Copilot + +--- + +## Part 1 — Narrative (Human-Friendly) + +### Context + +Semantic clustering of 858 non-test Go files surfaced four organizational issues that violate the file-naming and placement conventions established in ADR-27325 and extended by ADR-28282 and ADR-29336. First, `pkg/workflow/compiler_safe_outputs.go` contained six functions whose semantic domains (trigger parsing, default tools, sandbox state) did not match the file's name. Second, the `pkg/workflow` directory had a split-brain naming pattern where some files used the `compiler_safe_outputs_*` prefix and others used `safe_outputs_*` for the same conceptual area, including three thin files with one or fewer functions each. Third, MCP rendering file names mixed `mcp_rendering.go` (a renderer factory) with `mcp_config_builtin.go` (which actually contained renderer functions, not config). Fourth, `pkg/parser/include_processor.go` defined `containsExpression(v any) bool` — a recursive walker over `map[string]any`/`[]any` — that collided in name with `containsExpression(s string) bool` in `pkg/workflow/expression_patterns.go`, which performs a single-string match with different semantics. + +### Decision + +We will reorganize files in `pkg/workflow` and rename the colliding function in `pkg/parser` so that file names accurately reflect contents and exported/unexported names do not collide across packages with divergent semantics. Specifically: (1) move `parseOnSection`, `mergeCommandOtherEvents`, `mergeEventConfig`, and `parseEventTypes` from `compiler_safe_outputs.go` to `trigger_parser.go`; move `applyDefaultTools` to `tools.go`; move `isSandboxEnabled` to `sandbox.go`. (2) Merge the `compiler_safe_outputs_env.go`, `compiler_safe_outputs_config.go`, `compiler_safe_outputs_core.go`, and `compiler_safe_outputs_handlers.go` files into their `safe_outputs_*` counterparts and delete the source files; retain `compiler_safe_outputs_builder.go` as-is. (3) Rename `mcp_rendering.go` to `mcp_renderer_factory.go` and move `renderSafeOutputsMCPConfigWithOptions` and `renderAgenticWorkflowsMCPConfigWithOptions` into a new `mcp_renderer_builtin.go`, leaving `mcp_config_builtin.go` reserved for future type/constant declarations. (4) Rename `containsExpression(v any) bool` in `pkg/parser/include_processor.go` to `frontmatterValueContainsExpression` to make the recursive-over-frontmatter intent explicit and eliminate the cross-package name collision. All changes are intra-package (or rename-only); no public API behavior is altered. + +### Alternatives Considered + +#### Alternative 1: Leave Files As-Is and Document Drift via Comments + +We could leave the files in place and add file-header comments explaining that each file's actual scope diverges from its name. This was rejected because documentation without structural enforcement degrades the same way it did in the cases addressed by ADR-27325, ADR-28282, and ADR-29336 — contributors continue adding new functions to the nearest convenient file, and the gap between file name and contents widens over time. The split-brain `compiler_safe_outputs_*` vs. `safe_outputs_*` naming is exactly the outcome of letting earlier drift accumulate. + +#### Alternative 2: Rename `compiler_safe_outputs.go` to Match Its Contents + +Rather than moving functions out, we could rename `compiler_safe_outputs.go` to a name that matches whatever six-function bag it contains. This was rejected because the functions are not semantically related to each other — they cover trigger parsing, default tools, sandbox state, and safe-jobs merging — so no single accurate name exists. Splitting by domain into existing files (`trigger_parser.go`, `tools.go`, `sandbox.go`) better serves discoverability and matches the precedent set by ADR-29336. + +#### Alternative 3: Keep `containsExpression` Names and Disambiguate by Package Prefix at Call Sites + +We could keep the colliding `containsExpression` names and require callers to use package-qualified references (`parser.containsExpression` vs. `workflow.containsExpression`) for clarity. This was rejected because both functions are unexported and therefore can never be referenced cross-package — the qualification mechanism does not help. The collision only surfaces when reading the code or running cross-package searches, where two same-named functions with different signatures and semantics are actively misleading. A rename is the only way to make the divergent semantics visible at the name level. + +### Consequences + +#### Positive +- `compiler_safe_outputs.go` is reduced to two functions (`mergeSafeJobsFromIncludedConfigs`, `needsGitCommands`), tightly scoped to safe-output-specific compiler glue. +- Trigger parsing, default-tools logic, and sandbox state checks are co-located with the rest of their respective domains (`trigger_parser.go`, `tools.go`, `sandbox.go`), improving discoverability. +- The `compiler_safe_outputs_*` vs. `safe_outputs_*` split-brain is eliminated; safe-outputs code is reachable under a single naming prefix. +- `mcp_renderer_factory.go` and `mcp_renderer_builtin.go` accurately describe their renderer contents; `mcp_config_builtin.go` is reserved for future config types and no longer misleads readers. +- Cross-package `grep containsExpression` now returns one definition per distinct semantic, making static analysis and code review more reliable. +- Reinforces the semantic file-organization convention from ADR-27325, ADR-28282, and ADR-29336. + +#### Negative +- `git blame` on the moved functions points at the relocation commit rather than original authorship without `--follow`, increasing the friction of historical investigations. +- Open pull requests touching `compiler_safe_outputs_env.go`, `compiler_safe_outputs_config.go`, `compiler_safe_outputs_core.go`, or `compiler_safe_outputs_handlers.go` will incur merge conflicts that require rebasing onto the new file layout. +- Reviewers must read the diff with both rename and move semantics in mind; large patches with `additions ≈ deletions` are harder to scan than equivalent renames in a single git operation. + +#### Neutral +- No public API surface changes; all moved or renamed identifiers retain their signatures. +- `pkg/workflow` file count changes (net reduction from merging three thin files; net increase of one from splitting `mcp_renderer_builtin.go` out of `mcp_config_builtin.go`). +- The unexported `containsExpression` symbol in `pkg/parser` is gone; any future references must use `frontmatterValueContainsExpression`. + +--- + +## Part 2 — Normative Specification (RFC 2119) + +> The key words **MUST**, **MUST NOT**, **REQUIRED**, **SHALL**, **SHALL NOT**, **SHOULD**, **SHOULD NOT**, **RECOMMENDED**, **MAY**, and **OPTIONAL** in this section are to be interpreted as described in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). + +### Trigger Parsing (`pkg/workflow`) + +1. `parseOnSection`, `mergeCommandOtherEvents`, `mergeEventConfig`, and `parseEventTypes` **MUST** reside in `pkg/workflow/trigger_parser.go`. +2. New functions that parse, normalize, or merge the workflow `on:` section or its sub-events **MUST** be added to `trigger_parser.go` and **MUST NOT** be placed in `compiler_safe_outputs.go`. + +### Default Tools and Sandbox State (`pkg/workflow`) + +1. `applyDefaultTools` **MUST** reside in `pkg/workflow/tools.go`, adjacent to its caller `applyDefaults`. +2. `isSandboxEnabled` **MUST** reside in `pkg/workflow/sandbox.go`, adjacent to its callers in `firewall.go` and `strict_mode_permissions_validation.go`. +3. New default-tool-application functions **MUST** be added to `tools.go`; new sandbox state predicates **MUST** be added to `sandbox.go`. Neither **SHALL** be placed in `compiler_safe_outputs.go`. + +### Safe-Outputs File Naming (`pkg/workflow`) + +1. New files whose primary contents pertain to safe-outputs configuration, env wiring, core types, or handler registration **MUST** use the `safe_outputs_*` prefix. +2. New files **MUST NOT** use the `compiler_safe_outputs_*` prefix, except for `compiler_safe_outputs.go` and `compiler_safe_outputs_builder.go`, which are retained for backwards compatibility with their current scope. +3. The deleted files `compiler_safe_outputs_env.go`, `compiler_safe_outputs_config.go`, `compiler_safe_outputs_core.go`, and `compiler_safe_outputs_handlers.go` **MUST NOT** be reintroduced; their previous contents now reside in the corresponding `safe_outputs_*` files. + +### MCP Rendering File Naming (`pkg/workflow`) + +1. `mcp_renderer_factory.go` **MUST** contain the renderer factory previously located in `mcp_rendering.go`; the file name `mcp_rendering.go` **MUST NOT** be reintroduced. +2. `renderSafeOutputsMCPConfigWithOptions` and `renderAgenticWorkflowsMCPConfigWithOptions` **MUST** reside in `mcp_renderer_builtin.go`. +3. `mcp_config_builtin.go` **MUST NOT** contain renderer functions; its scope **MUST** be limited to type or constant declarations for built-in MCP configuration. + +### Cross-Package Function Naming (`pkg/parser` and `pkg/workflow`) + +1. The recursive frontmatter walker in `pkg/parser/include_processor.go` **MUST** be named `frontmatterValueContainsExpression`. +2. New unexported helper functions **MUST NOT** share an identifier with an unexported function in another `gh-aw` package when the two functions have divergent signatures or semantics, where this would mislead cross-package code search or review. + +### Conformance + +An implementation is considered conformant with this ADR if it satisfies all **MUST** and **MUST NOT** requirements above. Failure to meet any **MUST** or **MUST NOT** requirement — in particular, reintroducing the deleted `compiler_safe_outputs_*` files, placing trigger-parsing or sandbox-state functions back into `compiler_safe_outputs.go`, restoring the `mcp_rendering.go` filename, or restoring `containsExpression` as the name of the parser-side recursive walker — constitutes non-conformance. + +--- + +*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/26492354588) workflow. The PR author must review, complete, and finalize this document before the PR can merge.* diff --git a/pkg/parser/include_processor.go b/pkg/parser/include_processor.go index bb027f9ba3e..78fefd800c7 100644 --- a/pkg/parser/include_processor.go +++ b/pkg/parser/include_processor.go @@ -346,21 +346,21 @@ func extractIncludedMarkdownContent(filePath, sectionName string, content []byte // Validation of such files is deferred to avoid false-positive schema warnings. func frontmatterContainsExpressions(m map[string]any) bool { for _, v := range m { - if containsExpression(v) { + if frontmatterValueContainsExpression(v) { return true } } return false } -func containsExpression(v any) bool { +func frontmatterValueContainsExpression(v any) bool { switch val := v.(type) { case string: return strings.Contains(val, "${{") case map[string]any: return frontmatterContainsExpressions(val) case []any: - return slices.ContainsFunc(val, containsExpression) + return slices.ContainsFunc(val, frontmatterValueContainsExpression) } return false } diff --git a/pkg/workflow/compiler_safe_outputs.go b/pkg/workflow/compiler_safe_outputs.go index 1731aadab92..d63f86d864d 100644 --- a/pkg/workflow/compiler_safe_outputs.go +++ b/pkg/workflow/compiler_safe_outputs.go @@ -2,352 +2,13 @@ package workflow import ( "encoding/json" - "errors" "fmt" - "maps" - "path/filepath" - "strings" - "github.com/github/gh-aw/pkg/constants" "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/parser" - "github.com/goccy/go-yaml" ) var compilerSafeOutputsLog = logger.New("workflow:compiler_safe_outputs") -func mergeCommandOtherEvents(existing map[string]any, incoming map[string]any) map[string]any { - if len(existing) == 0 { - return incoming - } - if len(incoming) == 0 { - return existing - } - merged := maps.Clone(existing) - for eventName, incomingValue := range incoming { - if existingValue, hasExisting := merged[eventName]; hasExisting { - merged[eventName] = mergeEventConfig(existingValue, incomingValue) - continue - } - merged[eventName] = incomingValue - } - return merged -} - -func mergeEventConfig(existing any, incoming any) any { - existingMap, existingOK := existing.(map[string]any) - incomingMap, incomingOK := incoming.(map[string]any) - if !existingOK || !incomingOK { - return incoming - } - merged := maps.Clone(existingMap) - maps.Copy(merged, incomingMap) - - existingTypes, existingTypesOK := parseEventTypes(existingMap["types"]) - incomingTypes, incomingTypesOK := parseEventTypes(incomingMap["types"]) - if existingTypesOK && incomingTypesOK { - seen := make(map[string]bool, safeAllocationCapacity(len(existingTypes), len(incomingTypes))) - combined := make([]string, 0, safeAllocationCapacity(len(existingTypes), len(incomingTypes))) - for _, eventType := range existingTypes { - if !seen[eventType] { - seen[eventType] = true - combined = append(combined, eventType) - } - } - for _, eventType := range incomingTypes { - if !seen[eventType] { - seen[eventType] = true - combined = append(combined, eventType) - } - } - merged["types"] = combined - } - - return merged -} - -func parseEventTypes(value any) ([]string, bool) { - switch typed := value.(type) { - case []string: - return typed, true - case []any: - out := make([]string, 0, len(typed)) - for _, entry := range typed { - entryStr, ok := entry.(string) - if !ok { - return nil, false - } - out = append(out, entryStr) - } - return out, true - default: - return nil, false - } -} - -// parseOnSection handles parsing of the "on" section from frontmatter, extracting command triggers, -// reactions, and stop-after configurations while detecting conflicts with other event types. -func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *WorkflowData, markdownPath string) error { - compilerSafeOutputsLog.Printf("Parsing on section: workflow=%s, markdownPath=%s", workflowData.Name, markdownPath) - // Check if "slash_command" or "command" (deprecated) is used as a trigger in the "on" section - // Also extract "reaction" from the "on" section - var hasCommand bool - var hasLabelCommand bool - var hasReaction bool - var hasStopAfter bool - var hasStatusComment bool - var otherEvents map[string]any - - // Use cached On field from ParsedFrontmatter if available, otherwise fall back to map access - var onValue any - var exists bool - if workflowData.ParsedFrontmatter != nil && workflowData.ParsedFrontmatter.On != nil { - onValue = workflowData.ParsedFrontmatter.On - exists = true - } else { - onValue, exists = frontmatter["on"] - } - - if exists { - // Check for new format: on.slash_command/on.command and on.reaction - if onMap, ok := onValue.(map[string]any); ok { - // Check for stop-after in the on section - if _, hasStopAfterKey := onMap["stop-after"]; hasStopAfterKey { - hasStopAfter = true - } - - // Extract reaction from on section - if reactionValue, hasReactionField := onMap["reaction"]; hasReactionField { - hasReaction = true - reactionStr, reactionIssues, reactionPullRequests, reactionDiscussions, err := parseReactionConfig(reactionValue) - if err != nil { - return err - } - // Validate reaction value - if !isValidReaction(reactionStr) { - return fmt.Errorf("invalid reaction value '%s': must be one of %v", reactionStr, getValidReactions()) - } - // Set AIReaction even if it's "none" - "none" explicitly disables reactions - workflowData.AIReaction = reactionStr - workflowData.ReactionIssues = reactionIssues - workflowData.ReactionPullRequests = reactionPullRequests - workflowData.ReactionDiscussions = reactionDiscussions - } - - // Extract status-comment from on section - if statusCommentValue, hasStatusCommentField := onMap["status-comment"]; hasStatusCommentField { - hasStatusComment = true - if statusCommentBool, ok := statusCommentValue.(bool); ok { - workflowData.StatusComment = &statusCommentBool - compilerSafeOutputsLog.Printf("status-comment set to: %v", statusCommentBool) - } else if statusCommentMap, ok := statusCommentValue.(map[string]any); ok { - statusCommentIssues := true - if issuesValue, hasIssues := statusCommentMap["issues"]; hasIssues { - issuesBool, ok := issuesValue.(bool) - if !ok { - return fmt.Errorf("status-comment.issues must be a boolean value, got %T", issuesValue) - } - statusCommentIssues = issuesBool - } - - statusCommentPullRequests := true - if pullRequestsValue, hasPullRequests := statusCommentMap["pull-requests"]; hasPullRequests { - pullRequestsBool, ok := pullRequestsValue.(bool) - if !ok { - return fmt.Errorf("status-comment.pull-requests must be a boolean value, got %T", pullRequestsValue) - } - statusCommentPullRequests = pullRequestsBool - } - - statusCommentDiscussions := true - if discussionsValue, hasDiscussions := statusCommentMap["discussions"]; hasDiscussions { - discussionsBool, ok := discussionsValue.(bool) - if !ok { - return fmt.Errorf("status-comment.discussions must be a boolean value, got %T", discussionsValue) - } - statusCommentDiscussions = discussionsBool - } - - statusCommentEnabled := true - workflowData.StatusComment = &statusCommentEnabled - workflowData.StatusCommentIssues = &statusCommentIssues - workflowData.StatusCommentPullRequests = &statusCommentPullRequests - workflowData.StatusCommentDiscussions = &statusCommentDiscussions - if !statusCommentIssues && !statusCommentPullRequests && !statusCommentDiscussions { - return errors.New("status-comment object requires at least one target to be enabled (issues, pull-requests, or discussions)") - } - compilerSafeOutputsLog.Printf( - "status-comment object set: issues=%v pullRequests=%v discussions=%v", - statusCommentIssues, - statusCommentPullRequests, - statusCommentDiscussions, - ) - } else { - return fmt.Errorf("status-comment must be a boolean or object value, got %T", statusCommentValue) - } - } - - // Extract lock-for-agent from on.issues section - if issuesValue, hasIssues := onMap["issues"]; hasIssues { - if issuesMap, ok := issuesValue.(map[string]any); ok { - if lockForAgent, hasLockForAgent := issuesMap["lock-for-agent"]; hasLockForAgent { - if lockBool, ok := lockForAgent.(bool); ok { - workflowData.LockForAgent = lockBool - compilerSafeOutputsLog.Printf("lock-for-agent enabled for issues: %v", lockBool) - } - } - } - } - - // Extract lock-for-agent from on.issue_comment section - if issueCommentValue, hasIssueComment := onMap["issue_comment"]; hasIssueComment { - if issueCommentMap, ok := issueCommentValue.(map[string]any); ok { - if lockForAgent, hasLockForAgent := issueCommentMap["lock-for-agent"]; hasLockForAgent { - if lockBool, ok := lockForAgent.(bool); ok { - workflowData.LockForAgent = lockBool - compilerSafeOutputsLog.Printf("lock-for-agent enabled for issue_comment: %v", lockBool) - } - } - } - } - - if _, hasSlashCommandKey := onMap["slash_command"]; hasSlashCommandKey { - hasCommand = true - // Set default command to filename if not specified in the command section - if len(workflowData.Command) == 0 { - baseName := strings.TrimSuffix(filepath.Base(markdownPath), ".md") - workflowData.Command = []string{baseName} - } - // In centralized mode slash_command no longer compiles broad comment listeners, - // so slash/non-slash event co-existence is allowed. - if !workflowData.CommandCentralized { - // Check for conflicting events (but allow issues/pull_request with non-conflicting types: labeled/unlabeled/ready_for_review) - conflictingEvents := []string{"issues", "issue_comment", "pull_request", "pull_request_review_comment"} - for _, eventName := range conflictingEvents { - if eventValue, hasConflict := onMap[eventName]; hasConflict { - // Special case: allow issues/pull_request with non-conflicting types - if (eventName == "issues" || eventName == "pull_request") && parser.IsNonConflictingCommandEvent(eventValue) { - continue // Allow this - it doesn't conflict with command triggers - } - return fmt.Errorf("cannot use 'slash_command' with '%s' in the same workflow", eventName) - } - } - } - - // Clear the On field so applyDefaults will handle command trigger generation - workflowData.On = "" - } else if _, hasCommandKey := onMap["command"]; hasCommandKey { - hasCommand = true - // Set default command to filename if not specified in the command section - if len(workflowData.Command) == 0 { - baseName := strings.TrimSuffix(filepath.Base(markdownPath), ".md") - workflowData.Command = []string{baseName} - } - // Check for conflicting events (but allow issues/pull_request with non-conflicting types: labeled/unlabeled/ready_for_review) - conflictingEvents := []string{"issues", "issue_comment", "pull_request", "pull_request_review_comment"} - for _, eventName := range conflictingEvents { - if eventValue, hasConflict := onMap[eventName]; hasConflict { - // Special case: allow issues/pull_request with non-conflicting types - if (eventName == "issues" || eventName == "pull_request") && parser.IsNonConflictingCommandEvent(eventValue) { - continue // Allow this - it doesn't conflict with command triggers - } - return fmt.Errorf("cannot use 'command' with '%s' in the same workflow", eventName) - } - } - - // Clear the On field so applyDefaults will handle command trigger generation - workflowData.On = "" - } - - // Detect label_command trigger - if _, hasLabelCommandKey := onMap["label_command"]; hasLabelCommandKey { - hasLabelCommand = true - // Set default label names from WorkflowData if already populated by extractLabelCommandConfig - if len(workflowData.LabelCommand) == 0 { - // extractLabelCommandConfig has not been called yet or returned nothing; - // set a placeholder so applyDefaults knows this is a label-command workflow. - // The actual label names will be extracted from the frontmatter in applyDefaults - // via extractLabelCommandConfig which was called in parseOnSectionRaw. - baseName := strings.TrimSuffix(filepath.Base(markdownPath), ".md") - workflowData.LabelCommand = []string{baseName} - } - // In decentralized mode label_command no longer compiles direct labeled listeners, - // so label/non-label event co-existence is allowed. - if !workflowData.LabelCommandDecentralized { - // Validate: existing issues/pull_request/discussion triggers that have non-label types - // would be silently overridden by the label_command generation. Require label-only types - // (labeled/unlabeled) so the merge is deterministic and user config is not lost. - labelConflictingEvents := []string{"issues", "pull_request", "discussion"} - for _, eventName := range labelConflictingEvents { - if eventValue, hasConflict := onMap[eventName]; hasConflict { - if !parser.IsLabelOnlyEvent(eventValue) { - return fmt.Errorf("cannot use 'label_command' with '%s' trigger (non-label types); use only labeled/unlabeled types or remove this trigger", eventName) - } - } - } - } - // Clear the On field so applyDefaults will handle label-command trigger generation - workflowData.On = "" - } - - // Extract other (non-conflicting) events excluding slash_command, command, label_command, reaction, status-comment, and stop-after - otherEvents = excludeMapKeys(onMap, "slash_command", "command", "label_command", "reaction", "status-comment", "stop-after", "github-token", "github-app", "needs") - } - } - - // Clear command field if no command trigger was found - if !hasCommand { - workflowData.Command = nil - } - - // Clear label-command field if no label_command trigger was found - if !hasLabelCommand { - workflowData.LabelCommand = nil - workflowData.LabelCommandEvents = nil - workflowData.LabelCommandDecentralized = false - } - // Auto-enable "eyes" reaction for slash_command/label_command (and deprecated command) triggers if no explicit reaction was specified - if (hasCommand || hasLabelCommand) && !hasReaction && workflowData.AIReaction == "" { - workflowData.AIReaction = "eyes" - } - - // Auto-enable status-comment for slash_command/label_command (and deprecated command) triggers if not explicitly set - if (hasCommand || hasLabelCommand) && !hasStatusComment && workflowData.StatusComment == nil { - trueVal := true - workflowData.StatusComment = &trueVal - } - - // Store other events for merging in applyDefaults - if hasCommand && len(otherEvents) > 0 { - // We'll store this and handle it in applyDefaults - workflowData.On = "" // This will trigger command handling in applyDefaults - workflowData.CommandOtherEvents = mergeCommandOtherEvents(workflowData.CommandOtherEvents, otherEvents) - } else if hasLabelCommand && len(otherEvents) > 0 { - // Store other events for label-command merging in applyDefaults - workflowData.On = "" // This will trigger label-command handling in applyDefaults - workflowData.LabelCommandOtherEvents = otherEvents - } else if (hasReaction || hasStopAfter || hasStatusComment) && len(otherEvents) > 0 { - // Only re-marshal the "on" if we have to - onEventsYAML, err := yaml.Marshal(map[string]any{"on": otherEvents}) - if err == nil { - yamlStr := strings.TrimSuffix(string(onEventsYAML), "\n") - // Post-process YAML to ensure cron expressions are quoted - yamlStr = parser.QuoteCronExpressions(yamlStr) - // Apply comment processing to filter fields (draft, forks, names) - yamlStr = c.commentOutProcessedFieldsInOnSection(yamlStr, frontmatter) - // Add zizmor ignore comment if workflow_run trigger is present - yamlStr = c.addZizmorIgnoreForWorkflowRun(yamlStr) - // Keep "on" quoted as it's a YAML boolean keyword - workflowData.On = yamlStr - } else { - // Fallback to extracting the original on field (this will include reaction but shouldn't matter for compilation) - workflowData.On = c.extractTopLevelYAMLSection(frontmatter, "on") - } - } - - return nil -} - // mergeSafeJobsFromIncludedConfigs merges safe-jobs from included safe-outputs configurations func (c *Compiler) mergeSafeJobsFromIncludedConfigs(topSafeJobs map[string]*SafeJobConfig, includedConfigs []string) (map[string]*SafeJobConfig, error) { compilerSafeOutputsLog.Printf("Merging safe-jobs from included configs: includedCount=%d", len(includedConfigs)) @@ -383,194 +44,6 @@ func (c *Compiler) mergeSafeJobsFromIncludedConfigs(topSafeJobs map[string]*Safe return result, nil } -// applyDefaultTools adds default read-only GitHub MCP tools, creating github tool if not present -func (c *Compiler) applyDefaultTools(tools map[string]any, safeOutputs *SafeOutputsConfig, sandboxConfig *SandboxConfig, networkPermissions *NetworkPermissions) map[string]any { - compilerSafeOutputsLog.Printf("Applying default tools: existingToolCount=%d", len(tools)) - // Always apply default GitHub tools (create github section if it doesn't exist) - - if tools == nil { - tools = make(map[string]any) - } - - // Get existing github tool configuration - githubTool := tools["github"] - - // Check if github is explicitly disabled (github: false) - if githubTool == false { - // Remove the github tool entirely when set to false - delete(tools, "github") - } else { - // Process github tool configuration - var githubConfig map[string]any - - if toolConfig, ok := githubTool.(map[string]any); ok { - githubConfig = make(map[string]any) - maps.Copy(githubConfig, toolConfig) - } else { - githubConfig = make(map[string]any) - } - - // Parse the existing GitHub tool configuration for type safety - parsedConfig := parseGitHubTool(githubTool) - - // Create a set of existing tools for efficient lookup - existingToolsSet := make(map[string]bool) - if parsedConfig != nil { - for _, tool := range parsedConfig.Allowed { - existingToolsSet[string(tool)] = true - } - } - - // Only set allowed tools if explicitly configured - // Don't add default tools - let the MCP server use all available tools - if len(existingToolsSet) > 0 { - // Convert back to []any for the map - existingAllowed := make([]any, 0, len(parsedConfig.Allowed)) - for _, tool := range parsedConfig.Allowed { - existingAllowed = append(existingAllowed, string(tool)) - } - githubConfig["allowed"] = existingAllowed - } - tools["github"] = githubConfig - } - - // Enable edit and bash tools by default when sandbox is enabled - // The sandbox is enabled when: - // 1. Explicitly configured via sandbox.agent (awf) - // 2. Auto-enabled by firewall default enablement (when network restrictions are present) - if isSandboxEnabled(sandboxConfig, networkPermissions) { - compilerSafeOutputsLog.Print("Sandbox enabled, applying default edit and bash tools") - - // Add edit tool if not present - if _, exists := tools["edit"]; !exists { - tools["edit"] = true - compilerSafeOutputsLog.Print("Added edit tool (sandbox enabled)") - } - - // Add bash tool with wildcard if not present - if _, exists := tools["bash"]; !exists { - tools["bash"] = []any{"*"} - compilerSafeOutputsLog.Print("Added bash tool with wildcard (sandbox enabled)") - } - } - - // Add Git commands and file editing tools when safe-outputs includes create-pull-request or push-to-pull-request-branch - if safeOutputs != nil && needsGitCommands(safeOutputs) { - - // Add edit tool with null value - if _, exists := tools["edit"]; !exists { - tools["edit"] = nil - } - gitCommands := []any{ - "git checkout:*", - "git branch:*", - "git switch:*", - "git add:*", - "git rm:*", - "git commit:*", - "git merge:*", - "git status", - } - - // Add bash tool with Git commands if not already present - if _, exists := tools["bash"]; !exists { - // bash tool doesn't exist, add it with Git commands - tools["bash"] = gitCommands - } else { - // bash tool exists, merge Git commands with existing commands - existingBash := tools["bash"] - if existingCommands, ok := existingBash.([]any); ok { - // Convert existing commands to strings for comparison - existingSet := make(map[string]bool) - for _, cmd := range existingCommands { - if cmdStr, ok := cmd.(string); ok { - existingSet[cmdStr] = true - // If we see :* or *, all bash commands are already allowed - if cmdStr == ":*" || cmdStr == "*" { - // Don't add specific Git commands since all are already allowed - goto bashComplete - } - } - } - - // Add Git commands that aren't already present - newCommands := append([]any(nil), existingCommands...) - for _, gitCmd := range gitCommands { - if gitCmdStr, ok := gitCmd.(string); ok { - if !existingSet[gitCmdStr] { - newCommands = append(newCommands, gitCmd) - } - } - } - tools["bash"] = newCommands - } else if existingBash == false { - // bash: false was set, but git commands are required for PR operations - // Override with git commands only (minimum needed for PR functionality) - compilerSafeOutputsLog.Print("Overriding bash: false with git commands (required for PR operations)") - tools["bash"] = gitCommands - } else if existingBash == nil { - _ = existingBash // Keep the nil value as-is - } - } - bashComplete: - } - - // Add default bash commands when bash is enabled but no specific commands are provided - // This runs after git commands logic, so it only applies when git commands weren't added - // Behavior: - // - bash: true → All commands allowed (converted to ["*"]) - // - bash: false → Tool disabled (removed from tools), unless git commands were needed for PR operations - // - bash: nil → Add default commands - // - bash: [] → No commands (empty array means no tools allowed) - // - bash: ["cmd1", "cmd2"] → Add default commands + specific commands - if bashTool, exists := tools["bash"]; exists { - // Check if bash was left as nil or true after git processing - if bashTool == nil { - // bash is nil - only add defaults if this wasn't processed by git commands - // If git commands were needed, bash would have been set to git commands or left as nil intentionally - if safeOutputs == nil || !needsGitCommands(safeOutputs) { - defaultCommands := make([]any, len(constants.DefaultBashTools)) - for i, cmd := range constants.DefaultBashTools { - defaultCommands[i] = cmd - } - tools["bash"] = defaultCommands - } - } else if bashTool == true { - // bash is true - convert to wildcard (allow all commands) - tools["bash"] = []any{"*"} - } else if bashTool == false { - // bash is false - disable the tool by removing it - delete(tools, "bash") - } else if bashArray, ok := bashTool.([]any); ok { - // bash is an array - merge default commands with custom commands - if len(bashArray) > 0 { - // Create a set to track existing commands to avoid duplicates - existingCommands := make(map[string]bool) - for _, cmd := range bashArray { - if cmdStr, ok := cmd.(string); ok { - existingCommands[cmdStr] = true - } - } - - // Start with default commands (append handles capacity automatically) - var mergedCommands []any - for _, cmd := range constants.DefaultBashTools { - if !existingCommands[cmd] { - mergedCommands = append(mergedCommands, cmd) - } - } - - // Add the custom commands - mergedCommands = append(mergedCommands, bashArray...) - tools["bash"] = mergedCommands - } - // Note: bash with empty array (bash: []) means "no bash tools allowed" and is left as-is - } - } - - return tools -} - // needsGitCommands checks if safe outputs configuration requires Git commands func needsGitCommands(safeOutputs *SafeOutputsConfig) bool { if safeOutputs == nil { @@ -578,37 +51,3 @@ func needsGitCommands(safeOutputs *SafeOutputsConfig) bool { } return safeOutputs.CreatePullRequests != nil || safeOutputs.PushToPullRequestBranch != nil } - -// isSandboxEnabled checks if the sandbox is enabled (either explicitly or auto-enabled) -// Returns true when: -// - sandbox.agent is explicitly set to awf -// - Firewall is auto-enabled (networkPermissions.Firewall is set and enabled) -// Returns false when: -// - sandbox.agent is false (explicitly disabled) -// - No sandbox configuration and no auto-enabled firewall -func isSandboxEnabled(sandboxConfig *SandboxConfig, networkPermissions *NetworkPermissions) bool { - // Check if sandbox.agent is explicitly disabled - if sandboxConfig != nil && sandboxConfig.Agent != nil && sandboxConfig.Agent.Disabled { - return false - } - - // Check if sandbox.agent is explicitly configured with a type - if sandboxConfig != nil && sandboxConfig.Agent != nil { - agentType := getAgentType(sandboxConfig.Agent) - if isSupportedSandboxType(agentType) { - return true - } - } - - // Check legacy top-level Type field (deprecated but still supported) - if sandboxConfig != nil && isSupportedSandboxType(sandboxConfig.Type) { - return true - } - - // Check if firewall is auto-enabled (AWF) - if networkPermissions != nil && networkPermissions.Firewall != nil && networkPermissions.Firewall.Enabled { - return true - } - - return false -} diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go deleted file mode 100644 index bd08a02e42f..00000000000 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ /dev/null @@ -1,189 +0,0 @@ -package workflow - -import ( - "encoding/json" - "fmt" - - "github.com/github/gh-aw/pkg/logger" - "github.com/github/gh-aw/pkg/sliceutil" -) - -// ======================================== -// Handler Manager Config Generation -// ======================================== -// -// This file produces the GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG env var consumed -// by the handler manager at runtime, using the handlerRegistry and the fluent -// handlerConfigBuilder API. -// -// The handlerRegistry is the single source of truth for handler keys and field -// contracts. generateSafeOutputsConfig() in safe_outputs_config_generation.go -// derives config.json from this same registry so both consumers stay in sync -// without a separate generation path. -// -// Builder infrastructure (handlerConfigBuilder) lives in compiler_safe_outputs_builder.go. -// Handler registry entries live in compiler_safe_outputs_handlers.go. - -var compilerSafeOutputsConfigLog = logger.New("workflow:compiler_safe_outputs_config") - -func (c *Compiler) addHandlerManagerConfigEnvVar(steps *[]string, data *WorkflowData) { - if data.SafeOutputs == nil { - compilerSafeOutputsConfigLog.Print("No safe-outputs configuration, skipping handler manager config") - return - } - - compilerSafeOutputsConfigLog.Print("Building handler manager configuration for safe-outputs") - // config holds both per-handler configs (keyed by handler name, e.g. "add_comment") and - // global runtime knobs (e.g. "mentions") that safe_output_handler_manager.cjs forwards to - // specific handlers at startup. Handler names are the reserved keys defined in handlerRegistry; - // non-handler keys ("mentions") are documented in safe_outputs_config_generation.go. - config := make(map[string]any) - - // Collect engine-specific manifest files and path prefixes (AgentFileProvider interface). - // These are merged with the global runtime-derived lists so that engine-specific - // instruction files (e.g. CLAUDE.md, .claude/, AGENTS.md) are automatically protected. - extraManifestFiles, extraPathPrefixes := c.getEngineAgentFileInfo(data) - fullManifestFiles := getAllManifestFiles(extraManifestFiles...) - fullPathPrefixes := getProtectedPathPrefixes(extraPathPrefixes...) - - // For workflow_call relay workflows, inject the resolved platform repo and ref into the - // dispatch_workflow handler config so dispatch targets the host repo, not the caller's. - safeOutputs := data.SafeOutputs - if hasWorkflowCallTrigger(data.On) && safeOutputs.DispatchWorkflow != nil { - if safeOutputs.DispatchWorkflow.TargetRepoSlug == "" { - safeOutputs = safeOutputsWithDispatchTargetRepo(safeOutputs, "${{ needs.activation.outputs.target_repo }}") - compilerSafeOutputsConfigLog.Print("Injecting target_repo into dispatch_workflow config for workflow_call relay") - } - if safeOutputs.DispatchWorkflow.TargetRef == "" { - safeOutputs = safeOutputsWithDispatchTargetRef(safeOutputs, "${{ needs.activation.outputs.target_ref }}") - compilerSafeOutputsConfigLog.Print("Injecting target_ref into dispatch_workflow config for workflow_call relay") - } - } - - // Build configuration for each handler using the registry - for handlerName, builder := range handlerRegistry { - handlerConfig := builder(safeOutputs) - // Include handler if: - // 1. It returns a non-nil config (explicitly enabled, even if empty) - // 2. For auto-enabled handlers, include even with empty config - if handlerConfig != nil { - injectCurrentCheckoutPatchWorkspacePath(handlerName, handlerConfig, data) - // Augment protected-files protection with engine-specific files for handlers that use it. - if _, hasProtected := handlerConfig["protected_files"]; hasProtected { - // Extract per-handler exclusions set by the handler builder (sentinel key). - // These are compile-time overrides and must not be forwarded to the runtime. - excludeFiles := ParseStringArrayFromConfig(handlerConfig, "_protected_files_exclude", nil) - delete(handlerConfig, "_protected_files_exclude") - - handlerConfig["protected_files"] = sliceutil.Exclude(fullManifestFiles, excludeFiles...) - filteredPrefixes := sliceutil.Exclude(fullPathPrefixes, excludeFiles...) - if len(filteredPrefixes) > 0 { - handlerConfig["protected_path_prefixes"] = filteredPrefixes - } else { - delete(handlerConfig, "protected_path_prefixes") - } - // Compute which top-level dot-folder prefixes are excluded so the runtime - // dot-folder check can skip them. - if dotFolderExcludes := getDotFolderExcludes(excludeFiles); len(dotFolderExcludes) > 0 { - handlerConfig["protected_dot_folder_excludes"] = dotFolderExcludes - } - } - compilerSafeOutputsConfigLog.Printf("Adding %s handler configuration", handlerName) - config[handlerName] = handlerConfig - } - } - - // Include top-level mentions configuration so the handler manager can pass it to - // markdown-producing handlers that call sanitizeContent with allowed aliases. - if safeOutputs.Mentions != nil { - mentionsCfg := buildMentionsHandlerConfig(safeOutputs.Mentions) - if len(mentionsCfg) > 0 { - config["mentions"] = mentionsCfg - } - } - - // Only add the env var if there are handlers to configure - if len(config) > 0 { - compilerSafeOutputsConfigLog.Printf("Marshaling handler config with %d handlers", len(config)) - configJSON, err := json.Marshal(config) - if err != nil { - consolidatedSafeOutputsLog.Printf("Failed to marshal handler config: %v", err) - return - } - // Escape the JSON for YAML (handle quotes and special chars) - configStr := string(configJSON) - *steps = append(*steps, fmt.Sprintf(" GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: %q\n", configStr)) - compilerSafeOutputsConfigLog.Printf("Added handler config env var: size=%d bytes", len(configStr)) - } else { - compilerSafeOutputsConfigLog.Print("No handlers configured, skipping config env var") - } -} - -// buildMentionsHandlerConfig converts a MentionsConfig into the map format used by -// GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG so safe_output_handler_manager.cjs can pass -// the top-level mentions policy through to mention-aware handlers. -func buildMentionsHandlerConfig(m *MentionsConfig) map[string]any { - cfg := make(map[string]any) - if m.Enabled != nil { - cfg["enabled"] = *m.Enabled - } - if m.AllowTeamMembers != nil { - cfg["allowTeamMembers"] = *m.AllowTeamMembers - } - if m.AllowContext != nil { - cfg["allowContext"] = *m.AllowContext - } - if len(m.Allowed) > 0 { - cfg["allowed"] = m.Allowed - } - if m.Max != nil { - cfg["max"] = *m.Max - } - return cfg -} - -// safeOutputsWithDispatchTargetRepo returns a shallow copy of cfg with the dispatch_workflow -// TargetRepoSlug overridden to targetRepo. Only DispatchWorkflow is deep-copied; all other -// pointer fields remain shared. This avoids mutating the original config. -func safeOutputsWithDispatchTargetRepo(cfg *SafeOutputsConfig, targetRepo string) *SafeOutputsConfig { - dispatchCopy := *cfg.DispatchWorkflow - dispatchCopy.TargetRepoSlug = targetRepo - configCopy := *cfg - configCopy.DispatchWorkflow = &dispatchCopy - return &configCopy -} - -// safeOutputsWithDispatchTargetRef returns a shallow copy of cfg with the dispatch_workflow -// TargetRef overridden to targetRef. Only DispatchWorkflow is deep-copied; all other -// pointer fields remain shared. This avoids mutating the original config. -func safeOutputsWithDispatchTargetRef(cfg *SafeOutputsConfig, targetRef string) *SafeOutputsConfig { - dispatchCopy := *cfg.DispatchWorkflow - dispatchCopy.TargetRef = targetRef - configCopy := *cfg - configCopy.DispatchWorkflow = &dispatchCopy - return &configCopy -} - -// getEngineAgentFileInfo returns the engine-specific manifest filenames and path prefixes -// by type-asserting the active engine to AgentFileProvider. Returns empty slices when -// the engine is not set or does not implement the interface. -func (c *Compiler) getEngineAgentFileInfo(data *WorkflowData) (manifestFiles []string, pathPrefixes []string) { - if data == nil || data.EngineConfig == nil { - return nil, nil - } - engine, err := c.engineRegistry.GetEngine(data.EngineConfig.ID) - if err != nil { - compilerSafeOutputsConfigLog.Printf("Engine lookup failed for %q: %v — skipping agent manifest file injection", data.EngineConfig.ID, err) - return nil, nil - } - if engine == nil { - return nil, nil - } - provider, ok := engine.(AgentFileProvider) - if !ok { - return nil, nil - } - compilerSafeOutputsConfigLog.Printf("Engine %s provides AgentFileProvider: files=%v, prefixes=%v", - data.EngineConfig.ID, provider.GetAgentManifestFiles(), provider.GetAgentManifestPathPrefixes()) - return provider.GetAgentManifestFiles(), provider.GetAgentManifestPathPrefixes() -} diff --git a/pkg/workflow/compiler_safe_outputs_core.go b/pkg/workflow/compiler_safe_outputs_core.go deleted file mode 100644 index f84a37670d8..00000000000 --- a/pkg/workflow/compiler_safe_outputs_core.go +++ /dev/null @@ -1,33 +0,0 @@ -package workflow - -import ( - "github.com/github/gh-aw/pkg/logger" -) - -var consolidatedSafeOutputsLog = logger.New("workflow:compiler_safe_outputs_consolidated") - -// SafeOutputStepConfig holds configuration for building a single safe output step -// within the consolidated safe-outputs job -type SafeOutputStepConfig struct { - StepName string // Human-readable step name (e.g., "Create Issue") - StepID string // Step ID for referencing outputs (e.g., "create_issue") - Script string // JavaScript script to execute (for inline mode) - ScriptName string // Name of the script in the registry (for file mode) - CustomEnvVars []string // Environment variables specific to this step - Condition ConditionNode // Step-level condition (if clause) - Token string // GitHub token for this step - UseCopilotRequestsToken bool // Whether to use Copilot requests token preference chain - UseCopilotCodingAgentToken bool // Whether to use Copilot coding agent token preference chain - PreSteps []string // Optional steps to run before the script step - PostSteps []string // Optional steps to run after the script step - Outputs map[string]string // Outputs from this step - ContinueOnError bool // Whether to continue the job even if this step fails (continue-on-error: true) -} - -// Note: The implementation functions have been moved to focused module files: -// - buildConsolidatedSafeOutputsJob, buildJobLevelSafeOutputEnvVars, buildDetectionSuccessCondition -// are in compiler_safe_outputs_job.go -// - buildConsolidatedSafeOutputStep, buildSharedPRCheckoutSteps, buildHandlerManagerStep -// are in compiler_safe_outputs_steps.go -// - addHandlerManagerConfigEnvVar is in compiler_safe_outputs_config.go -// - addAllSafeOutputConfigEnvVars is in compiler_safe_outputs_env.go diff --git a/pkg/workflow/compiler_safe_outputs_env.go b/pkg/workflow/compiler_safe_outputs_env.go deleted file mode 100644 index 6664cb91854..00000000000 --- a/pkg/workflow/compiler_safe_outputs_env.go +++ /dev/null @@ -1,31 +0,0 @@ -package workflow - -import ( - "github.com/github/gh-aw/pkg/logger" -) - -var compilerSafeOutputsEnvLog = logger.New("workflow:compiler_safe_outputs_env") - -func (c *Compiler) addAllSafeOutputConfigEnvVars(steps *[]string, data *WorkflowData) { - compilerSafeOutputsEnvLog.Print("Adding safe output config environment variables") - if data.SafeOutputs == nil { - compilerSafeOutputsEnvLog.Print("No safe outputs configured, skipping env var addition") - return - } - - // Add the global staged env var once if staged mode is enabled, not in trial mode, - // and at least one handler is configured. Staged mode is independent of target-repo. - if !c.trialMode && data.SafeOutputs.Staged && hasAnySafeOutputEnabled(data.SafeOutputs) { - *steps = append(*steps, " GH_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") - compilerSafeOutputsEnvLog.Print("Added staged flag") - } - - // Check if copilot is in create-issue or create-pull-request assignees - enables inline copilot assignment - if (data.SafeOutputs.CreateIssues != nil && hasCopilotAssignee(data.SafeOutputs.CreateIssues.Assignees)) || - (data.SafeOutputs.CreatePullRequests != nil && hasCopilotAssignee(data.SafeOutputs.CreatePullRequests.Assignees)) { - *steps = append(*steps, " GH_AW_ASSIGN_COPILOT: \"true\"\n") - compilerSafeOutputsEnvLog.Print("Copilot assignment requested - enabled for create-issue or create-pull-request fallback issues") - } - - // Note: All handler configuration is read from the config.json file at runtime. -} diff --git a/pkg/workflow/compiler_safe_outputs_handlers.go b/pkg/workflow/compiler_safe_outputs_handlers.go deleted file mode 100644 index 7a86c9503a0..00000000000 --- a/pkg/workflow/compiler_safe_outputs_handlers.go +++ /dev/null @@ -1,922 +0,0 @@ -package workflow - -// handlerRegistry maps handler names to their builder functions. -// Each entry is keyed by the handler name used in GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG -// and returns a config map (nil means the handler is disabled). -var handlerRegistry = map[string]handlerBuilder{ - "create_issue": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreateIssues == nil { - return nil - } - c := cfg.CreateIssues - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed_labels", c.AllowedLabels). - AddStringSlice("allowed_fields", c.AllowedFields). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfPositive("expires", c.Expires). - AddStringSlice("labels", c.Labels). - AddIfNotEmpty("title_prefix", c.TitlePrefix). - AddStringSlice("assignees", c.Assignees). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddTemplatableBool("group", c.Group). - AddTemplatableBool("close_older_issues", c.CloseOlderIssues). - AddIfNotEmpty("close_older_key", c.CloseOlderKey). - AddTemplatableBool("group_by_day", c.GroupByDay). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "add_comment": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.AddComments == nil { - return nil - } - c := cfg.AddComments - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddTemplatableBool("hide_older_comments", c.HideOlderComments). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfTrue("staged", c.Staged). - Build() - }, - "comment_memory": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CommentMemory == nil { - return nil - } - c := cfg.CommentMemory - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("memory_id", c.MemoryID). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "create_discussion": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreateDiscussions == nil { - return nil - } - c := cfg.CreateDiscussions - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("category", c.Category). - AddIfNotEmpty("title_prefix", c.TitlePrefix). - AddIfPositive("min_body_length", c.MinBodyLength). - AddStringSlice("labels", c.Labels). - AddStringSlice("allowed_labels", c.AllowedLabels). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddTemplatableBool("close_older_discussions", c.CloseOlderDiscussions). - AddIfNotEmpty("close_older_key", c.CloseOlderKey). - AddIfNotEmpty("required_category", c.RequiredCategory). - AddIfPositive("expires", c.Expires). - AddBoolPtr("fallback_to_issue", c.FallbackToIssue). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "close_issue": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CloseIssues == nil { - return nil - } - c := cfg.CloseIssues - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("state_reason", c.StateReason). - AddBoolPtr("allow_body", c.AllowBody). - AddIfTrue("staged", c.Staged). - Build() - }, - "close_discussion": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CloseDiscussions == nil { - return nil - } - c := cfg.CloseDiscussions - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddBoolPtr("allow_body", c.AllowBody). - AddIfTrue("staged", c.Staged). - Build() - }, - "add_labels": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.AddLabels == nil { - return nil - } - c := cfg.AddLabels - config := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed", c.Allowed). - AddStringSlice("blocked", c.Blocked). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - // If config is empty, it means add_labels was explicitly configured with no options - // (null config), which means "allow any labels". Return non-nil empty map to - // indicate the handler is enabled. - if len(config) == 0 { - // Return empty map so handler is included in config - return make(map[string]any) - } - return config - }, - "remove_labels": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.RemoveLabels == nil { - return nil - } - c := cfg.RemoveLabels - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed", c.Allowed). - AddStringSlice("blocked", c.Blocked). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "add_reviewer": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.AddReviewer == nil { - return nil - } - c := cfg.AddReviewer - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed", c.AllowedReviewers). - AddStringSlice("allowed_team_reviewers", c.AllowedTeamReviewers). - AddIfNotEmpty("target", c.Target).AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "assign_milestone": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.AssignMilestone == nil { - return nil - } - c := cfg.AssignMilestone - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed", c.Allowed). - AddIfNotEmpty("target", c.Target).AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - AddIfTrue("auto_create", c.AutoCreate). - Build() - }, - "mark_pull_request_as_ready_for_review": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.MarkPullRequestAsReadyForReview == nil { - return nil - } - c := cfg.MarkPullRequestAsReadyForReview - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "create_code_scanning_alert": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreateCodeScanningAlerts == nil { - return nil - } - c := cfg.CreateCodeScanningAlerts - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("driver", c.Driver). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "create_check_run": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreateCheckRun == nil { - return nil - } - c := cfg.CreateCheckRun - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("name", c.Name). - AddIfTrue("staged", c.Staged) - if c.Output != nil { - builder. - AddIfNotEmpty("output_title", c.Output.Title). - AddIfNotEmpty("output_summary", c.Output.Summary) - } - // When a per-handler github-app is configured, the compiler mints a token in a - // separate step (create-check-run-app-token) and passes it as github-token so the - // JS handler can use it via createAuthenticatedGitHubClient. - // Per-handler github-token takes precedence when github-app is NOT set. - if c.GitHubApp != nil { - //nolint:gosec // G101: False positive - this is a GitHub Actions expression template, not a hardcoded credential - builder.AddIfNotEmpty("github-token", "${{ steps.create-check-run-app-token.outputs.token }}") - } else { - builder.AddIfNotEmpty("github-token", c.GitHubToken) - } - return builder.Build() - }, - "create_agent_session": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreateAgentSessions == nil { - return nil - } - c := cfg.CreateAgentSessions - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("base", c.Base). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "update_issue": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UpdateIssues == nil { - return nil - } - c := cfg.UpdateIssues - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("title_prefix", c.TitlePrefix). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix) - // Boolean pointer fields indicate which fields can be updated - if c.Status != nil { - builder.AddDefault("allow_status", true) - } - if c.Title != nil { - builder.AddDefault("allow_title", true) - } - // Body uses boolean value mode - add the actual boolean value - builder.AddBoolPtrOrDefault("allow_body", c.Body, true) - return builder. - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). - Build() - }, - "update_discussion": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UpdateDiscussions == nil { - return nil - } - c := cfg.UpdateDiscussions - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target) - // Boolean pointer fields indicate which fields can be updated - if c.Title != nil { - builder.AddDefault("allow_title", true) - } - if c.Body != nil { - builder.AddDefault("allow_body", true) - } - if c.Labels != nil { - builder.AddDefault("allow_labels", true) - } - return builder. - AddStringSlice("allowed_labels", c.AllowedLabels). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). - Build() - }, - "link_sub_issue": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.LinkSubIssue == nil { - return nil - } - c := cfg.LinkSubIssue - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("parent_required_labels", c.ParentRequiredLabels). - AddIfNotEmpty("parent_title_prefix", c.ParentTitlePrefix). - AddStringSlice("sub_required_labels", c.SubRequiredLabels). - AddIfNotEmpty("sub_title_prefix", c.SubTitlePrefix). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "update_release": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UpdateRelease == nil { - return nil - } - c := cfg.UpdateRelease - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). - Build() - }, - "create_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreatePullRequestReviewComments == nil { - return nil - } - c := cfg.CreatePullRequestReviewComments - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("side", c.Side). - AddIfNotEmpty("target", c.Target). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "submit_pull_request_review": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.SubmitPullRequestReview == nil { - return nil - } - c := cfg.SubmitPullRequestReview - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddStringSlice("allowed_events", c.AllowedEvents). - AddIfTrue("supersede_older_reviews", c.SupersedeOlderReviews).AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("github-token", c.GitHubToken). - AddStringPtr("footer", getEffectiveFooterString(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). - Build() - }, - "reply_to_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.ReplyToPullRequestReviewComment == nil { - return nil - } - c := cfg.ReplyToPullRequestReviewComment - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfTrue("staged", c.Staged). - Build() - }, - "resolve_pull_request_review_thread": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.ResolvePullRequestReviewThread == nil { - return nil - } - c := cfg.ResolvePullRequestReviewThread - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "create_pull_request": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreatePullRequests == nil { - return nil - } - c := cfg.CreatePullRequests - protectedFilesPolicy := "request_review" - if c.ManifestFilesPolicy != nil { - protectedFilesPolicy = *c.ManifestFilesPolicy - } - maxPatchSize := 1024 // default 1024 KB - if cfg.MaximumPatchSize > 0 { - maxPatchSize = cfg.MaximumPatchSize - } - if c.MaxPatchSize > 0 { - maxPatchSize = c.MaxPatchSize - } - maxPatchFiles := 100 // default 100 unique files - if cfg.MaximumPatchFiles > 0 { - maxPatchFiles = cfg.MaximumPatchFiles - } - if c.MaxPatchFiles > 0 { - maxPatchFiles = c.MaxPatchFiles - } - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("branch_prefix", c.BranchPrefix). - AddIfNotEmpty("title_prefix", c.TitlePrefix). - AddTemplatableStringSlice("labels", c.Labels). - AddStringSlice("fallback_labels", c.FallbackLabels). - AddStringSlice("reviewers", c.Reviewers). - AddStringSlice("team_reviewers", c.TeamReviewers). - AddStringSlice("assignees", c.Assignees). - AddTemplatableBool("draft", c.Draft). - AddIfNotEmpty("if_no_changes", c.IfNoChanges). - AddTemplatableBool("allow_empty", c.AllowEmpty). - AddTemplatableBool("auto_merge", c.AutoMerge). - AddIfPositive("expires", c.Expires). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). - AddTemplatableStringSlice("allowed_base_branches", c.AllowedBaseBranches). - AddTemplatableStringSlice("allowed_branches", c.AllowedBranches). - AddDefault("max_patch_size", maxPatchSize). - AddDefault("max_patch_files", maxPatchFiles). - AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddBoolPtr("fallback_as_issue", c.FallbackAsIssue). - AddTemplatableBool("auto_close_issue", c.AutoCloseIssue). - AddIfNotEmpty("base_branch", c.BaseBranch). - AddDefault("protected_files_policy", protectedFilesPolicy). - AddStringSlice("protected_files", getAllManifestFiles()). - AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). - AddDefault("protect_top_level_dot_folders", true). - AddStringSlice("_protected_files_exclude", c.ProtectedFilesExclude). - AddStringSlice("allowed_files", c.AllowedFiles). - AddStringSlice("excluded_files", c.ExcludedFiles). - AddIfTrue("preserve_branch_name", c.PreserveBranchName). - AddIfTrue("recreate_ref", c.RecreateRef). - AddIfNotEmpty("patch_format", c.PatchFormat). - AddBoolPtr("signed_commits", c.SignedCommits). - AddIfTrue("staged", c.Staged) - return builder.Build() - }, - "push_to_pull_request_branch": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.PushToPullRequestBranch == nil { - return nil - } - c := cfg.PushToPullRequestBranch - maxPatchSize := 1024 // default 1024 KB - if cfg.MaximumPatchSize > 0 { - maxPatchSize = cfg.MaximumPatchSize - } - if c.MaxPatchSize > 0 { - maxPatchSize = c.MaxPatchSize - } - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("title_prefix", c.TitlePrefix). - AddTemplatableStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("if_no_changes", c.IfNoChanges). - AddIfTrue("ignore_missing_branch_failure", c.IgnoreMissingBranchFailure). - AddIfNotEmpty("commit_title_suffix", c.CommitTitleSuffix). - AddDefault("max_patch_size", maxPatchSize). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - AddStringPtr("protected_files_policy", c.ManifestFilesPolicy). - AddStringSlice("protected_files", getAllManifestFiles()). - AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). - AddDefault("protect_top_level_dot_folders", true). - AddStringSlice("_protected_files_exclude", c.ProtectedFilesExclude). - AddStringSlice("allowed_files", c.AllowedFiles). - AddStringSlice("excluded_files", c.ExcludedFiles). - AddIfNotEmpty("patch_format", c.PatchFormat). - AddBoolPtr("fallback_as_pull_request", c.FallbackAsPullRequest). - AddBoolPtr("signed_commits", c.SignedCommits). - AddBoolPtr("check_branch_protection", c.CheckBranchProtection). - Build() - }, - "update_pull_request": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UpdatePullRequests == nil { - return nil - } - c := cfg.UpdatePullRequests - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddBoolPtrOrDefault("allow_title", c.Title, true). - AddBoolPtrOrDefault("allow_body", c.Body, true). - AddBoolPtrOrDefault("update_branch", c.UpdateBranch, false). - AddStringPtr("default_operation", c.Operation). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)).AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "merge_pull_request": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.MergePullRequest == nil { - return nil - } - c := cfg.MergePullRequest - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("required_labels", c.RequiredLabels).AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddStringSlice("allowed_branches", c.AllowedBranches). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "close_pull_request": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.ClosePullRequests == nil { - return nil - } - c := cfg.ClosePullRequests - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target", c.Target). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "hide_comment": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.HideComment == nil { - return nil - } - c := cfg.HideComment - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed_reasons", c.AllowedReasons).AddIfNotEmpty("target", c.Target). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "dispatch_workflow": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.DispatchWorkflow == nil { - return nil - } - c := cfg.DispatchWorkflow - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("workflows", c.Workflows). - AddIfNotEmpty("target-repo", c.TargetRepoSlug) - - // Add workflow_files map if it has entries - if len(c.WorkflowFiles) > 0 { - builder.AddDefault("workflow_files", c.WorkflowFiles) - } - - // Add aw_context_workflows list if it has entries - if len(c.AwContextWorkflows) > 0 { - builder.AddStringSlice("aw_context_workflows", c.AwContextWorkflows) - } - - builder.AddIfNotEmpty("target-ref", c.TargetRef) - builder.AddIfNotEmpty("github-token", c.GitHubToken) - builder.AddIfTrue("staged", c.Staged) - return builder.Build() - }, - "dispatch_repository": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.DispatchRepository == nil || len(cfg.DispatchRepository.Tools) == 0 { - return nil - } - // Serialize each tool as a sub-map - tools := make(map[string]any, len(cfg.DispatchRepository.Tools)) - for toolKey, tool := range cfg.DispatchRepository.Tools { - toolConfig := newHandlerConfigBuilder(). - AddIfNotEmpty("workflow", tool.Workflow). - AddIfNotEmpty("event_type", tool.EventType). - AddIfNotEmpty("repository", tool.Repository). - AddStringSlice("allowed_repositories", tool.AllowedRepositories). - AddTemplatableInt("max", tool.Max). - AddIfNotEmpty("github-token", tool.GitHubToken). - AddIfTrue("staged", tool.Staged). - Build() - tools[toolKey] = toolConfig - } - return map[string]any{"tools": tools} - }, - "call_workflow": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CallWorkflow == nil { - return nil - } - c := cfg.CallWorkflow - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("workflows", c.Workflows) - - // Add workflow_files map if it has entries - if len(c.WorkflowFiles) > 0 { - builder.AddDefault("workflow_files", c.WorkflowFiles) - } - - builder.AddIfTrue("staged", c.Staged) - return builder.Build() - }, - "missing_tool": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.MissingTool == nil { - return nil - } - c := cfg.MissingTool - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "missing_data": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.MissingData == nil { - return nil - } - c := cfg.MissingData - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "noop": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.NoOp == nil { - return nil - } - c := cfg.NoOp - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringPtr("report-as-issue", c.ReportAsIssue). - AddIfTrue("staged", c.Staged). - Build() - }, - "report_incomplete": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.ReportIncomplete == nil { - return nil - } - c := cfg.ReportIncomplete - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "create_report_incomplete_issue": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.ReportIncomplete == nil { - return nil - } - c := cfg.ReportIncomplete - // If create-issue is explicitly false, skip generating the issue handler. - // For nil (default) or "true", always include; for expressions, include - // the handler and embed the expression so it is evaluated at runtime. - if c.CreateIssue != nil && *c.CreateIssue == "false" { - return nil - } - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("title-prefix", c.TitlePrefix). - AddStringSlice("labels", c.Labels). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged) - // When create-issue is a GitHub Actions expression, embed it in the handler config. - // GitHub Actions evaluates the expression before the handler runs; the JavaScript - // handler then parses the resolved value via parseBoolTemplatable at runtime. - if c.CreateIssue != nil && isExpression(*c.CreateIssue) { - builder = builder.AddTemplatableBool("create-issue", c.CreateIssue) - } - return builder.Build() - }, - "assign_to_agent": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.AssignToAgent == nil { - return nil - } - c := cfg.AssignToAgent - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("name", c.DefaultAgent). - AddIfNotEmpty("model", c.DefaultModel). - AddIfNotEmpty("custom-agent", c.DefaultCustomAgent). - AddIfNotEmpty("custom-instructions", c.DefaultCustomInstructions). - AddStringSlice("allowed", c.Allowed). - AddIfTrue("ignore-if-error", c.IgnoreIfError). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed-repos", c.AllowedRepos). - AddIfNotEmpty("pull-request-repo", c.PullRequestRepoSlug). - AddStringSlice("allowed-pull-request-repos", c.AllowedPullRequestRepos). - AddIfNotEmpty("base-branch", c.BaseBranch). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "upload_asset": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UploadAssets == nil { - return nil - } - c := cfg.UploadAssets - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("branch", c.BranchName). - AddIfPositive("max-size", c.MaxSizeKB). - AddStringSlice("allowed-exts", c.AllowedExts). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "upload_artifact": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UploadArtifact == nil { - return nil - } - c := cfg.UploadArtifact - b := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfPositive("max-uploads", c.MaxUploads). - AddTemplatableInt("retention-days", c.RetentionDays). - AddTemplatableBool("skip-archive", c.SkipArchive). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged) - if c.MaxSizeBytes > 0 { - b = b.AddDefault("max-size-bytes", c.MaxSizeBytes) - } - if len(c.AllowedPaths) > 0 { - b = b.AddStringSlice("allowed-paths", c.AllowedPaths) - } - if c.Defaults != nil { - if c.Defaults.IfNoFiles != "" { - b = b.AddIfNotEmpty("default-if-no-files", c.Defaults.IfNoFiles) - } - } - if c.Filters != nil { - if len(c.Filters.Include) > 0 { - b = b.AddStringSlice("filters-include", c.Filters.Include) - } - if len(c.Filters.Exclude) > 0 { - b = b.AddStringSlice("filters-exclude", c.Filters.Exclude) - } - } - return b.Build() - }, - "autofix_code_scanning_alert": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.AutofixCodeScanningAlert == nil { - return nil - } - c := cfg.AutofixCodeScanningAlert - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - // Note: create_project, update_project and create_project_status_update are handled by the unified handler, - // not the separate project handler manager, so they are included in this registry. - "create_project": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreateProjects == nil { - return nil - } - c := cfg.CreateProjects - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("target_owner", c.TargetOwner). - AddIfNotEmpty("title_prefix", c.TitlePrefix). - AddIfNotEmpty("github-token", c.GitHubToken) - if len(c.Views) > 0 { - builder.AddDefault("views", c.Views) - } - if len(c.FieldDefinitions) > 0 { - builder.AddDefault("field_definitions", c.FieldDefinitions) - } - builder.AddIfTrue("staged", c.Staged) - return builder.Build() - }, - "update_project": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UpdateProjects == nil { - return nil - } - c := cfg.UpdateProjects - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfNotEmpty("project", c.Project). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos) - if len(c.Views) > 0 { - builder.AddDefault("views", c.Views) - } - if len(c.FieldDefinitions) > 0 { - builder.AddDefault("field_definitions", c.FieldDefinitions) - } - builder.AddIfTrue("staged", c.Staged) - return builder.Build() - }, - "assign_to_user": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.AssignToUser == nil { - return nil - } - c := cfg.AssignToUser - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed", c.Allowed). - AddStringSlice("blocked", c.Blocked). - AddIfNotEmpty("target", c.Target).AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddTemplatableBool("unassign_first", c.UnassignFirst). - AddIfTrue("staged", c.Staged). - Build() - }, - "unassign_from_user": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UnassignFromUser == nil { - return nil - } - c := cfg.UnassignFromUser - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed", c.Allowed). - AddStringSlice("blocked", c.Blocked). - AddIfNotEmpty("target", c.Target). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - }, - "create_project_status_update": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.CreateProjectStatusUpdates == nil { - return nil - } - c := cfg.CreateProjectStatusUpdates - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfNotEmpty("project", c.Project). - AddIfTrue("staged", c.Staged). - Build() - }, - "set_issue_type": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.SetIssueType == nil { - return nil - } - c := cfg.SetIssueType - config := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed", c.Allowed). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - // If config is empty, it means set_issue_type was explicitly configured with no options - // (null config), which means "allow any type". Return non-nil empty map to - // indicate the handler is enabled. - if len(config) == 0 { - return make(map[string]any) - } - return config - }, - "set_issue_field": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.SetIssueField == nil { - return nil - } - c := cfg.SetIssueField - config := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed_fields", c.AllowedFields). - AddIfNotEmpty("target", c.Target).AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", c.GitHubToken). - AddIfTrue("staged", c.Staged). - Build() - if len(config) == 0 { - return make(map[string]any) - } - return config - }, -} diff --git a/pkg/workflow/mcp_config_builtin.go b/pkg/workflow/mcp_config_builtin.go index e98591b7788..d0665da61d4 100644 --- a/pkg/workflow/mcp_config_builtin.go +++ b/pkg/workflow/mcp_config_builtin.go @@ -98,202 +98,3 @@ // } // } package workflow - -import ( - "strings" - - "github.com/github/gh-aw/pkg/constants" - "github.com/github/gh-aw/pkg/logger" -) - -var mcpBuiltinLog = logger.New("workflow:mcp-config-builtin") - -// renderSafeOutputsMCPConfigWithOptions generates the Safe Outputs MCP server configuration with engine-specific options -// Now uses HTTP transport instead of stdio, similar to mcp-scripts -// The server is started in a separate step before the agent job -func renderSafeOutputsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, workflowData *WorkflowData) { - mcpBuiltinLog.Printf("Rendering Safe Outputs MCP config with options: isLast=%v, includeCopilotFields=%v", isLast, includeCopilotFields) - yaml.WriteString(" \"" + constants.SafeOutputsMCPServerID.String() + "\": {\n") - - // HTTP transport configuration - server started in separate step - // Add type field for HTTP (required by MCP specification for HTTP transport) - yaml.WriteString(" \"type\": \"http\",\n") - - // Determine host based on whether agent is disabled - host := "host.docker.internal" - if workflowData != nil && workflowData.SandboxConfig != nil && workflowData.SandboxConfig.Agent != nil && workflowData.SandboxConfig.Agent.Disabled { - // When agent is disabled (no firewall), use localhost instead of host.docker.internal - host = "localhost" - mcpBuiltinLog.Print("Agent firewall disabled, using localhost instead of host.docker.internal") - } - mcpBuiltinLog.Printf("Using host: %s", host) - - // HTTP URL using environment variable - NOT escaped so shell expands it before awmg validation - // Use host.docker.internal to allow access from firewall container (or localhost if agent disabled) - // Note: awmg validates URL format before variable resolution, so we must expand the port variable - yaml.WriteString(" \"url\": \"http://" + host + ":$GH_AW_SAFE_OUTPUTS_PORT\",\n") - - // Add Authorization header with API key - yaml.WriteString(" \"headers\": {\n") - if includeCopilotFields { - // Copilot format: backslash-escaped shell variable reference - yaml.WriteString(" \"Authorization\": \"\\${GH_AW_SAFE_OUTPUTS_API_KEY}\"\n") - } else { - // Claude/Custom format: direct shell variable reference - yaml.WriteString(" \"Authorization\": \"$GH_AW_SAFE_OUTPUTS_API_KEY\"\n") - } - yaml.WriteString(" }") - - // Check if GitHub tool has guard-policies configured (or auto-lockdown will run) - // If so, generate a linked write-sink guard-policy for safeoutputs - guardPolicies := deriveWriteSinkGuardPolicyFromWorkflow(workflowData) - - // Add guard-policies if configured - if len(guardPolicies) > 0 { - mcpBuiltinLog.Print("Adding guard-policies to safeoutputs (derived from GitHub guard-policy or auto-lockdown detection)") - yaml.WriteString(",\n") - renderGuardPoliciesJSON(yaml, guardPolicies, " ") - } else { - yaml.WriteString("\n") - } - - if isLast { - yaml.WriteString(" }\n") - } else { - yaml.WriteString(" },\n") - } -} - -// renderAgenticWorkflowsMCPConfigWithOptions generates the Agentic Workflows MCP server configuration with engine-specific options -// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. -// Uses MCP Gateway spec format: container, entrypoint, entrypointArgs, and mounts fields. -func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, actionMode ActionMode, guardPolicies map[string]any) { - mcpBuiltinLog.Printf("Rendering Agentic Workflows MCP config: isLast=%v, includeCopilotFields=%v, actionMode=%v", isLast, includeCopilotFields, actionMode) - - // Environment variables: map of env var name to value (literal) or source variable (reference) - envVars := []struct { - name string - value string - isLiteral bool - }{ - {"DEBUG", "*", true}, // Literal value "*" - {"GITHUB_TOKEN", "GITHUB_TOKEN", false}, // Variable reference (gh CLI auto-sets GH_TOKEN from GITHUB_TOKEN if needed) - {"GITHUB_ACTOR", "GITHUB_ACTOR", false}, // Variable reference for actor-based access control - {"GITHUB_REPOSITORY", "GITHUB_REPOSITORY", false}, // Variable reference for repository context - } - - // Use MCP Gateway spec format with container, entrypoint, entrypointArgs, and mounts - yaml.WriteString(" \"" + constants.AgenticWorkflowsMCPServerID.String() + "\": {\n") - - // Add type field for Copilot (per MCP Gateway Specification v1.0.0, use "stdio" for containerized servers) - if includeCopilotFields { - yaml.WriteString(" \"type\": \"stdio\",\n") - } - - // MCP Gateway spec fields for containerized stdio servers - containerImage := constants.DefaultAlpineImage - var entrypoint string - var entrypointArgs []string - var mounts []string - - if actionMode.IsDev() { - mcpBuiltinLog.Print("Using dev mode configuration with locally built Docker image") - // Dev mode: Use locally built Docker image which includes gh-aw binary and gh CLI - // The Dockerfile sets ENTRYPOINT ["gh-aw"] and CMD ["mcp-server", "--validate-actor"] - // Binary path is automatically detected via os.Executable() - // So we don't need to specify entrypoint or entrypointArgs - containerImage = constants.DevModeGhAwImage - entrypoint = "" // Use container's default entrypoint - entrypointArgs = nil // Use container's default CMD - // Only mount workspace and temp directory - binary and gh CLI are in the image - mounts = []string{constants.DefaultWorkspaceMount, constants.DefaultTmpGhAwMount} - } else { - // Release mode: Use minimal Alpine image with mounted binaries - // The gh-aw binary is mounted from ${RUNNER_TEMP}/gh-aw and executed directly - // Pass --validate-actor flag to enable role-based access control - entrypoint = "${RUNNER_TEMP}/gh-aw/gh-aw" - entrypointArgs = []string{"mcp-server", "--validate-actor"} - // Mount gh-aw binary, gh CLI binary, workspace, and temp directory - mounts = []string{constants.DefaultGhAwMount, constants.DefaultGhBinaryMount, constants.DefaultWorkspaceMount, constants.DefaultTmpGhAwMount} - } - - yaml.WriteString(" \"container\": \"" + containerImage + "\",\n") - - // Only write entrypoint if it's specified (release mode) - // In dev mode, use the container's default ENTRYPOINT - if entrypoint != "" { - yaml.WriteString(" \"entrypoint\": \"" + entrypoint + "\",\n") - } - - // Only write entrypointArgs if specified (release mode) - // In dev mode, use the container's default CMD - if entrypointArgs != nil { - yaml.WriteString(" \"entrypointArgs\": [") - for i, arg := range entrypointArgs { - if i > 0 { - yaml.WriteString(", ") - } - yaml.WriteString("\"" + arg + "\"") - } - yaml.WriteString("],\n") - } - - // Write mounts - yaml.WriteString(" \"mounts\": [") - for i, mount := range mounts { - if i > 0 { - yaml.WriteString(", ") - } - yaml.WriteString("\"" + mount + "\"") - } - yaml.WriteString("],\n") - - // Add Docker runtime args: - // - --network host: Enables network access for GitHub API calls (gh CLI needs api.github.com) - // - -w: Sets working directory to workspace for .github/workflows folder resolution - // Security: Use GITHUB_WORKSPACE environment variable instead of template expansion to prevent template injection - yaml.WriteString(" \"args\": [\"--network\", \"host\", \"-w\", \"\\${GITHUB_WORKSPACE}\"],\n") - - // Note: tools field is NOT included here - the converter script adds it back - // for Copilot. This keeps the gateway config compatible with the schema. - - // Write environment variables - yaml.WriteString(" \"env\": {\n") - for i, envVar := range envVars { - isLastEnvVar := i == len(envVars)-1 - comma := "" - if !isLastEnvVar { - comma = "," - } - - var valueStr string - if envVar.isLiteral { - // Literal value (e.g., DEBUG = "*") - valueStr = envVar.value - } else { - // Variable reference - if includeCopilotFields { - // Copilot format: backslash-escaped shell variable reference - valueStr = "\\${" + envVar.value + "}" - } else { - // Claude/Custom format: direct shell variable reference - valueStr = "$" + envVar.value - } - } - - yaml.WriteString(" \"" + envVar.name + "\": \"" + valueStr + "\"" + comma + "\n") - } - // Close env section - with or without trailing comma depending on whether guard policies follow - if len(guardPolicies) > 0 { - yaml.WriteString(" },\n") - renderGuardPoliciesJSON(yaml, guardPolicies, " ") - } else { - yaml.WriteString(" }\n") - } - - if isLast { - yaml.WriteString(" }\n") - } else { - yaml.WriteString(" },\n") - } -} diff --git a/pkg/workflow/mcp_renderer_builtin.go b/pkg/workflow/mcp_renderer_builtin.go index 3a731d6bbcd..8b13a166d5b 100644 --- a/pkg/workflow/mcp_renderer_builtin.go +++ b/pkg/workflow/mcp_renderer_builtin.go @@ -234,3 +234,193 @@ func (r *MCPConfigRendererUnified) renderAgenticWorkflowsTOML(yaml *strings.Buil yaml.WriteString(" env_vars = [\"DEBUG\", \"GH_TOKEN\", \"GITHUB_TOKEN\", \"GITHUB_ACTOR\", \"GITHUB_REPOSITORY\"]\n") } + +// renderSafeOutputsMCPConfigWithOptions generates the Safe Outputs MCP server configuration with engine-specific options +// Now uses HTTP transport instead of stdio, similar to mcp-scripts +// The server is started in a separate step before the agent job +func renderSafeOutputsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, workflowData *WorkflowData) { + mcpRendererBuiltinLog.Printf("Rendering Safe Outputs MCP config with options: isLast=%v, includeCopilotFields=%v", isLast, includeCopilotFields) + yaml.WriteString(" \"" + constants.SafeOutputsMCPServerID.String() + "\": {\n") + + // HTTP transport configuration - server started in separate step + // Add type field for HTTP (required by MCP specification for HTTP transport) + yaml.WriteString(" \"type\": \"http\",\n") + + // Determine host based on whether agent is disabled + host := "host.docker.internal" + if workflowData != nil && workflowData.SandboxConfig != nil && workflowData.SandboxConfig.Agent != nil && workflowData.SandboxConfig.Agent.Disabled { + // When agent is disabled (no firewall), use localhost instead of host.docker.internal + host = "localhost" + mcpRendererBuiltinLog.Print("Agent firewall disabled, using localhost instead of host.docker.internal") + } + mcpRendererBuiltinLog.Printf("Using host: %s", host) + + // HTTP URL using environment variable - NOT escaped so shell expands it before awmg validation + // Use host.docker.internal to allow access from firewall container (or localhost if agent disabled) + // Note: awmg validates URL format before variable resolution, so we must expand the port variable + yaml.WriteString(" \"url\": \"http://" + host + ":$GH_AW_SAFE_OUTPUTS_PORT\",\n") + + // Add Authorization header with API key + yaml.WriteString(" \"headers\": {\n") + if includeCopilotFields { + // Copilot format: backslash-escaped shell variable reference + yaml.WriteString(" \"Authorization\": \"\\${GH_AW_SAFE_OUTPUTS_API_KEY}\"\n") + } else { + // Claude/Custom format: direct shell variable reference + yaml.WriteString(" \"Authorization\": \"$GH_AW_SAFE_OUTPUTS_API_KEY\"\n") + } + yaml.WriteString(" }") + + // Check if GitHub tool has guard-policies configured (or auto-lockdown will run) + // If so, generate a linked write-sink guard-policy for safeoutputs + guardPolicies := deriveWriteSinkGuardPolicyFromWorkflow(workflowData) + + // Add guard-policies if configured + if len(guardPolicies) > 0 { + mcpRendererBuiltinLog.Print("Adding guard-policies to safeoutputs (derived from GitHub guard-policy or auto-lockdown detection)") + yaml.WriteString(",\n") + renderGuardPoliciesJSON(yaml, guardPolicies, " ") + } else { + yaml.WriteString("\n") + } + + if isLast { + yaml.WriteString(" }\n") + } else { + yaml.WriteString(" },\n") + } +} + +// renderAgenticWorkflowsMCPConfigWithOptions generates the Agentic Workflows MCP server configuration with engine-specific options +// Per MCP Gateway Specification v1.0.0 section 3.2.1, stdio-based MCP servers MUST be containerized. +// Uses MCP Gateway spec format: container, entrypoint, entrypointArgs, and mounts fields. +func renderAgenticWorkflowsMCPConfigWithOptions(yaml *strings.Builder, isLast bool, includeCopilotFields bool, actionMode ActionMode, guardPolicies map[string]any) { + mcpRendererBuiltinLog.Printf("Rendering Agentic Workflows MCP config: isLast=%v, includeCopilotFields=%v, actionMode=%v", isLast, includeCopilotFields, actionMode) + + // Environment variables: map of env var name to value (literal) or source variable (reference) + envVars := []struct { + name string + value string + isLiteral bool + }{ + {"DEBUG", "*", true}, // Literal value "*" + {"GITHUB_TOKEN", "GITHUB_TOKEN", false}, // Variable reference (gh CLI auto-sets GH_TOKEN from GITHUB_TOKEN if needed) + {"GITHUB_ACTOR", "GITHUB_ACTOR", false}, // Variable reference for actor-based access control + {"GITHUB_REPOSITORY", "GITHUB_REPOSITORY", false}, // Variable reference for repository context + } + + // Use MCP Gateway spec format with container, entrypoint, entrypointArgs, and mounts + yaml.WriteString(" \"" + constants.AgenticWorkflowsMCPServerID.String() + "\": {\n") + + // Add type field for Copilot (per MCP Gateway Specification v1.0.0, use "stdio" for containerized servers) + if includeCopilotFields { + yaml.WriteString(" \"type\": \"stdio\",\n") + } + + // MCP Gateway spec fields for containerized stdio servers + containerImage := constants.DefaultAlpineImage + var entrypoint string + var entrypointArgs []string + var mounts []string + + if actionMode.IsDev() { + mcpRendererBuiltinLog.Print("Using dev mode configuration with locally built Docker image") + // Dev mode: Use locally built Docker image which includes gh-aw binary and gh CLI + // The Dockerfile sets ENTRYPOINT ["gh-aw"] and CMD ["mcp-server", "--validate-actor"] + // Binary path is automatically detected via os.Executable() + // So we don't need to specify entrypoint or entrypointArgs + containerImage = constants.DevModeGhAwImage + entrypoint = "" // Use container's default entrypoint + entrypointArgs = nil // Use container's default CMD + // Only mount workspace and temp directory - binary and gh CLI are in the image + mounts = []string{constants.DefaultWorkspaceMount, constants.DefaultTmpGhAwMount} + } else { + // Release mode: Use minimal Alpine image with mounted binaries + // The gh-aw binary is mounted from ${RUNNER_TEMP}/gh-aw and executed directly + // Pass --validate-actor flag to enable role-based access control + entrypoint = "${RUNNER_TEMP}/gh-aw/gh-aw" + entrypointArgs = []string{"mcp-server", "--validate-actor"} + // Mount gh-aw binary, gh CLI binary, workspace, and temp directory + mounts = []string{constants.DefaultGhAwMount, constants.DefaultGhBinaryMount, constants.DefaultWorkspaceMount, constants.DefaultTmpGhAwMount} + } + + yaml.WriteString(" \"container\": \"" + containerImage + "\",\n") + + // Only write entrypoint if it's specified (release mode) + // In dev mode, use the container's default ENTRYPOINT + if entrypoint != "" { + yaml.WriteString(" \"entrypoint\": \"" + entrypoint + "\",\n") + } + + // Only write entrypointArgs if specified (release mode) + // In dev mode, use the container's default CMD + if entrypointArgs != nil { + yaml.WriteString(" \"entrypointArgs\": [") + for i, arg := range entrypointArgs { + if i > 0 { + yaml.WriteString(", ") + } + yaml.WriteString("\"" + arg + "\"") + } + yaml.WriteString("],\n") + } + + // Write mounts + yaml.WriteString(" \"mounts\": [") + for i, mount := range mounts { + if i > 0 { + yaml.WriteString(", ") + } + yaml.WriteString("\"" + mount + "\"") + } + yaml.WriteString("],\n") + + // Add Docker runtime args: + // - --network host: Enables network access for GitHub API calls (gh CLI needs api.github.com) + // - -w: Sets working directory to workspace for .github/workflows folder resolution + // Security: Use GITHUB_WORKSPACE environment variable instead of template expansion to prevent template injection + yaml.WriteString(" \"args\": [\"--network\", \"host\", \"-w\", \"\\${GITHUB_WORKSPACE}\"],\n") + + // Note: tools field is NOT included here - the converter script adds it back + // for Copilot. This keeps the gateway config compatible with the schema. + + // Write environment variables + yaml.WriteString(" \"env\": {\n") + for i, envVar := range envVars { + isLastEnvVar := i == len(envVars)-1 + comma := "" + if !isLastEnvVar { + comma = "," + } + + var valueStr string + if envVar.isLiteral { + // Literal value (e.g., DEBUG = "*") + valueStr = envVar.value + } else { + // Variable reference + if includeCopilotFields { + // Copilot format: backslash-escaped shell variable reference + valueStr = "\\${" + envVar.value + "}" + } else { + // Claude/Custom format: direct shell variable reference + valueStr = "$" + envVar.value + } + } + + yaml.WriteString(" \"" + envVar.name + "\": \"" + valueStr + "\"" + comma + "\n") + } + // Close env section - with or without trailing comma depending on whether guard policies follow + if len(guardPolicies) > 0 { + yaml.WriteString(" },\n") + renderGuardPoliciesJSON(yaml, guardPolicies, " ") + } else { + yaml.WriteString(" }\n") + } + + if isLast { + yaml.WriteString(" }\n") + } else { + yaml.WriteString(" },\n") + } +} diff --git a/pkg/workflow/mcp_rendering.go b/pkg/workflow/mcp_renderer_factory.go similarity index 100% rename from pkg/workflow/mcp_rendering.go rename to pkg/workflow/mcp_renderer_factory.go diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index 103f8f2e055..8c205adec19 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -1,10 +1,13 @@ package workflow import ( + "encoding/json" + "fmt" "math" "strings" "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/sliceutil" "github.com/github/gh-aw/pkg/typeutil" ) @@ -753,3 +756,1104 @@ func (c *Compiler) parseBaseSafeOutputConfig(configMap map[string]any, config *B } } } + +// SafeOutputStepConfig holds configuration for building a single safe output step +// within the consolidated safe-outputs job +type SafeOutputStepConfig struct { + StepName string // Human-readable step name (e.g., "Create Issue") + StepID string // Step ID for referencing outputs (e.g., "create_issue") + Script string // JavaScript script to execute (for inline mode) + ScriptName string // Name of the script in the registry (for file mode) + CustomEnvVars []string // Environment variables specific to this step + Condition ConditionNode // Step-level condition (if clause) + Token string // GitHub token for this step + UseCopilotRequestsToken bool // Whether to use Copilot requests token preference chain + UseCopilotCodingAgentToken bool // Whether to use Copilot coding agent token preference chain + PreSteps []string // Optional steps to run before the script step + PostSteps []string // Optional steps to run after the script step + Outputs map[string]string // Outputs from this step + ContinueOnError bool // Whether to continue the job even if this step fails (continue-on-error: true) +} + +// handlerRegistry maps handler names to their builder functions. +// Each entry is keyed by the handler name used in GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG +// and returns a config map (nil means the handler is disabled). +var handlerRegistry = map[string]handlerBuilder{ + "create_issue": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateIssues == nil { + return nil + } + c := cfg.CreateIssues + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed_labels", c.AllowedLabels). + AddStringSlice("allowed_fields", c.AllowedFields). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfPositive("expires", c.Expires). + AddStringSlice("labels", c.Labels). + AddIfNotEmpty("title_prefix", c.TitlePrefix). + AddStringSlice("assignees", c.Assignees). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddTemplatableBool("group", c.Group). + AddTemplatableBool("close_older_issues", c.CloseOlderIssues). + AddIfNotEmpty("close_older_key", c.CloseOlderKey). + AddTemplatableBool("group_by_day", c.GroupByDay). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "add_comment": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AddComments == nil { + return nil + } + c := cfg.AddComments + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddTemplatableBool("hide_older_comments", c.HideOlderComments). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfTrue("staged", c.Staged). + Build() + }, + "comment_memory": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CommentMemory == nil { + return nil + } + c := cfg.CommentMemory + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("memory_id", c.MemoryID). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "create_discussion": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateDiscussions == nil { + return nil + } + c := cfg.CreateDiscussions + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("category", c.Category). + AddIfNotEmpty("title_prefix", c.TitlePrefix). + AddIfPositive("min_body_length", c.MinBodyLength). + AddStringSlice("labels", c.Labels). + AddStringSlice("allowed_labels", c.AllowedLabels). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddTemplatableBool("close_older_discussions", c.CloseOlderDiscussions). + AddIfNotEmpty("close_older_key", c.CloseOlderKey). + AddIfNotEmpty("required_category", c.RequiredCategory). + AddIfPositive("expires", c.Expires). + AddBoolPtr("fallback_to_issue", c.FallbackToIssue). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "close_issue": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CloseIssues == nil { + return nil + } + c := cfg.CloseIssues + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("state_reason", c.StateReason). + AddBoolPtr("allow_body", c.AllowBody). + AddIfTrue("staged", c.Staged). + Build() + }, + "close_discussion": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CloseDiscussions == nil { + return nil + } + c := cfg.CloseDiscussions + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddBoolPtr("allow_body", c.AllowBody). + AddIfTrue("staged", c.Staged). + Build() + }, + "add_labels": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AddLabels == nil { + return nil + } + c := cfg.AddLabels + config := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddStringSlice("blocked", c.Blocked). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + // If config is empty, it means add_labels was explicitly configured with no options + // (null config), which means "allow any labels". Return non-nil empty map to + // indicate the handler is enabled. + if len(config) == 0 { + // Return empty map so handler is included in config + return make(map[string]any) + } + return config + }, + "remove_labels": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.RemoveLabels == nil { + return nil + } + c := cfg.RemoveLabels + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddStringSlice("blocked", c.Blocked). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "add_reviewer": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AddReviewer == nil { + return nil + } + c := cfg.AddReviewer + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.AllowedReviewers). + AddStringSlice("allowed_team_reviewers", c.AllowedTeamReviewers). + AddIfNotEmpty("target", c.Target).AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "assign_milestone": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AssignMilestone == nil { + return nil + } + c := cfg.AssignMilestone + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddIfNotEmpty("target", c.Target).AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + AddIfTrue("auto_create", c.AutoCreate). + Build() + }, + "mark_pull_request_as_ready_for_review": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.MarkPullRequestAsReadyForReview == nil { + return nil + } + c := cfg.MarkPullRequestAsReadyForReview + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "create_code_scanning_alert": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateCodeScanningAlerts == nil { + return nil + } + c := cfg.CreateCodeScanningAlerts + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("driver", c.Driver). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "create_check_run": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateCheckRun == nil { + return nil + } + c := cfg.CreateCheckRun + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("name", c.Name). + AddIfTrue("staged", c.Staged) + if c.Output != nil { + builder. + AddIfNotEmpty("output_title", c.Output.Title). + AddIfNotEmpty("output_summary", c.Output.Summary) + } + // When a per-handler github-app is configured, the compiler mints a token in a + // separate step (create-check-run-app-token) and passes it as github-token so the + // JS handler can use it via createAuthenticatedGitHubClient. + // Per-handler github-token takes precedence when github-app is NOT set. + if c.GitHubApp != nil { + //nolint:gosec // G101: False positive - this is a GitHub Actions expression template, not a hardcoded credential + builder.AddIfNotEmpty("github-token", "${{ steps.create-check-run-app-token.outputs.token }}") + } else { + builder.AddIfNotEmpty("github-token", c.GitHubToken) + } + return builder.Build() + }, + "create_agent_session": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateAgentSessions == nil { + return nil + } + c := cfg.CreateAgentSessions + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("base", c.Base). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "update_issue": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UpdateIssues == nil { + return nil + } + c := cfg.UpdateIssues + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("title_prefix", c.TitlePrefix). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix) + // Boolean pointer fields indicate which fields can be updated + if c.Status != nil { + builder.AddDefault("allow_status", true) + } + if c.Title != nil { + builder.AddDefault("allow_title", true) + } + // Body uses boolean value mode - add the actual boolean value + builder.AddBoolPtrOrDefault("allow_body", c.Body, true) + return builder. + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfTrue("staged", c.Staged). + Build() + }, + "update_discussion": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UpdateDiscussions == nil { + return nil + } + c := cfg.UpdateDiscussions + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target) + // Boolean pointer fields indicate which fields can be updated + if c.Title != nil { + builder.AddDefault("allow_title", true) + } + if c.Body != nil { + builder.AddDefault("allow_body", true) + } + if c.Labels != nil { + builder.AddDefault("allow_labels", true) + } + return builder. + AddStringSlice("allowed_labels", c.AllowedLabels). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfTrue("staged", c.Staged). + Build() + }, + "link_sub_issue": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.LinkSubIssue == nil { + return nil + } + c := cfg.LinkSubIssue + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("parent_required_labels", c.ParentRequiredLabels). + AddIfNotEmpty("parent_title_prefix", c.ParentTitlePrefix). + AddStringSlice("sub_required_labels", c.SubRequiredLabels). + AddIfNotEmpty("sub_title_prefix", c.SubTitlePrefix). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "update_release": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UpdateRelease == nil { + return nil + } + c := cfg.UpdateRelease + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfTrue("staged", c.Staged). + Build() + }, + "create_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreatePullRequestReviewComments == nil { + return nil + } + c := cfg.CreatePullRequestReviewComments + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("side", c.Side). + AddIfNotEmpty("target", c.Target). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "submit_pull_request_review": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.SubmitPullRequestReview == nil { + return nil + } + c := cfg.SubmitPullRequestReview + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddStringSlice("allowed_events", c.AllowedEvents). + AddIfTrue("supersede_older_reviews", c.SupersedeOlderReviews).AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("github-token", c.GitHubToken). + AddStringPtr("footer", getEffectiveFooterString(c.Footer, cfg.Footer)). + AddIfTrue("staged", c.Staged). + Build() + }, + "reply_to_pull_request_review_comment": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.ReplyToPullRequestReviewComment == nil { + return nil + } + c := cfg.ReplyToPullRequestReviewComment + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfTrue("staged", c.Staged). + Build() + }, + "resolve_pull_request_review_thread": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.ResolvePullRequestReviewThread == nil { + return nil + } + c := cfg.ResolvePullRequestReviewThread + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "create_pull_request": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreatePullRequests == nil { + return nil + } + c := cfg.CreatePullRequests + protectedFilesPolicy := "request_review" + if c.ManifestFilesPolicy != nil { + protectedFilesPolicy = *c.ManifestFilesPolicy + } + maxPatchSize := 1024 // default 1024 KB + if cfg.MaximumPatchSize > 0 { + maxPatchSize = cfg.MaximumPatchSize + } + if c.MaxPatchSize > 0 { + maxPatchSize = c.MaxPatchSize + } + maxPatchFiles := 100 // default 100 unique files + if cfg.MaximumPatchFiles > 0 { + maxPatchFiles = cfg.MaximumPatchFiles + } + if c.MaxPatchFiles > 0 { + maxPatchFiles = c.MaxPatchFiles + } + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("branch_prefix", c.BranchPrefix). + AddIfNotEmpty("title_prefix", c.TitlePrefix). + AddTemplatableStringSlice("labels", c.Labels). + AddStringSlice("fallback_labels", c.FallbackLabels). + AddStringSlice("reviewers", c.Reviewers). + AddStringSlice("team_reviewers", c.TeamReviewers). + AddStringSlice("assignees", c.Assignees). + AddTemplatableBool("draft", c.Draft). + AddIfNotEmpty("if_no_changes", c.IfNoChanges). + AddTemplatableBool("allow_empty", c.AllowEmpty). + AddTemplatableBool("auto_merge", c.AutoMerge). + AddIfPositive("expires", c.Expires). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). + AddTemplatableStringSlice("allowed_base_branches", c.AllowedBaseBranches). + AddTemplatableStringSlice("allowed_branches", c.AllowedBranches). + AddDefault("max_patch_size", maxPatchSize). + AddDefault("max_patch_files", maxPatchFiles). + AddIfNotEmpty("github-token", c.GitHubToken). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddBoolPtr("fallback_as_issue", c.FallbackAsIssue). + AddTemplatableBool("auto_close_issue", c.AutoCloseIssue). + AddIfNotEmpty("base_branch", c.BaseBranch). + AddDefault("protected_files_policy", protectedFilesPolicy). + AddStringSlice("protected_files", getAllManifestFiles()). + AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). + AddDefault("protect_top_level_dot_folders", true). + AddStringSlice("_protected_files_exclude", c.ProtectedFilesExclude). + AddStringSlice("allowed_files", c.AllowedFiles). + AddStringSlice("excluded_files", c.ExcludedFiles). + AddIfTrue("preserve_branch_name", c.PreserveBranchName). + AddIfTrue("recreate_ref", c.RecreateRef). + AddIfNotEmpty("patch_format", c.PatchFormat). + AddBoolPtr("signed_commits", c.SignedCommits). + AddIfTrue("staged", c.Staged) + return builder.Build() + }, + "push_to_pull_request_branch": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.PushToPullRequestBranch == nil { + return nil + } + c := cfg.PushToPullRequestBranch + maxPatchSize := 1024 // default 1024 KB + if cfg.MaximumPatchSize > 0 { + maxPatchSize = cfg.MaximumPatchSize + } + if c.MaxPatchSize > 0 { + maxPatchSize = c.MaxPatchSize + } + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("title_prefix", c.TitlePrefix). + AddTemplatableStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("if_no_changes", c.IfNoChanges). + AddIfTrue("ignore_missing_branch_failure", c.IgnoreMissingBranchFailure). + AddIfNotEmpty("commit_title_suffix", c.CommitTitleSuffix). + AddDefault("max_patch_size", maxPatchSize). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + AddStringPtr("protected_files_policy", c.ManifestFilesPolicy). + AddStringSlice("protected_files", getAllManifestFiles()). + AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). + AddDefault("protect_top_level_dot_folders", true). + AddStringSlice("_protected_files_exclude", c.ProtectedFilesExclude). + AddStringSlice("allowed_files", c.AllowedFiles). + AddStringSlice("excluded_files", c.ExcludedFiles). + AddIfNotEmpty("patch_format", c.PatchFormat). + AddBoolPtr("fallback_as_pull_request", c.FallbackAsPullRequest). + AddBoolPtr("signed_commits", c.SignedCommits). + AddBoolPtr("check_branch_protection", c.CheckBranchProtection). + Build() + }, + "update_pull_request": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UpdatePullRequests == nil { + return nil + } + c := cfg.UpdatePullRequests + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddBoolPtrOrDefault("allow_title", c.Title, true). + AddBoolPtrOrDefault("allow_body", c.Body, true). + AddBoolPtrOrDefault("update_branch", c.UpdateBranch, false). + AddStringPtr("default_operation", c.Operation). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)).AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "merge_pull_request": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.MergePullRequest == nil { + return nil + } + c := cfg.MergePullRequest + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("required_labels", c.RequiredLabels).AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddStringSlice("allowed_branches", c.AllowedBranches). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "close_pull_request": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.ClosePullRequests == nil { + return nil + } + c := cfg.ClosePullRequests + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target", c.Target). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "hide_comment": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.HideComment == nil { + return nil + } + c := cfg.HideComment + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed_reasons", c.AllowedReasons).AddIfNotEmpty("target", c.Target). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "dispatch_workflow": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.DispatchWorkflow == nil { + return nil + } + c := cfg.DispatchWorkflow + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("workflows", c.Workflows). + AddIfNotEmpty("target-repo", c.TargetRepoSlug) + + // Add workflow_files map if it has entries + if len(c.WorkflowFiles) > 0 { + builder.AddDefault("workflow_files", c.WorkflowFiles) + } + + // Add aw_context_workflows list if it has entries + if len(c.AwContextWorkflows) > 0 { + builder.AddStringSlice("aw_context_workflows", c.AwContextWorkflows) + } + + builder.AddIfNotEmpty("target-ref", c.TargetRef) + builder.AddIfNotEmpty("github-token", c.GitHubToken) + builder.AddIfTrue("staged", c.Staged) + return builder.Build() + }, + "dispatch_repository": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.DispatchRepository == nil || len(cfg.DispatchRepository.Tools) == 0 { + return nil + } + // Serialize each tool as a sub-map + tools := make(map[string]any, len(cfg.DispatchRepository.Tools)) + for toolKey, tool := range cfg.DispatchRepository.Tools { + toolConfig := newHandlerConfigBuilder(). + AddIfNotEmpty("workflow", tool.Workflow). + AddIfNotEmpty("event_type", tool.EventType). + AddIfNotEmpty("repository", tool.Repository). + AddStringSlice("allowed_repositories", tool.AllowedRepositories). + AddTemplatableInt("max", tool.Max). + AddIfNotEmpty("github-token", tool.GitHubToken). + AddIfTrue("staged", tool.Staged). + Build() + tools[toolKey] = toolConfig + } + return map[string]any{"tools": tools} + }, + "call_workflow": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CallWorkflow == nil { + return nil + } + c := cfg.CallWorkflow + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("workflows", c.Workflows) + + // Add workflow_files map if it has entries + if len(c.WorkflowFiles) > 0 { + builder.AddDefault("workflow_files", c.WorkflowFiles) + } + + builder.AddIfTrue("staged", c.Staged) + return builder.Build() + }, + "missing_tool": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.MissingTool == nil { + return nil + } + c := cfg.MissingTool + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "missing_data": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.MissingData == nil { + return nil + } + c := cfg.MissingData + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "noop": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.NoOp == nil { + return nil + } + c := cfg.NoOp + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringPtr("report-as-issue", c.ReportAsIssue). + AddIfTrue("staged", c.Staged). + Build() + }, + "report_incomplete": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.ReportIncomplete == nil { + return nil + } + c := cfg.ReportIncomplete + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "create_report_incomplete_issue": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.ReportIncomplete == nil { + return nil + } + c := cfg.ReportIncomplete + // If create-issue is explicitly false, skip generating the issue handler. + // For nil (default) or "true", always include; for expressions, include + // the handler and embed the expression so it is evaluated at runtime. + if c.CreateIssue != nil && *c.CreateIssue == "false" { + return nil + } + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("title-prefix", c.TitlePrefix). + AddStringSlice("labels", c.Labels). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged) + // When create-issue is a GitHub Actions expression, embed it in the handler config. + // GitHub Actions evaluates the expression before the handler runs; the JavaScript + // handler then parses the resolved value via parseBoolTemplatable at runtime. + if c.CreateIssue != nil && isExpression(*c.CreateIssue) { + builder = builder.AddTemplatableBool("create-issue", c.CreateIssue) + } + return builder.Build() + }, + "assign_to_agent": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AssignToAgent == nil { + return nil + } + c := cfg.AssignToAgent + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("name", c.DefaultAgent). + AddIfNotEmpty("model", c.DefaultModel). + AddIfNotEmpty("custom-agent", c.DefaultCustomAgent). + AddIfNotEmpty("custom-instructions", c.DefaultCustomInstructions). + AddStringSlice("allowed", c.Allowed). + AddIfTrue("ignore-if-error", c.IgnoreIfError). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed-repos", c.AllowedRepos). + AddIfNotEmpty("pull-request-repo", c.PullRequestRepoSlug). + AddStringSlice("allowed-pull-request-repos", c.AllowedPullRequestRepos). + AddIfNotEmpty("base-branch", c.BaseBranch). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "upload_asset": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UploadAssets == nil { + return nil + } + c := cfg.UploadAssets + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("branch", c.BranchName). + AddIfPositive("max-size", c.MaxSizeKB). + AddStringSlice("allowed-exts", c.AllowedExts). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "upload_artifact": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UploadArtifact == nil { + return nil + } + c := cfg.UploadArtifact + b := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfPositive("max-uploads", c.MaxUploads). + AddTemplatableInt("retention-days", c.RetentionDays). + AddTemplatableBool("skip-archive", c.SkipArchive). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged) + if c.MaxSizeBytes > 0 { + b = b.AddDefault("max-size-bytes", c.MaxSizeBytes) + } + if len(c.AllowedPaths) > 0 { + b = b.AddStringSlice("allowed-paths", c.AllowedPaths) + } + if c.Defaults != nil { + if c.Defaults.IfNoFiles != "" { + b = b.AddIfNotEmpty("default-if-no-files", c.Defaults.IfNoFiles) + } + } + if c.Filters != nil { + if len(c.Filters.Include) > 0 { + b = b.AddStringSlice("filters-include", c.Filters.Include) + } + if len(c.Filters.Exclude) > 0 { + b = b.AddStringSlice("filters-exclude", c.Filters.Exclude) + } + } + return b.Build() + }, + "autofix_code_scanning_alert": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AutofixCodeScanningAlert == nil { + return nil + } + c := cfg.AutofixCodeScanningAlert + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + // Note: create_project, update_project and create_project_status_update are handled by the unified handler, + // not the separate project handler manager, so they are included in this registry. + "create_project": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateProjects == nil { + return nil + } + c := cfg.CreateProjects + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("target_owner", c.TargetOwner). + AddIfNotEmpty("title_prefix", c.TitlePrefix). + AddIfNotEmpty("github-token", c.GitHubToken) + if len(c.Views) > 0 { + builder.AddDefault("views", c.Views) + } + if len(c.FieldDefinitions) > 0 { + builder.AddDefault("field_definitions", c.FieldDefinitions) + } + builder.AddIfTrue("staged", c.Staged) + return builder.Build() + }, + "update_project": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UpdateProjects == nil { + return nil + } + c := cfg.UpdateProjects + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfNotEmpty("project", c.Project). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos) + if len(c.Views) > 0 { + builder.AddDefault("views", c.Views) + } + if len(c.FieldDefinitions) > 0 { + builder.AddDefault("field_definitions", c.FieldDefinitions) + } + builder.AddIfTrue("staged", c.Staged) + return builder.Build() + }, + "assign_to_user": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.AssignToUser == nil { + return nil + } + c := cfg.AssignToUser + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddStringSlice("blocked", c.Blocked). + AddIfNotEmpty("target", c.Target).AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddTemplatableBool("unassign_first", c.UnassignFirst). + AddIfTrue("staged", c.Staged). + Build() + }, + "unassign_from_user": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UnassignFromUser == nil { + return nil + } + c := cfg.UnassignFromUser + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddStringSlice("blocked", c.Blocked). + AddIfNotEmpty("target", c.Target). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + }, + "create_project_status_update": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateProjectStatusUpdates == nil { + return nil + } + c := cfg.CreateProjectStatusUpdates + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfNotEmpty("project", c.Project). + AddIfTrue("staged", c.Staged). + Build() + }, + "set_issue_type": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.SetIssueType == nil { + return nil + } + c := cfg.SetIssueType + config := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed", c.Allowed). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + // If config is empty, it means set_issue_type was explicitly configured with no options + // (null config), which means "allow any type". Return non-nil empty map to + // indicate the handler is enabled. + if len(config) == 0 { + return make(map[string]any) + } + return config + }, + "set_issue_field": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.SetIssueField == nil { + return nil + } + c := cfg.SetIssueField + config := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed_fields", c.AllowedFields). + AddIfNotEmpty("target", c.Target).AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", c.GitHubToken). + AddIfTrue("staged", c.Staged). + Build() + if len(config) == 0 { + return make(map[string]any) + } + return config + }, +} + +func (c *Compiler) addHandlerManagerConfigEnvVar(steps *[]string, data *WorkflowData) { + if data.SafeOutputs == nil { + safeOutputsConfigLog.Print("No safe-outputs configuration, skipping handler manager config") + return + } + + safeOutputsConfigLog.Print("Building handler manager configuration for safe-outputs") + // config holds both per-handler configs (keyed by handler name, e.g. "add_comment") and + // global runtime knobs (e.g. "mentions") that safe_output_handler_manager.cjs forwards to + // specific handlers at startup. Handler names are the reserved keys defined in handlerRegistry; + // non-handler keys ("mentions") are documented in safe_outputs_config_generation.go. + config := make(map[string]any) + + // Collect engine-specific manifest files and path prefixes (AgentFileProvider interface). + // These are merged with the global runtime-derived lists so that engine-specific + // instruction files (e.g. CLAUDE.md, .claude/, AGENTS.md) are automatically protected. + extraManifestFiles, extraPathPrefixes := c.getEngineAgentFileInfo(data) + fullManifestFiles := getAllManifestFiles(extraManifestFiles...) + fullPathPrefixes := getProtectedPathPrefixes(extraPathPrefixes...) + + // For workflow_call relay workflows, inject the resolved platform repo and ref into the + // dispatch_workflow handler config so dispatch targets the host repo, not the caller's. + safeOutputs := data.SafeOutputs + if hasWorkflowCallTrigger(data.On) && safeOutputs.DispatchWorkflow != nil { + if safeOutputs.DispatchWorkflow.TargetRepoSlug == "" { + safeOutputs = safeOutputsWithDispatchTargetRepo(safeOutputs, "${{ needs.activation.outputs.target_repo }}") + safeOutputsConfigLog.Print("Injecting target_repo into dispatch_workflow config for workflow_call relay") + } + if safeOutputs.DispatchWorkflow.TargetRef == "" { + safeOutputs = safeOutputsWithDispatchTargetRef(safeOutputs, "${{ needs.activation.outputs.target_ref }}") + safeOutputsConfigLog.Print("Injecting target_ref into dispatch_workflow config for workflow_call relay") + } + } + + // Build configuration for each handler using the registry + for handlerName, builder := range handlerRegistry { + handlerConfig := builder(safeOutputs) + // Include handler if: + // 1. It returns a non-nil config (explicitly enabled, even if empty) + // 2. For auto-enabled handlers, include even with empty config + if handlerConfig != nil { + injectCurrentCheckoutPatchWorkspacePath(handlerName, handlerConfig, data) + // Augment protected-files protection with engine-specific files for handlers that use it. + if _, hasProtected := handlerConfig["protected_files"]; hasProtected { + // Extract per-handler exclusions set by the handler builder (sentinel key). + // These are compile-time overrides and must not be forwarded to the runtime. + excludeFiles := ParseStringArrayFromConfig(handlerConfig, "_protected_files_exclude", nil) + delete(handlerConfig, "_protected_files_exclude") + + handlerConfig["protected_files"] = sliceutil.Exclude(fullManifestFiles, excludeFiles...) + filteredPrefixes := sliceutil.Exclude(fullPathPrefixes, excludeFiles...) + if len(filteredPrefixes) > 0 { + handlerConfig["protected_path_prefixes"] = filteredPrefixes + } else { + delete(handlerConfig, "protected_path_prefixes") + } + // Compute which top-level dot-folder prefixes are excluded so the runtime + // dot-folder check can skip them. + if dotFolderExcludes := getDotFolderExcludes(excludeFiles); len(dotFolderExcludes) > 0 { + handlerConfig["protected_dot_folder_excludes"] = dotFolderExcludes + } + } + safeOutputsConfigLog.Printf("Adding %s handler configuration", handlerName) + config[handlerName] = handlerConfig + } + } + + // Include top-level mentions configuration so the handler manager can pass it to + // markdown-producing handlers that call sanitizeContent with allowed aliases. + if safeOutputs.Mentions != nil { + mentionsCfg := buildMentionsHandlerConfig(safeOutputs.Mentions) + if len(mentionsCfg) > 0 { + config["mentions"] = mentionsCfg + } + } + + // Only add the env var if there are handlers to configure + if len(config) > 0 { + safeOutputsConfigLog.Printf("Marshaling handler config with %d handlers", len(config)) + configJSON, err := json.Marshal(config) + if err != nil { + safeOutputsConfigLog.Printf("Failed to marshal handler config: %v", err) + return + } + // Escape the JSON for YAML (handle quotes and special chars) + configStr := string(configJSON) + *steps = append(*steps, fmt.Sprintf(" GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: %q\n", configStr)) + safeOutputsConfigLog.Printf("Added handler config env var: size=%d bytes", len(configStr)) + } else { + safeOutputsConfigLog.Print("No handlers configured, skipping config env var") + } +} + +// buildMentionsHandlerConfig converts a MentionsConfig into the map format used by +// GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG so safe_output_handler_manager.cjs can pass +// the top-level mentions policy through to mention-aware handlers. +func buildMentionsHandlerConfig(m *MentionsConfig) map[string]any { + cfg := make(map[string]any) + if m.Enabled != nil { + cfg["enabled"] = *m.Enabled + } + if m.AllowTeamMembers != nil { + cfg["allowTeamMembers"] = *m.AllowTeamMembers + } + if m.AllowContext != nil { + cfg["allowContext"] = *m.AllowContext + } + if len(m.Allowed) > 0 { + cfg["allowed"] = m.Allowed + } + if m.Max != nil { + cfg["max"] = *m.Max + } + return cfg +} + +// safeOutputsWithDispatchTargetRepo returns a shallow copy of cfg with the dispatch_workflow +// TargetRepoSlug overridden to targetRepo. Only DispatchWorkflow is deep-copied; all other +// pointer fields remain shared. This avoids mutating the original config. +func safeOutputsWithDispatchTargetRepo(cfg *SafeOutputsConfig, targetRepo string) *SafeOutputsConfig { + dispatchCopy := *cfg.DispatchWorkflow + dispatchCopy.TargetRepoSlug = targetRepo + configCopy := *cfg + configCopy.DispatchWorkflow = &dispatchCopy + return &configCopy +} + +// safeOutputsWithDispatchTargetRef returns a shallow copy of cfg with the dispatch_workflow +// TargetRef overridden to targetRef. Only DispatchWorkflow is deep-copied; all other +// pointer fields remain shared. This avoids mutating the original config. +func safeOutputsWithDispatchTargetRef(cfg *SafeOutputsConfig, targetRef string) *SafeOutputsConfig { + dispatchCopy := *cfg.DispatchWorkflow + dispatchCopy.TargetRef = targetRef + configCopy := *cfg + configCopy.DispatchWorkflow = &dispatchCopy + return &configCopy +} + +// getEngineAgentFileInfo returns the engine-specific manifest filenames and path prefixes +// by type-asserting the active engine to AgentFileProvider. Returns empty slices when +// the engine is not set or does not implement the interface. +func (c *Compiler) getEngineAgentFileInfo(data *WorkflowData) (manifestFiles []string, pathPrefixes []string) { + if data == nil || data.EngineConfig == nil { + return nil, nil + } + engine, err := c.engineRegistry.GetEngine(data.EngineConfig.ID) + if err != nil { + safeOutputsConfigLog.Printf("Engine lookup failed for %q: %v — skipping agent manifest file injection", data.EngineConfig.ID, err) + return nil, nil + } + if engine == nil { + return nil, nil + } + provider, ok := engine.(AgentFileProvider) + if !ok { + return nil, nil + } + safeOutputsConfigLog.Printf("Engine %s provides AgentFileProvider: files=%v, prefixes=%v", + data.EngineConfig.ID, provider.GetAgentManifestFiles(), provider.GetAgentManifestPathPrefixes()) + return provider.GetAgentManifestFiles(), provider.GetAgentManifestPathPrefixes() +} diff --git a/pkg/workflow/safe_outputs_env.go b/pkg/workflow/safe_outputs_env.go index b74c1521482..fdfb099777d 100644 --- a/pkg/workflow/safe_outputs_env.go +++ b/pkg/workflow/safe_outputs_env.go @@ -239,3 +239,27 @@ func (c *Compiler) addSafeOutputAgentGitHubTokenForConfig(steps *[]string, data // Copilot assignment API rejects them. c.addResolvedSafeOutputGitHubTokenForConfig(steps, data, configToken, getEffectiveCopilotCodingAgentGitHubToken, false) } + +func (c *Compiler) addAllSafeOutputConfigEnvVars(steps *[]string, data *WorkflowData) { + safeOutputsEnvLog.Print("Adding safe output config environment variables") + if data.SafeOutputs == nil { + safeOutputsEnvLog.Print("No safe outputs configured, skipping env var addition") + return + } + + // Add the global staged env var once if staged mode is enabled, not in trial mode, + // and at least one handler is configured. Staged mode is independent of target-repo. + if !c.trialMode && data.SafeOutputs.Staged && hasAnySafeOutputEnabled(data.SafeOutputs) { + *steps = append(*steps, " GH_AW_SAFE_OUTPUTS_STAGED: \"true\"\n") + safeOutputsEnvLog.Print("Added staged flag") + } + + // Check if copilot is in create-issue or create-pull-request assignees - enables inline copilot assignment + if (data.SafeOutputs.CreateIssues != nil && hasCopilotAssignee(data.SafeOutputs.CreateIssues.Assignees)) || + (data.SafeOutputs.CreatePullRequests != nil && hasCopilotAssignee(data.SafeOutputs.CreatePullRequests.Assignees)) { + *steps = append(*steps, " GH_AW_ASSIGN_COPILOT: \"true\"\n") + safeOutputsEnvLog.Print("Copilot assignment requested - enabled for create-issue or create-pull-request fallback issues") + } + + // Note: All handler configuration is read from the config.json file at runtime. +} diff --git a/pkg/workflow/sandbox.go b/pkg/workflow/sandbox.go index a1e0bc99e40..525926167a5 100644 --- a/pkg/workflow/sandbox.go +++ b/pkg/workflow/sandbox.go @@ -213,3 +213,37 @@ func ensureDefaultAgentWritePath(sandboxConfig *SandboxConfig) { defaultAgentWorkspaceWritePath, ) } + +// isSandboxEnabled checks if the sandbox is enabled (either explicitly or auto-enabled) +// Returns true when: +// - sandbox.agent is explicitly set to awf +// - Firewall is auto-enabled (networkPermissions.Firewall is set and enabled) +// Returns false when: +// - sandbox.agent is false (explicitly disabled) +// - No sandbox configuration and no auto-enabled firewall +func isSandboxEnabled(sandboxConfig *SandboxConfig, networkPermissions *NetworkPermissions) bool { + // Check if sandbox.agent is explicitly disabled + if sandboxConfig != nil && sandboxConfig.Agent != nil && sandboxConfig.Agent.Disabled { + return false + } + + // Check if sandbox.agent is explicitly configured with a type + if sandboxConfig != nil && sandboxConfig.Agent != nil { + agentType := getAgentType(sandboxConfig.Agent) + if isSupportedSandboxType(agentType) { + return true + } + } + + // Check legacy top-level Type field (deprecated but still supported) + if sandboxConfig != nil && isSupportedSandboxType(sandboxConfig.Type) { + return true + } + + // Check if firewall is auto-enabled (AWF) + if networkPermissions != nil && networkPermissions.Firewall != nil && networkPermissions.Firewall.Enabled { + return true + } + + return false +} diff --git a/pkg/workflow/tools.go b/pkg/workflow/tools.go index 56ff1125c44..1dc7b2decea 100644 --- a/pkg/workflow/tools.go +++ b/pkg/workflow/tools.go @@ -522,3 +522,191 @@ func (c *Compiler) replaceIssueNumberReferences(yamlContent string) string { // Replace all occurrences of github.event.issue.number with inputs.issue_number return strings.ReplaceAll(yamlContent, "github.event.issue.number", "inputs.issue_number") } + +// applyDefaultTools adds default read-only GitHub MCP tools, creating github tool if not present +func (c *Compiler) applyDefaultTools(tools map[string]any, safeOutputs *SafeOutputsConfig, sandboxConfig *SandboxConfig, networkPermissions *NetworkPermissions) map[string]any { + toolsLog.Printf("Applying default tools: existingToolCount=%d", len(tools)) + // Always apply default GitHub tools (create github section if it doesn't exist) + + if tools == nil { + tools = make(map[string]any) + } + + // Get existing github tool configuration + githubTool := tools["github"] + + // Check if github is explicitly disabled (github: false) + if githubTool == false { + // Remove the github tool entirely when set to false + delete(tools, "github") + } else { + // Process github tool configuration + var githubConfig map[string]any + + if toolConfig, ok := githubTool.(map[string]any); ok { + githubConfig = make(map[string]any) + maps.Copy(githubConfig, toolConfig) + } else { + githubConfig = make(map[string]any) + } + + // Parse the existing GitHub tool configuration for type safety + parsedConfig := parseGitHubTool(githubTool) + + // Create a set of existing tools for efficient lookup + existingToolsSet := make(map[string]bool) + if parsedConfig != nil { + for _, tool := range parsedConfig.Allowed { + existingToolsSet[string(tool)] = true + } + } + + // Only set allowed tools if explicitly configured + // Don't add default tools - let the MCP server use all available tools + if len(existingToolsSet) > 0 { + // Convert back to []any for the map + existingAllowed := make([]any, 0, len(parsedConfig.Allowed)) + for _, tool := range parsedConfig.Allowed { + existingAllowed = append(existingAllowed, string(tool)) + } + githubConfig["allowed"] = existingAllowed + } + tools["github"] = githubConfig + } + + // Enable edit and bash tools by default when sandbox is enabled + // The sandbox is enabled when: + // 1. Explicitly configured via sandbox.agent (awf) + // 2. Auto-enabled by firewall default enablement (when network restrictions are present) + if isSandboxEnabled(sandboxConfig, networkPermissions) { + toolsLog.Print("Sandbox enabled, applying default edit and bash tools") + + // Add edit tool if not present + if _, exists := tools["edit"]; !exists { + tools["edit"] = true + toolsLog.Print("Added edit tool (sandbox enabled)") + } + + // Add bash tool with wildcard if not present + if _, exists := tools["bash"]; !exists { + tools["bash"] = []any{"*"} + toolsLog.Print("Added bash tool with wildcard (sandbox enabled)") + } + } + + // Add Git commands and file editing tools when safe-outputs includes create-pull-request or push-to-pull-request-branch + if safeOutputs != nil && needsGitCommands(safeOutputs) { + + // Add edit tool with null value + if _, exists := tools["edit"]; !exists { + tools["edit"] = nil + } + gitCommands := []any{ + "git checkout:*", + "git branch:*", + "git switch:*", + "git add:*", + "git rm:*", + "git commit:*", + "git merge:*", + "git status", + } + + // Add bash tool with Git commands if not already present + if _, exists := tools["bash"]; !exists { + // bash tool doesn't exist, add it with Git commands + tools["bash"] = gitCommands + } else { + // bash tool exists, merge Git commands with existing commands + existingBash := tools["bash"] + if existingCommands, ok := existingBash.([]any); ok { + // Convert existing commands to strings for comparison + existingSet := make(map[string]bool) + for _, cmd := range existingCommands { + if cmdStr, ok := cmd.(string); ok { + existingSet[cmdStr] = true + // If we see :* or *, all bash commands are already allowed + if cmdStr == ":*" || cmdStr == "*" { + // Don't add specific Git commands since all are already allowed + goto bashComplete + } + } + } + + // Add Git commands that aren't already present + newCommands := append([]any(nil), existingCommands...) + for _, gitCmd := range gitCommands { + if gitCmdStr, ok := gitCmd.(string); ok { + if !existingSet[gitCmdStr] { + newCommands = append(newCommands, gitCmd) + } + } + } + tools["bash"] = newCommands + } else if existingBash == false { + // bash: false was set, but git commands are required for PR operations + // Override with git commands only (minimum needed for PR functionality) + toolsLog.Print("Overriding bash: false with git commands (required for PR operations)") + tools["bash"] = gitCommands + } else if existingBash == nil { + _ = existingBash // Keep the nil value as-is + } + } + bashComplete: + } + + // Add default bash commands when bash is enabled but no specific commands are provided + // This runs after git commands logic, so it only applies when git commands weren't added + // Behavior: + // - bash: true → All commands allowed (converted to ["*"]) + // - bash: false → Tool disabled (removed from tools), unless git commands were needed for PR operations + // - bash: nil → Add default commands + // - bash: [] → No commands (empty array means no tools allowed) + // - bash: ["cmd1", "cmd2"] → Add default commands + specific commands + if bashTool, exists := tools["bash"]; exists { + // Check if bash was left as nil or true after git processing + if bashTool == nil { + // bash is nil - only add defaults if this wasn't processed by git commands + // If git commands were needed, bash would have been set to git commands or left as nil intentionally + if safeOutputs == nil || !needsGitCommands(safeOutputs) { + defaultCommands := make([]any, len(constants.DefaultBashTools)) + for i, cmd := range constants.DefaultBashTools { + defaultCommands[i] = cmd + } + tools["bash"] = defaultCommands + } + } else if bashTool == true { + // bash is true - convert to wildcard (allow all commands) + tools["bash"] = []any{"*"} + } else if bashTool == false { + // bash is false - disable the tool by removing it + delete(tools, "bash") + } else if bashArray, ok := bashTool.([]any); ok { + // bash is an array - merge default commands with custom commands + if len(bashArray) > 0 { + // Create a set to track existing commands to avoid duplicates + existingCommands := make(map[string]bool) + for _, cmd := range bashArray { + if cmdStr, ok := cmd.(string); ok { + existingCommands[cmdStr] = true + } + } + + // Start with default commands (append handles capacity automatically) + var mergedCommands []any + for _, cmd := range constants.DefaultBashTools { + if !existingCommands[cmd] { + mergedCommands = append(mergedCommands, cmd) + } + } + + // Add the custom commands + mergedCommands = append(mergedCommands, bashArray...) + tools["bash"] = mergedCommands + } + // Note: bash with empty array (bash: []) means "no bash tools allowed" and is left as-is + } + } + + return tools +} diff --git a/pkg/workflow/trigger_parser.go b/pkg/workflow/trigger_parser.go index 69815e9f08b..bbb16f30fc3 100644 --- a/pkg/workflow/trigger_parser.go +++ b/pkg/workflow/trigger_parser.go @@ -4,9 +4,12 @@ import ( "errors" "fmt" "maps" + "path/filepath" "strings" "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/parser" + "github.com/goccy/go-yaml" ) var triggerParserLog = logger.New("workflow:trigger_parser") @@ -695,3 +698,335 @@ func parseDeploymentTrigger(input string) (*TriggerIR, error) { Conditions: []string{condition}, }, nil } + +func mergeCommandOtherEvents(existing map[string]any, incoming map[string]any) map[string]any { + if len(existing) == 0 { + return incoming + } + if len(incoming) == 0 { + return existing + } + merged := maps.Clone(existing) + for eventName, incomingValue := range incoming { + if existingValue, hasExisting := merged[eventName]; hasExisting { + merged[eventName] = mergeEventConfig(existingValue, incomingValue) + continue + } + merged[eventName] = incomingValue + } + return merged +} + +func mergeEventConfig(existing any, incoming any) any { + existingMap, existingOK := existing.(map[string]any) + incomingMap, incomingOK := incoming.(map[string]any) + if !existingOK || !incomingOK { + return incoming + } + merged := maps.Clone(existingMap) + maps.Copy(merged, incomingMap) + + existingTypes, existingTypesOK := parseEventTypes(existingMap["types"]) + incomingTypes, incomingTypesOK := parseEventTypes(incomingMap["types"]) + if existingTypesOK && incomingTypesOK { + seen := make(map[string]bool, safeAllocationCapacity(len(existingTypes), len(incomingTypes))) + combined := make([]string, 0, safeAllocationCapacity(len(existingTypes), len(incomingTypes))) + for _, eventType := range existingTypes { + if !seen[eventType] { + seen[eventType] = true + combined = append(combined, eventType) + } + } + for _, eventType := range incomingTypes { + if !seen[eventType] { + seen[eventType] = true + combined = append(combined, eventType) + } + } + merged["types"] = combined + } + + return merged +} + +func parseEventTypes(value any) ([]string, bool) { + switch typed := value.(type) { + case []string: + return typed, true + case []any: + out := make([]string, 0, len(typed)) + for _, entry := range typed { + entryStr, ok := entry.(string) + if !ok { + return nil, false + } + out = append(out, entryStr) + } + return out, true + default: + return nil, false + } +} + +// parseOnSection handles parsing of the "on" section from frontmatter, extracting command triggers, +// reactions, and stop-after configurations while detecting conflicts with other event types. +func (c *Compiler) parseOnSection(frontmatter map[string]any, workflowData *WorkflowData, markdownPath string) error { + triggerParserLog.Printf("Parsing on section: workflow=%s, markdownPath=%s", workflowData.Name, markdownPath) + // Check if "slash_command" or "command" (deprecated) is used as a trigger in the "on" section + // Also extract "reaction" from the "on" section + var hasCommand bool + var hasLabelCommand bool + var hasReaction bool + var hasStopAfter bool + var hasStatusComment bool + var otherEvents map[string]any + + // Use cached On field from ParsedFrontmatter if available, otherwise fall back to map access + var onValue any + var exists bool + if workflowData.ParsedFrontmatter != nil && workflowData.ParsedFrontmatter.On != nil { + onValue = workflowData.ParsedFrontmatter.On + exists = true + } else { + onValue, exists = frontmatter["on"] + } + + if exists { + // Check for new format: on.slash_command/on.command and on.reaction + if onMap, ok := onValue.(map[string]any); ok { + // Check for stop-after in the on section + if _, hasStopAfterKey := onMap["stop-after"]; hasStopAfterKey { + hasStopAfter = true + } + + // Extract reaction from on section + if reactionValue, hasReactionField := onMap["reaction"]; hasReactionField { + hasReaction = true + reactionStr, reactionIssues, reactionPullRequests, reactionDiscussions, err := parseReactionConfig(reactionValue) + if err != nil { + return err + } + // Validate reaction value + if !isValidReaction(reactionStr) { + return fmt.Errorf("invalid reaction value '%s': must be one of %v", reactionStr, getValidReactions()) + } + // Set AIReaction even if it's "none" - "none" explicitly disables reactions + workflowData.AIReaction = reactionStr + workflowData.ReactionIssues = reactionIssues + workflowData.ReactionPullRequests = reactionPullRequests + workflowData.ReactionDiscussions = reactionDiscussions + } + + // Extract status-comment from on section + if statusCommentValue, hasStatusCommentField := onMap["status-comment"]; hasStatusCommentField { + hasStatusComment = true + if statusCommentBool, ok := statusCommentValue.(bool); ok { + workflowData.StatusComment = &statusCommentBool + triggerParserLog.Printf("status-comment set to: %v", statusCommentBool) + } else if statusCommentMap, ok := statusCommentValue.(map[string]any); ok { + statusCommentIssues := true + if issuesValue, hasIssues := statusCommentMap["issues"]; hasIssues { + issuesBool, ok := issuesValue.(bool) + if !ok { + return fmt.Errorf("status-comment.issues must be a boolean value, got %T", issuesValue) + } + statusCommentIssues = issuesBool + } + + statusCommentPullRequests := true + if pullRequestsValue, hasPullRequests := statusCommentMap["pull-requests"]; hasPullRequests { + pullRequestsBool, ok := pullRequestsValue.(bool) + if !ok { + return fmt.Errorf("status-comment.pull-requests must be a boolean value, got %T", pullRequestsValue) + } + statusCommentPullRequests = pullRequestsBool + } + + statusCommentDiscussions := true + if discussionsValue, hasDiscussions := statusCommentMap["discussions"]; hasDiscussions { + discussionsBool, ok := discussionsValue.(bool) + if !ok { + return fmt.Errorf("status-comment.discussions must be a boolean value, got %T", discussionsValue) + } + statusCommentDiscussions = discussionsBool + } + + statusCommentEnabled := true + workflowData.StatusComment = &statusCommentEnabled + workflowData.StatusCommentIssues = &statusCommentIssues + workflowData.StatusCommentPullRequests = &statusCommentPullRequests + workflowData.StatusCommentDiscussions = &statusCommentDiscussions + if !statusCommentIssues && !statusCommentPullRequests && !statusCommentDiscussions { + return errors.New("status-comment object requires at least one target to be enabled (issues, pull-requests, or discussions)") + } + triggerParserLog.Printf( + "status-comment object set: issues=%v pullRequests=%v discussions=%v", + statusCommentIssues, + statusCommentPullRequests, + statusCommentDiscussions, + ) + } else { + return fmt.Errorf("status-comment must be a boolean or object value, got %T", statusCommentValue) + } + } + + // Extract lock-for-agent from on.issues section + if issuesValue, hasIssues := onMap["issues"]; hasIssues { + if issuesMap, ok := issuesValue.(map[string]any); ok { + if lockForAgent, hasLockForAgent := issuesMap["lock-for-agent"]; hasLockForAgent { + if lockBool, ok := lockForAgent.(bool); ok { + workflowData.LockForAgent = lockBool + triggerParserLog.Printf("lock-for-agent enabled for issues: %v", lockBool) + } + } + } + } + + // Extract lock-for-agent from on.issue_comment section + if issueCommentValue, hasIssueComment := onMap["issue_comment"]; hasIssueComment { + if issueCommentMap, ok := issueCommentValue.(map[string]any); ok { + if lockForAgent, hasLockForAgent := issueCommentMap["lock-for-agent"]; hasLockForAgent { + if lockBool, ok := lockForAgent.(bool); ok { + workflowData.LockForAgent = lockBool + triggerParserLog.Printf("lock-for-agent enabled for issue_comment: %v", lockBool) + } + } + } + } + + if _, hasSlashCommandKey := onMap["slash_command"]; hasSlashCommandKey { + hasCommand = true + // Set default command to filename if not specified in the command section + if len(workflowData.Command) == 0 { + baseName := strings.TrimSuffix(filepath.Base(markdownPath), ".md") + workflowData.Command = []string{baseName} + } + // In centralized mode slash_command no longer compiles broad comment listeners, + // so slash/non-slash event co-existence is allowed. + if !workflowData.CommandCentralized { + // Check for conflicting events (but allow issues/pull_request with non-conflicting types: labeled/unlabeled/ready_for_review) + conflictingEvents := []string{"issues", "issue_comment", "pull_request", "pull_request_review_comment"} + for _, eventName := range conflictingEvents { + if eventValue, hasConflict := onMap[eventName]; hasConflict { + // Special case: allow issues/pull_request with non-conflicting types + if (eventName == "issues" || eventName == "pull_request") && parser.IsNonConflictingCommandEvent(eventValue) { + continue // Allow this - it doesn't conflict with command triggers + } + return fmt.Errorf("cannot use 'slash_command' with '%s' in the same workflow", eventName) + } + } + } + + // Clear the On field so applyDefaults will handle command trigger generation + workflowData.On = "" + } else if _, hasCommandKey := onMap["command"]; hasCommandKey { + hasCommand = true + // Set default command to filename if not specified in the command section + if len(workflowData.Command) == 0 { + baseName := strings.TrimSuffix(filepath.Base(markdownPath), ".md") + workflowData.Command = []string{baseName} + } + // Check for conflicting events (but allow issues/pull_request with non-conflicting types: labeled/unlabeled/ready_for_review) + conflictingEvents := []string{"issues", "issue_comment", "pull_request", "pull_request_review_comment"} + for _, eventName := range conflictingEvents { + if eventValue, hasConflict := onMap[eventName]; hasConflict { + // Special case: allow issues/pull_request with non-conflicting types + if (eventName == "issues" || eventName == "pull_request") && parser.IsNonConflictingCommandEvent(eventValue) { + continue // Allow this - it doesn't conflict with command triggers + } + return fmt.Errorf("cannot use 'command' with '%s' in the same workflow", eventName) + } + } + + // Clear the On field so applyDefaults will handle command trigger generation + workflowData.On = "" + } + + // Detect label_command trigger + if _, hasLabelCommandKey := onMap["label_command"]; hasLabelCommandKey { + hasLabelCommand = true + // Set default label names from WorkflowData if already populated by extractLabelCommandConfig + if len(workflowData.LabelCommand) == 0 { + // extractLabelCommandConfig has not been called yet or returned nothing; + // set a placeholder so applyDefaults knows this is a label-command workflow. + // The actual label names will be extracted from the frontmatter in applyDefaults + // via extractLabelCommandConfig which was called in parseOnSectionRaw. + baseName := strings.TrimSuffix(filepath.Base(markdownPath), ".md") + workflowData.LabelCommand = []string{baseName} + } + // In decentralized mode label_command no longer compiles direct labeled listeners, + // so label/non-label event co-existence is allowed. + if !workflowData.LabelCommandDecentralized { + // Validate: existing issues/pull_request/discussion triggers that have non-label types + // would be silently overridden by the label_command generation. Require label-only types + // (labeled/unlabeled) so the merge is deterministic and user config is not lost. + labelConflictingEvents := []string{"issues", "pull_request", "discussion"} + for _, eventName := range labelConflictingEvents { + if eventValue, hasConflict := onMap[eventName]; hasConflict { + if !parser.IsLabelOnlyEvent(eventValue) { + return fmt.Errorf("cannot use 'label_command' with '%s' trigger (non-label types); use only labeled/unlabeled types or remove this trigger", eventName) + } + } + } + } + // Clear the On field so applyDefaults will handle label-command trigger generation + workflowData.On = "" + } + + // Extract other (non-conflicting) events excluding slash_command, command, label_command, reaction, status-comment, and stop-after + otherEvents = excludeMapKeys(onMap, "slash_command", "command", "label_command", "reaction", "status-comment", "stop-after", "github-token", "github-app", "needs") + } + } + + // Clear command field if no command trigger was found + if !hasCommand { + workflowData.Command = nil + } + + // Clear label-command field if no label_command trigger was found + if !hasLabelCommand { + workflowData.LabelCommand = nil + workflowData.LabelCommandEvents = nil + workflowData.LabelCommandDecentralized = false + } + // Auto-enable "eyes" reaction for slash_command/label_command (and deprecated command) triggers if no explicit reaction was specified + if (hasCommand || hasLabelCommand) && !hasReaction && workflowData.AIReaction == "" { + workflowData.AIReaction = "eyes" + } + + // Auto-enable status-comment for slash_command/label_command (and deprecated command) triggers if not explicitly set + if (hasCommand || hasLabelCommand) && !hasStatusComment && workflowData.StatusComment == nil { + trueVal := true + workflowData.StatusComment = &trueVal + } + + // Store other events for merging in applyDefaults + if hasCommand && len(otherEvents) > 0 { + // We'll store this and handle it in applyDefaults + workflowData.On = "" // This will trigger command handling in applyDefaults + workflowData.CommandOtherEvents = mergeCommandOtherEvents(workflowData.CommandOtherEvents, otherEvents) + } else if hasLabelCommand && len(otherEvents) > 0 { + // Store other events for label-command merging in applyDefaults + workflowData.On = "" // This will trigger label-command handling in applyDefaults + workflowData.LabelCommandOtherEvents = otherEvents + } else if (hasReaction || hasStopAfter || hasStatusComment) && len(otherEvents) > 0 { + // Only re-marshal the "on" if we have to + onEventsYAML, err := yaml.Marshal(map[string]any{"on": otherEvents}) + if err == nil { + yamlStr := strings.TrimSuffix(string(onEventsYAML), "\n") + // Post-process YAML to ensure cron expressions are quoted + yamlStr = parser.QuoteCronExpressions(yamlStr) + // Apply comment processing to filter fields (draft, forks, names) + yamlStr = c.commentOutProcessedFieldsInOnSection(yamlStr, frontmatter) + // Add zizmor ignore comment if workflow_run trigger is present + yamlStr = c.addZizmorIgnoreForWorkflowRun(yamlStr) + // Keep "on" quoted as it's a YAML boolean keyword + workflowData.On = yamlStr + } else { + // Fallback to extracting the original on field (this will include reaction but shouldn't matter for compilation) + workflowData.On = c.extractTopLevelYAMLSection(frontmatter, "on") + } + } + + return nil +}