diff --git a/docs/adr/55482-split-safe-outputs-handler-registry-by-domain.md b/docs/adr/55482-split-safe-outputs-handler-registry-by-domain.md new file mode 100644 index 00000000000..705eb02e8ef --- /dev/null +++ b/docs/adr/55482-split-safe-outputs-handler-registry-by-domain.md @@ -0,0 +1,45 @@ +# ADR-55482: Split Safe Outputs Handler Registry by Domain + +**Date**: 2026-08-24 +**Status**: Draft +**Deciders**: pelikhan, copilot-swe-agent + +--- + +### Context + +`pkg/workflow/safe_outputs_handler_registry.go` accumulated all safe-output handler builders in a single monolithic `map[string]handlerBuilder` literal that grew to 1 000+ lines. Reviewing, editing, or adding handlers required navigating a single large file with no logical grouping, making domain-specific changes error-prone and PR reviews difficult. The handlers naturally cluster by GitHub entity: issues, discussions, pull requests, workflow-level actions, projects, assignments, comments, releases, and diagnostics. + +### Decision + +We will decompose the single `handlerRegistry` map into focused sub-registries (one per domain), each in its own file, and compose them at package init time via a new `mergeHandlerMaps(...)` helper in the existing file. The resulting `handlerRegistry` variable and all call sites remain unchanged; only the file layout and initialization sequence change. + +### Alternatives Considered + +#### Alternative 1: Keep the monolith, improve navigation with comments/regions + +Add prominent section dividers and a table-of-contents comment. No structural change; IDE jump-to-definition still works. Rejected because the file continues to grow with each new handler and the readability problem recurs with every future domain. + +#### Alternative 2: Plugin/interface-based extensible registry + +Define a `HandlerProvider` interface; each domain registers via `init()`. Fully decoupled, but requires a registry-registration protocol, initialization-order care, and significantly more boilerplate for a problem that does not require runtime extensibility. Over-engineered for a compile-time-only registry. + +### Consequences + +#### Positive +- Domain handlers can be reviewed, tested, and changed in isolation without touching an unrelated 1 000-line file. +- `mergeHandlerMaps` explicitly detects duplicate handler keys and logs a warning rather than silently overwriting, reducing accidental collision risk. +- Smaller per-file diffs improve PR review clarity. +- New registry test coverage verifies domain membership, full composition, builder enable/disable behavior, duplicate-key handling, and token-helper behavior. + +#### Negative +- `mergeHandlerMaps` applies a first-wins policy on key collisions (logged, not panicked). A future developer who inadvertently registers the same key in two domain files will only see a log line rather than a compile-time or startup error. +- Registry initialization order (the order of arguments to `mergeHandlerMaps`) is now implicit; the order matters for collision resolution but is not enforced by type system or test. + +#### Neutral +- All existing call sites (`handlerRegistry[key]`, `handlerSupportsPerHandlerGitHubAppToken`, etc.) are unchanged; this is a pure internal reorganization. +- The `add_comment` handler lives in `commentHandlerRegistry` because it applies to both issues and discussions; this placement is a documentation convention, not enforced by code. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index d32536b2487..4da807f205c 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -54,1038 +54,28 @@ func handlerSupportsPerHandlerGitHubAppToken(handlerKey string) bool { // 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 - builder := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfTrue("require_temporary_id", c.RequireTemporaryID). - 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). - // Shared CloseOlderConfig.Enabled is remapped here to this handler's - // entity-specific env key name; the other create-* handlers below map the - // same shared field to their own entity-specific keys. - AddTemplatableBool("close_older_issues", c.Enabled). - AddIfNotEmpty("close_older_key", c.Key). - AddTemplatableBool("group_by_day", c.GroupByDay). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "create-issue", c.GitHubToken)). - AddBoolPtr("normalize_closing_keywords", c.NormalizeClosingKeywords). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). - AddTemplatableBoolOrInt("deduplicate_by_title", c.DeduplicateByTitle) - return builder.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). - AddStringSlice("hide_older_comments_match", c.HideOlderCommentsMatch). - AddBoolPtr("discussions", c.Discussions). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). - AddTemplatableStringSlice("allows_comment_ids", c.AllowedCommentIDs). - AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "add-comment", c.GitHubToken)). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddBoolPtr("normalize_closing_keywords", c.NormalizeClosingKeywords). - AddStringSlice("required_labels", c.RequiredLabels). - AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). - // entity-specific env key name per shared CloseOlderConfig field (see create-issue handler above) - AddTemplatableBool("close_older_discussions", c.Enabled). - AddIfNotEmpty("close_older_key", c.Key). - 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", resolveHandlerGitHubToken(c.GitHubApp, "create-discussion", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). - AddStringSlice("allowed_state_reason", c.AllowedStateReason). - AddBoolPtr("allow_body", c.AllowBody). - AddBoolPtr("issue_intent", c.IssueIntent). - AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "close-issue", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). - AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "close-discussion", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). - AddBoolPtr("issue_intent", c.IssueIntent). - 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", resolveHandlerGitHubToken(c.GitHubApp, "add-labels", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "remove-labels", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). - Build() - }, - "replace_label": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.ReplaceLabel == nil { - return nil - } - c := cfg.ReplaceLabel - transitions := make([]map[string]string, len(c.AllowedTransitions)) - for i, t := range c.AllowedTransitions { - transitions[i] = map[string]string{"from": t.From, "to": t.To} - } - config := newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddStringSlice("allowed_add", c.AllowedAdd). - AddStringSlice("allowed_remove", c.AllowedRemove). - AddStringSlice("blocked", c.Blocked). - AddMapSlice("allowed_transitions", transitions). - 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", resolveHandlerGitHubToken(c.GitHubApp, "replace-label", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). - Build() - // If config is empty, it means replace_label 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 make(map[string]any) - } - return config - }, - "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", resolveHandlerGitHubToken(c.GitHubApp, "add-reviewer", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "assign-milestone", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "mark-pull-request-as-ready-for-review", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). - Build() - }, - "approve_workflow_run": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.ApproveWorkflowRun == nil { - return nil - } - c := cfg.ApproveWorkflowRun - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddDefault("comment", c.Comment). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddTemplatableJSONSlice("allowed_pull_requests", c.AllowedPullRequests). - AddStringSlice("allowed_workflows", c.AllowedWorkflows). - AddStringSlice("protected_files", getAllManifestFiles()). - AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). - AddDefault("protect_top_level_dot_folders", true). - AddStringSlice("_protected_files_exclude", c.ProtectedFilesExclude). - AddIfNotEmpty("github-token", resolveApproveWorkflowRunGitHubToken(cfg, c)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). - Build() - }, - "dismiss_pull_request_review": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.DismissPullRequestReview == nil { - return nil - } - c := cfg.DismissPullRequestReview - 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", resolveHandlerGitHubToken(c.GitHubApp, "dismiss-pull-request-review", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "create-code-scanning-alert", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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("target", c.Target). - AddIfNotEmpty("name", c.Name). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) - if c.Output != nil { - builder. - AddIfNotEmpty("output_title", c.Output.Title). - AddIfNotEmpty("output_summary", c.Output.Summary) - } - // Use resolveHandlerGitHubToken so the per-handler github-app pattern is consistent - // with all other handlers: when github-app is set the compiler mints a dedicated - // {key}-app-token step; otherwise fall back to the explicit github-token. - builder.AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "create-check-run", 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", resolveHandlerGitHubToken(c.GitHubApp, "create-agent-session", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "update-issue", c.GitHubToken)). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "update-discussion", c.GitHubToken)). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). - AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "link-sub-issue", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "update-release", c.GitHubToken)). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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("commit_id", c.CommitId). - AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "create-pull-request-review-comment", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "submit-pull-request-review", c.GitHubToken)). - AddStringPtr("footer", getEffectiveFooterString(c.Footer, cfg.Footer)). - AddIfNotEmpty("commit_id", c.CommitId). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "reply-to-pull-request-review-comment", c.GitHubToken)). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "resolve-pull-request-review-thread", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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 := 4096 // default 4096 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). - AddIfTrue("require_temporary_id", c.RequireTemporaryID). - AddIfNotEmpty("branch_prefix", c.BranchPrefix). - AddIfNotEmpty("title_prefix", c.TitlePrefix). - AddTemplatableStringSlice("labels", c.Labels). - AddStringSlice("fallback_labels", c.FallbackLabels). - AddTemplatableStringSlice("reviewers", c.Reviewers). - AddTemplatableStringSlice("team_reviewers", c.TeamReviewers). - AddTemplatableStringSlice("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). - AddIfNotEmpty("head-repo", c.HeadRepoSlug). - 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", resolveHandlerGitHubToken(c.GitHubApp, "create-pull-request", c.GitHubToken)). - AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). - AddBoolPtr("normalize_closing_keywords", c.NormalizeClosingKeywords). - 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). - // entity-specific env key name per shared CloseOlderConfig field (see create-issue handler above) - AddTemplatableBool("close_older_pull_requests", c.Enabled). - AddIfNotEmpty("close_older_key", c.Key). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) - if isPreCreatePullRequestConfigured(c) { - builder. - AddDefault("pre_created_pull_request_number", "${{ needs.activation.outputs.pre_created_pull_request_number }}"). - AddDefault("pre_created_pull_request_url", "${{ needs.activation.outputs.pre_created_pull_request_url }}"). - AddDefault("pre_created_branch", "${{ needs.activation.outputs.pre_created_pull_request_branch }}") - } - // Stacked pull requests are enabled by default; only emit the flag when disabled - // (e.g. GitHub Enterprise Server instances without stacked pull request support). - if !isStackedPullRequestsEnabled(c) { - builder.AddDefault("stacked", false) - } - // Use app-minted token if head-github-app is configured; fall back to head-github-token. - if c.HeadGitHubApp != nil { - //nolint:gosec // G101: False positive - this is a GitHub Actions expression template, not a hardcoded credential - builder.AddIfNotEmpty("head-github-token", "${{ steps.safe-outputs-head-app-token.outputs.token }}") - } else { - builder.AddIfNotEmpty("head-github-token", c.HeadGitHubToken) - } - return builder.Build() - }, - "push_to_pull_request_branch": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.PushToPullRequestBranch == nil { - return nil - } - c := cfg.PushToPullRequestBranch - maxPatchSize := 4096 // default 4096 KB - if cfg.MaximumPatchSize > 0 { - maxPatchSize = cfg.MaximumPatchSize - } - if c.MaxPatchSize > 0 { - maxPatchSize = c.MaxPatchSize - } - builder := 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). - AddIfNotEmpty("head-repo", c.HeadRepoSlug). - AddIfNotEmpty("base_branch", c.BaseBranch). - AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "push-to-pull-request-branch", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). - AddIfTrue("allow_workflows", c.AllowWorkflows) - // Use app-minted token if head-github-app is configured; fall back to head-github-token. - if c.HeadGitHubApp != nil { - //nolint:gosec // G101: False positive - this is a GitHub Actions expression template, not a hardcoded credential - builder.AddIfNotEmpty("head-github-token", "${{ steps.safe-outputs-head-app-token.outputs.token }}") - } else { - builder.AddIfNotEmpty("head-github-token", c.HeadGitHubToken) - } - return builder.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). - AddBoolPtrOrDefault("update_branch_stacks", c.UpdateBranchStacks, true). - 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", resolveHandlerGitHubToken(c.GitHubApp, "update-pull-request", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). - AddIfNotEmpty("target", c.Target). - AddStringSlice("required_labels", c.RequiredLabels).AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddStringSlice("allowed_branches", c.AllowedBranches). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "merge-pull-request", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "close-pull-request", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "hide-comment", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). - AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). - AddTemplatableStringSlice("allowed_refs", c.AllowedRefs) - - // 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) - } +var handlerRegistry = mergeHandlerMaps( + issueHandlerRegistry, + discussionHandlerRegistry, + pullRequestHandlerRegistry, + workflowHandlerRegistry, + projectHandlerRegistry, + assignmentHandlerRegistry, + commentHandlerRegistry, + releaseHandlerRegistry, + diagnosticHandlerRegistry, +) - builder.AddIfNotEmpty("target-ref", c.TargetRef) - builder.AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "dispatch-workflow", c.GitHubToken)) - builder.AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubTokenWithStepID(tool.GitHubApp, dispatchRepositoryToolAppTokenStepID(toolKey), tool.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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.AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "missing-tool", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "missing-data", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "report-incomplete", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "report-incomplete", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). - AddBoolPtr("issue_intent", c.IssueIntent). - 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", resolveHandlerGitHubToken(c.GitHubApp, "assign-to-agent", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "upload-asset", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "upload-artifact", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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) +func mergeHandlerMaps(registries ...map[string]handlerBuilder) map[string]handlerBuilder { + merged := make(map[string]handlerBuilder) + for _, registry := range registries { + for key, builder := range registry { + if _, exists := merged[key]; exists { + handlerRegistryLog.Printf("Duplicate safe outputs handler registry key %q; keeping first builder", key) + continue } - if len(c.Filters.Exclude) > 0 { - b = b.AddStringSlice("filters-exclude", c.Filters.Exclude) - } - } - return b.Build() - }, - "upload_code_coverage": func(cfg *SafeOutputsConfig) map[string]any { - if cfg.UploadCodeCoverage == nil { - return nil - } - c := cfg.UploadCodeCoverage - return newHandlerConfigBuilder(). - AddTemplatableInt("max", c.Max). - AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "upload-code-coverage", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). - 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", resolveHandlerGitHubToken(c.GitHubApp, "autofix-code-scanning-alert", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "create-project", c.GitHubToken)) - if len(c.Views) > 0 { - builder.AddDefault("views", c.Views) - } - if len(c.FieldDefinitions) > 0 { - builder.AddDefault("field_definitions", c.FieldDefinitions) + merged[key] = builder } - builder.AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "update-project", 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.AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "assign-to-user", c.GitHubToken)). - AddTemplatableBool("unassign_first", c.UnassignFirst). - AddBoolPtr("issue_intent", c.IssueIntent). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "unassign-from-user", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "create-project-status-update", c.GitHubToken)). - AddIfNotEmpty("project", c.Project). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). - AddBoolPtr("issue_intent", c.IssueIntent). - AddIfNotEmpty("target", c.Target). - AddIfNotEmpty("target-repo", c.TargetRepoSlug). - AddStringSlice("allowed_repos", c.AllowedRepos). - AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "set-issue-type", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). - AddBoolPtr("issue_intent", c.IssueIntent). - 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", resolveHandlerGitHubToken(c.GitHubApp, "set-issue-field", c.GitHubToken)). - AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). - Build() - if len(config) == 0 { - return make(map[string]any) - } - return config - }, + } + return merged } diff --git a/pkg/workflow/safe_outputs_handler_registry_assignments.go b/pkg/workflow/safe_outputs_handler_registry_assignments.go new file mode 100644 index 00000000000..252da5cf785 --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_assignments.go @@ -0,0 +1,79 @@ +package workflow + +// assignmentHandlerRegistry contains assignment and agent session handler builders. +var assignmentHandlerRegistry = map[string]handlerBuilder{ + "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). + AddBoolPtr("issue_intent", c.IssueIntent). + 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", resolveHandlerGitHubToken(c.GitHubApp, "assign-to-agent", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + 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", resolveHandlerGitHubToken(c.GitHubApp, "assign-to-user", c.GitHubToken)). + AddTemplatableBool("unassign_first", c.UnassignFirst). + AddBoolPtr("issue_intent", c.IssueIntent). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "unassign-from-user", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + 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", resolveHandlerGitHubToken(c.GitHubApp, "create-agent-session", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + Build() + }, +} diff --git a/pkg/workflow/safe_outputs_handler_registry_comments.go b/pkg/workflow/safe_outputs_handler_registry_comments.go new file mode 100644 index 00000000000..b0bb43129c4 --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_comments.go @@ -0,0 +1,42 @@ +package workflow + +// commentHandlerRegistry contains comment handler builders. +var commentHandlerRegistry = map[string]handlerBuilder{ + "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). + AddStringSlice("hide_older_comments_match", c.HideOlderCommentsMatch). + AddBoolPtr("discussions", c.Discussions). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). + AddTemplatableStringSlice("allows_comment_ids", c.AllowedCommentIDs). + AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "add-comment", c.GitHubToken)). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddBoolPtr("normalize_closing_keywords", c.NormalizeClosingKeywords). + AddStringSlice("required_labels", c.RequiredLabels). + AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "hide-comment", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + Build() + }, +} diff --git a/pkg/workflow/safe_outputs_handler_registry_diagnostics.go b/pkg/workflow/safe_outputs_handler_registry_diagnostics.go new file mode 100644 index 00000000000..895ad97fe25 --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_diagnostics.go @@ -0,0 +1,74 @@ +package workflow + +// diagnosticHandlerRegistry contains diagnostic, reporting, and no-op handler builders. +var diagnosticHandlerRegistry = map[string]handlerBuilder{ + "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", resolveHandlerGitHubToken(c.GitHubApp, "missing-tool", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "missing-data", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "report-incomplete", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "report-incomplete", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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() + }, +} diff --git a/pkg/workflow/safe_outputs_handler_registry_discussions.go b/pkg/workflow/safe_outputs_handler_registry_discussions.go new file mode 100644 index 00000000000..d220c6cc847 --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_discussions.go @@ -0,0 +1,74 @@ +package workflow + +// discussionHandlerRegistry contains discussion lifecycle handler builders. +var discussionHandlerRegistry = map[string]handlerBuilder{ + "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). + // entity-specific env key name per shared CloseOlderConfig field (see the create_issue handler in safe_outputs_handler_registry_issues.go) + AddTemplatableBool("close_older_discussions", c.Enabled). + AddIfNotEmpty("close_older_key", c.Key). + 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", resolveHandlerGitHubToken(c.GitHubApp, "create-discussion", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). + AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "close-discussion", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "update-discussion", c.GitHubToken)). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + Build() + }, +} diff --git a/pkg/workflow/safe_outputs_handler_registry_issues.go b/pkg/workflow/safe_outputs_handler_registry_issues.go new file mode 100644 index 00000000000..4adc6512603 --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_issues.go @@ -0,0 +1,235 @@ +package workflow + +// issueHandlerRegistry contains issue lifecycle and metadata handler builders. +var issueHandlerRegistry = map[string]handlerBuilder{ + "create_issue": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreateIssues == nil { + return nil + } + c := cfg.CreateIssues + builder := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfTrue("require_temporary_id", c.RequireTemporaryID). + 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). + // Shared CloseOlderConfig.Enabled is remapped here to this handler's + // entity-specific env key name; the other create-* handlers below map the + // same shared field to their own entity-specific keys. + AddTemplatableBool("close_older_issues", c.Enabled). + AddIfNotEmpty("close_older_key", c.Key). + AddTemplatableBool("group_by_day", c.GroupByDay). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "create-issue", c.GitHubToken)). + AddBoolPtr("normalize_closing_keywords", c.NormalizeClosingKeywords). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + AddTemplatableBoolOrInt("deduplicate_by_title", c.DeduplicateByTitle) + return builder.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). + AddStringSlice("allowed_state_reason", c.AllowedStateReason). + AddBoolPtr("allow_body", c.AllowBody). + AddBoolPtr("issue_intent", c.IssueIntent). + AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "close-issue", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "update-issue", c.GitHubToken)). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). + AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "link-sub-issue", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). + AddBoolPtr("issue_intent", c.IssueIntent). + AddIfNotEmpty("target", c.Target). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "set-issue-type", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). + AddBoolPtr("issue_intent", c.IssueIntent). + 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", resolveHandlerGitHubToken(c.GitHubApp, "set-issue-field", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + Build() + if len(config) == 0 { + return make(map[string]any) + } + return config + }, + "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). + AddBoolPtr("issue_intent", c.IssueIntent). + 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", resolveHandlerGitHubToken(c.GitHubApp, "add-labels", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "remove-labels", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + Build() + }, + "replace_label": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.ReplaceLabel == nil { + return nil + } + c := cfg.ReplaceLabel + transitions := make([]map[string]string, len(c.AllowedTransitions)) + for i, t := range c.AllowedTransitions { + transitions[i] = map[string]string{"from": t.From, "to": t.To} + } + config := newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddStringSlice("allowed_add", c.AllowedAdd). + AddStringSlice("allowed_remove", c.AllowedRemove). + AddStringSlice("blocked", c.Blocked). + AddMapSlice("allowed_transitions", transitions). + 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", resolveHandlerGitHubToken(c.GitHubApp, "replace-label", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + Build() + // If config is empty, it means replace_label 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 make(map[string]any) + } + return config + }, + "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", resolveHandlerGitHubToken(c.GitHubApp, "assign-milestone", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + AddIfTrue("auto_create", c.AutoCreate). + Build() + }, +} diff --git a/pkg/workflow/safe_outputs_handler_registry_projects.go b/pkg/workflow/safe_outputs_handler_registry_projects.go new file mode 100644 index 00000000000..d9eb2afe96c --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_projects.go @@ -0,0 +1,56 @@ +package workflow + +// projectHandlerRegistry contains project board handler builders. +var projectHandlerRegistry = map[string]handlerBuilder{ + "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", resolveHandlerGitHubToken(c.GitHubApp, "create-project", c.GitHubToken)) + if len(c.Views) > 0 { + builder.AddDefault("views", c.Views) + } + if len(c.FieldDefinitions) > 0 { + builder.AddDefault("field_definitions", c.FieldDefinitions) + } + builder.AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "update-project", 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.AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) + return builder.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", resolveHandlerGitHubToken(c.GitHubApp, "create-project-status-update", c.GitHubToken)). + AddIfNotEmpty("project", c.Project). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + Build() + }, +} diff --git a/pkg/workflow/safe_outputs_handler_registry_pull_requests.go b/pkg/workflow/safe_outputs_handler_registry_pull_requests.go new file mode 100644 index 00000000000..a903e74e08a --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_pull_requests.go @@ -0,0 +1,328 @@ +package workflow + +// pullRequestHandlerRegistry contains pull request lifecycle and review handler builders. +var pullRequestHandlerRegistry = map[string]handlerBuilder{ + "create_pull_request": buildCreatePullRequestHandlerConfig, + "push_to_pull_request_branch": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.PushToPullRequestBranch == nil { + return nil + } + c := cfg.PushToPullRequestBranch + maxPatchSize := 4096 // default 4096 KB + if cfg.MaximumPatchSize > 0 { + maxPatchSize = cfg.MaximumPatchSize + } + if c.MaxPatchSize > 0 { + maxPatchSize = c.MaxPatchSize + } + builder := 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). + AddIfNotEmpty("head-repo", c.HeadRepoSlug). + AddIfNotEmpty("base_branch", c.BaseBranch). + AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "push-to-pull-request-branch", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). + AddIfTrue("allow_workflows", c.AllowWorkflows) + // Use app-minted token if head-github-app is configured; fall back to head-github-token. + if c.HeadGitHubApp != nil { + //nolint:gosec // G101: False positive - this is a GitHub Actions expression template, not a hardcoded credential + builder.AddIfNotEmpty("head-github-token", "${{ steps.safe-outputs-head-app-token.outputs.token }}") + } else { + builder.AddIfNotEmpty("head-github-token", c.HeadGitHubToken) + } + return builder.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). + AddBoolPtrOrDefault("update_branch_stacks", c.UpdateBranchStacks, true). + 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", resolveHandlerGitHubToken(c.GitHubApp, "update-pull-request", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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). + AddIfNotEmpty("target", c.Target). + AddStringSlice("required_labels", c.RequiredLabels).AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix).AddStringSlice("allowed_branches", c.AllowedBranches). + AddIfNotEmpty("target-repo", c.TargetRepoSlug). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "merge-pull-request", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "close-pull-request", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + 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", resolveHandlerGitHubToken(c.GitHubApp, "mark-pull-request-as-ready-for-review", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "add-reviewer", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + Build() + }, + "dismiss_pull_request_review": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.DismissPullRequestReview == nil { + return nil + } + c := cfg.DismissPullRequestReview + 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", resolveHandlerGitHubToken(c.GitHubApp, "dismiss-pull-request-review", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "submit-pull-request-review", c.GitHubToken)). + AddStringPtr("footer", getEffectiveFooterString(c.Footer, cfg.Footer)). + AddIfNotEmpty("commit_id", c.CommitId). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "resolve-pull-request-review-thread", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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("commit_id", c.CommitId). + AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "create-pull-request-review-comment", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "reply-to-pull-request-review-comment", c.GitHubToken)). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + Build() + }, +} + +func buildCreatePullRequestHandlerConfig(cfg *SafeOutputsConfig) map[string]any { + if cfg.CreatePullRequests == nil { + return nil + } + c := cfg.CreatePullRequests + builder := newCreatePullRequestHandlerConfigBuilder(cfg, c) + if isPreCreatePullRequestConfigured(c) { + builder. + AddDefault("pre_created_pull_request_number", "${{ needs.activation.outputs.pre_created_pull_request_number }}"). + AddDefault("pre_created_pull_request_url", "${{ needs.activation.outputs.pre_created_pull_request_url }}"). + AddDefault("pre_created_branch", "${{ needs.activation.outputs.pre_created_pull_request_branch }}") + } + // Stacked pull requests are enabled by default; only emit the flag when disabled + // (e.g. GitHub Enterprise Server instances without stacked pull request support). + if !isStackedPullRequestsEnabled(c) { + builder.AddDefault("stacked", false) + } + // Use app-minted token if head-github-app is configured; fall back to head-github-token. + if c.HeadGitHubApp != nil { + //nolint:gosec // G101: False positive - this is a GitHub Actions expression template, not a hardcoded credential + builder.AddIfNotEmpty("head-github-token", "${{ steps.safe-outputs-head-app-token.outputs.token }}") + } else { + builder.AddIfNotEmpty("head-github-token", c.HeadGitHubToken) + } + return builder.Build() +} + +func newCreatePullRequestHandlerConfigBuilder(cfg *SafeOutputsConfig, c *CreatePullRequestsConfig) *handlerConfigBuilder { + maxPatchSize := createPullRequestMaxPatchSize(cfg, c) + maxPatchFiles := createPullRequestMaxPatchFiles(cfg, c) + protectedFilesPolicy := createPullRequestProtectedFilesPolicy(c) + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfTrue("require_temporary_id", c.RequireTemporaryID). + AddIfNotEmpty("branch_prefix", c.BranchPrefix). + AddIfNotEmpty("title_prefix", c.TitlePrefix). + AddTemplatableStringSlice("labels", c.Labels). + AddStringSlice("fallback_labels", c.FallbackLabels). + AddTemplatableStringSlice("reviewers", c.Reviewers). + AddTemplatableStringSlice("team_reviewers", c.TeamReviewers). + AddTemplatableStringSlice("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). + AddIfNotEmpty("head-repo", c.HeadRepoSlug). + 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", resolveHandlerGitHubToken(c.GitHubApp, "create-pull-request", c.GitHubToken)). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddBoolPtr("normalize_closing_keywords", c.NormalizeClosingKeywords). + 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). + // entity-specific env key name per shared CloseOlderConfig field (see the create_issue handler in safe_outputs_handler_registry_issues.go) + AddTemplatableBool("close_older_pull_requests", c.Enabled). + AddIfNotEmpty("close_older_key", c.Key). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) +} + +func createPullRequestProtectedFilesPolicy(c *CreatePullRequestsConfig) string { + protectedFilesPolicy := "request_review" + if c.ManifestFilesPolicy != nil { + protectedFilesPolicy = *c.ManifestFilesPolicy + } + return protectedFilesPolicy +} + +func createPullRequestMaxPatchSize(cfg *SafeOutputsConfig, c *CreatePullRequestsConfig) int { + maxPatchSize := 4096 // default 4096 KB + if cfg.MaximumPatchSize > 0 { + maxPatchSize = cfg.MaximumPatchSize + } + if c.MaxPatchSize > 0 { + maxPatchSize = c.MaxPatchSize + } + return maxPatchSize +} + +func createPullRequestMaxPatchFiles(cfg *SafeOutputsConfig, c *CreatePullRequestsConfig) int { + maxPatchFiles := 100 // default 100 unique files + if cfg.MaximumPatchFiles > 0 { + maxPatchFiles = cfg.MaximumPatchFiles + } + if c.MaxPatchFiles > 0 { + maxPatchFiles = c.MaxPatchFiles + } + return maxPatchFiles +} diff --git a/pkg/workflow/safe_outputs_handler_registry_releases.go b/pkg/workflow/safe_outputs_handler_registry_releases.go new file mode 100644 index 00000000000..b09994c0db1 --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_releases.go @@ -0,0 +1,17 @@ +package workflow + +// releaseHandlerRegistry contains release handler builders. +var releaseHandlerRegistry = map[string]handlerBuilder{ + "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", resolveHandlerGitHubToken(c.GitHubApp, "update-release", c.GitHubToken)). + AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + Build() + }, +} diff --git a/pkg/workflow/safe_outputs_handler_registry_test.go b/pkg/workflow/safe_outputs_handler_registry_test.go new file mode 100644 index 00000000000..e0293d0a4ad --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_test.go @@ -0,0 +1,253 @@ +package workflow + +import ( + "reflect" + "sort" + "testing" +) + +func TestHandlerRegistryDomainComposition(t *testing.T) { + domains := []struct { + name string + registry map[string]handlerBuilder + wantKeys []string + }{ + {name: "issueHandlerRegistry", registry: issueHandlerRegistry, wantKeys: []string{"create_issue", "close_issue", "update_issue", "link_sub_issue", "set_issue_type", "set_issue_field", "add_labels", "remove_labels", "replace_label", "assign_milestone"}}, + {name: "discussionHandlerRegistry", registry: discussionHandlerRegistry, wantKeys: []string{"create_discussion", "close_discussion", "update_discussion"}}, + {name: "pullRequestHandlerRegistry", registry: pullRequestHandlerRegistry, wantKeys: []string{"create_pull_request", "push_to_pull_request_branch", "update_pull_request", "merge_pull_request", "close_pull_request", "mark_pull_request_as_ready_for_review", "add_reviewer", "dismiss_pull_request_review", "submit_pull_request_review", "resolve_pull_request_review_thread", "create_pull_request_review_comment", "reply_to_pull_request_review_comment"}}, + {name: "workflowHandlerRegistry", registry: workflowHandlerRegistry, wantKeys: []string{"approve_workflow_run", "create_code_scanning_alert", "create_check_run", "dispatch_workflow", "dispatch_repository", "call_workflow", "autofix_code_scanning_alert", "upload_code_coverage", "upload_asset", "upload_artifact"}}, + {name: "projectHandlerRegistry", registry: projectHandlerRegistry, wantKeys: []string{"create_project", "update_project", "create_project_status_update"}}, + {name: "assignmentHandlerRegistry", registry: assignmentHandlerRegistry, wantKeys: []string{"assign_to_agent", "assign_to_user", "unassign_from_user", "create_agent_session"}}, + {name: "commentHandlerRegistry", registry: commentHandlerRegistry, wantKeys: []string{"add_comment", "hide_comment"}}, + {name: "releaseHandlerRegistry", registry: releaseHandlerRegistry, wantKeys: []string{"update_release"}}, + {name: "diagnosticHandlerRegistry", registry: diagnosticHandlerRegistry, wantKeys: []string{"missing_tool", "missing_data", "noop", "report_incomplete", "create_report_incomplete_issue"}}, + } + + wantAll := map[string]struct{}{} + for _, domain := range domains { + t.Run(domain.name, func(t *testing.T) { + gotKeys := sortedHandlerKeys(domain.registry) + wantKeys := append([]string(nil), domain.wantKeys...) + sort.Strings(wantKeys) + if !reflect.DeepEqual(gotKeys, wantKeys) { + t.Fatalf("keys mismatch: got %v, want %v", gotKeys, wantKeys) + } + }) + for _, key := range domain.wantKeys { + if _, exists := wantAll[key]; exists { + t.Fatalf("duplicate domain handler key %q", key) + } + wantAll[key] = struct{}{} + } + } + + if len(handlerRegistry) != len(wantAll) { + t.Fatalf("handlerRegistry length = %d, want %d", len(handlerRegistry), len(wantAll)) + } + for key := range wantAll { + if _, exists := handlerRegistry[key]; !exists { + t.Fatalf("handlerRegistry missing %q", key) + } + } +} + +func TestHandlerRegistryBuilders(t *testing.T) { + tests := []struct { + name string + cfg *SafeOutputsConfig + }{ + {name: "create_issue", cfg: &SafeOutputsConfig{CreateIssues: &CreateIssuesConfig{}}}, + {name: "close_issue", cfg: &SafeOutputsConfig{CloseIssues: &CloseIssuesConfig{}}}, + {name: "update_issue", cfg: &SafeOutputsConfig{UpdateIssues: &UpdateIssuesConfig{}}}, + {name: "link_sub_issue", cfg: &SafeOutputsConfig{LinkSubIssue: &LinkSubIssueConfig{}}}, + {name: "set_issue_type", cfg: &SafeOutputsConfig{SetIssueType: &SetIssueTypeConfig{}}}, + {name: "set_issue_field", cfg: &SafeOutputsConfig{SetIssueField: &SetIssueFieldConfig{}}}, + {name: "add_labels", cfg: &SafeOutputsConfig{AddLabels: &AddLabelsConfig{}}}, + {name: "remove_labels", cfg: &SafeOutputsConfig{RemoveLabels: &RemoveLabelsConfig{}}}, + {name: "replace_label", cfg: &SafeOutputsConfig{ReplaceLabel: &ReplaceLabelConfig{}}}, + {name: "assign_milestone", cfg: &SafeOutputsConfig{AssignMilestone: &AssignMilestoneConfig{}}}, + {name: "create_discussion", cfg: &SafeOutputsConfig{CreateDiscussions: &CreateDiscussionsConfig{}}}, + {name: "close_discussion", cfg: &SafeOutputsConfig{CloseDiscussions: &CloseDiscussionsConfig{}}}, + {name: "update_discussion", cfg: &SafeOutputsConfig{UpdateDiscussions: &UpdateDiscussionsConfig{}}}, + {name: "create_pull_request", cfg: &SafeOutputsConfig{CreatePullRequests: &CreatePullRequestsConfig{}}}, + {name: "push_to_pull_request_branch", cfg: &SafeOutputsConfig{PushToPullRequestBranch: &PushToPullRequestBranchConfig{}}}, + {name: "update_pull_request", cfg: &SafeOutputsConfig{UpdatePullRequests: &UpdatePullRequestsConfig{}}}, + {name: "merge_pull_request", cfg: &SafeOutputsConfig{MergePullRequest: &MergePullRequestConfig{}}}, + {name: "close_pull_request", cfg: &SafeOutputsConfig{ClosePullRequests: &ClosePullRequestsConfig{}}}, + {name: "mark_pull_request_as_ready_for_review", cfg: &SafeOutputsConfig{MarkPullRequestAsReadyForReview: &MarkPullRequestAsReadyForReviewConfig{}}}, + {name: "add_reviewer", cfg: &SafeOutputsConfig{AddReviewer: &AddReviewerConfig{}}}, + {name: "dismiss_pull_request_review", cfg: &SafeOutputsConfig{DismissPullRequestReview: &DismissPullRequestReviewConfig{}}}, + {name: "submit_pull_request_review", cfg: &SafeOutputsConfig{SubmitPullRequestReview: &SubmitPullRequestReviewConfig{}}}, + {name: "resolve_pull_request_review_thread", cfg: &SafeOutputsConfig{ResolvePullRequestReviewThread: &ResolvePullRequestReviewThreadConfig{}}}, + {name: "create_pull_request_review_comment", cfg: &SafeOutputsConfig{CreatePullRequestReviewComments: &CreatePullRequestReviewCommentsConfig{}}}, + {name: "reply_to_pull_request_review_comment", cfg: &SafeOutputsConfig{ReplyToPullRequestReviewComment: &ReplyToPullRequestReviewCommentConfig{}}}, + {name: "approve_workflow_run", cfg: &SafeOutputsConfig{ApproveWorkflowRun: &ApproveWorkflowRunConfig{}}}, + {name: "create_code_scanning_alert", cfg: &SafeOutputsConfig{CreateCodeScanningAlerts: &CreateCodeScanningAlertsConfig{}}}, + {name: "create_check_run", cfg: &SafeOutputsConfig{CreateCheckRun: &CreateCheckRunConfig{}}}, + {name: "dispatch_workflow", cfg: &SafeOutputsConfig{DispatchWorkflow: &DispatchWorkflowConfig{}}}, + {name: "dispatch_repository", cfg: &SafeOutputsConfig{DispatchRepository: &DispatchRepositoryConfig{Tools: map[string]*DispatchRepositoryToolConfig{"dispatch": {}}}}}, + {name: "call_workflow", cfg: &SafeOutputsConfig{CallWorkflow: &CallWorkflowConfig{}}}, + {name: "autofix_code_scanning_alert", cfg: &SafeOutputsConfig{AutofixCodeScanningAlert: &AutofixCodeScanningAlertConfig{}}}, + {name: "upload_code_coverage", cfg: &SafeOutputsConfig{UploadCodeCoverage: &UploadCodeCoverageConfig{}}}, + {name: "upload_asset", cfg: &SafeOutputsConfig{UploadAssets: &UploadAssetsConfig{}}}, + {name: "upload_artifact", cfg: &SafeOutputsConfig{UploadArtifact: &UploadArtifactConfig{}}}, + {name: "create_project", cfg: &SafeOutputsConfig{CreateProjects: &CreateProjectsConfig{}}}, + {name: "update_project", cfg: &SafeOutputsConfig{UpdateProjects: &UpdateProjectConfig{}}}, + {name: "create_project_status_update", cfg: &SafeOutputsConfig{CreateProjectStatusUpdates: &CreateProjectStatusUpdateConfig{}}}, + {name: "assign_to_agent", cfg: &SafeOutputsConfig{AssignToAgent: &AssignToAgentConfig{}}}, + {name: "assign_to_user", cfg: &SafeOutputsConfig{AssignToUser: &AssignToUserConfig{}}}, + {name: "unassign_from_user", cfg: &SafeOutputsConfig{UnassignFromUser: &UnassignFromUserConfig{}}}, + {name: "create_agent_session", cfg: &SafeOutputsConfig{CreateAgentSessions: &CreateAgentSessionConfig{}}}, + {name: "add_comment", cfg: &SafeOutputsConfig{AddComments: &AddCommentsConfig{}}}, + {name: "hide_comment", cfg: &SafeOutputsConfig{HideComment: &HideCommentConfig{}}}, + {name: "update_release", cfg: &SafeOutputsConfig{UpdateRelease: &UpdateReleaseConfig{}}}, + {name: "missing_tool", cfg: &SafeOutputsConfig{MissingTool: &MissingToolConfig{}}}, + {name: "missing_data", cfg: &SafeOutputsConfig{MissingData: &MissingDataConfig{}}}, + {name: "noop", cfg: &SafeOutputsConfig{NoOp: &NoOpConfig{}}}, + {name: "report_incomplete", cfg: &SafeOutputsConfig{ReportIncomplete: &ReportIncompleteConfig{}}}, + {name: "create_report_incomplete_issue", cfg: &SafeOutputsConfig{ReportIncomplete: &ReportIncompleteConfig{CreateIssue: strPtr("true")}}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + builder, exists := handlerRegistry[tt.name] + if !exists { + t.Fatalf("handler %q not found", tt.name) + } + if got := builder(&SafeOutputsConfig{}); got != nil { + t.Fatalf("disabled handler returned %v, want nil", got) + } + if got := builder(tt.cfg); got == nil { + t.Fatal("enabled handler returned nil config") + } + }) + } +} + +func TestMergeHandlerMapsKeepsFirstDuplicateKey(t *testing.T) { + first := func(*SafeOutputsConfig) map[string]any { return map[string]any{"source": "first"} } + second := func(*SafeOutputsConfig) map[string]any { return map[string]any{"source": "second"} } + + got := mergeHandlerMaps( + map[string]handlerBuilder{"duplicate": first}, + map[string]handlerBuilder{"duplicate": second}, + ) + + if len(got) != 1 { + t.Fatalf("mergeHandlerMaps length = %d, want 1", len(got)) + } + if got["duplicate"](&SafeOutputsConfig{})["source"] != "first" { + t.Fatal("mergeHandlerMaps did not keep the first duplicate builder") + } +} + +func TestResolveHandlerGitHubToken(t *testing.T) { + app := &GitHubAppConfig{} + + tests := []struct { + name string + app *GitHubAppConfig + handlerKey string + fallback string + want string + wantSupports bool + }{ + { + name: "falls back without app", + handlerKey: "create-issue", + fallback: "fallback-token", + want: "fallback-token", + wantSupports: true, + }, + { + name: "uses per-handler app token for supported handler", + app: app, + handlerKey: "create-issue", + fallback: "fallback-token", + want: "${{ steps.create-issue-app-token.outputs.token }}", + wantSupports: true, + }, + { + name: "falls back for unsupported handler", + app: app, + handlerKey: "missing-tool", + fallback: "fallback-token", + want: "fallback-token", + wantSupports: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolveHandlerGitHubToken(tt.app, tt.handlerKey, tt.fallback); got != tt.want { + t.Fatalf("resolveHandlerGitHubToken() = %q, want %q", got, tt.want) + } + if got := handlerSupportsPerHandlerGitHubAppToken(tt.handlerKey); got != tt.wantSupports { + t.Fatalf("handlerSupportsPerHandlerGitHubAppToken() = %v, want %v", got, tt.wantSupports) + } + }) + } + + if got := handlerSupportsPerHandlerGitHubAppToken("unknown-handler"); got { + t.Fatal("unknown handler unexpectedly supports per-handler GitHub App token") + } + if got := resolveHandlerGitHubTokenWithStepID(app, "custom-token-step", "fallback-token"); got != "${{ steps.custom-token-step.outputs.token }}" { + t.Fatalf("resolveHandlerGitHubTokenWithStepID() = %q", got) + } + if got := resolveHandlerGitHubTokenWithStepID(app, "", "fallback-token"); got != "fallback-token" { + t.Fatalf("resolveHandlerGitHubTokenWithStepID() with empty step = %q, want fallback-token", got) + } +} + +func TestResolveApproveWorkflowRunGitHubToken(t *testing.T) { + app := &GitHubAppConfig{} + + tests := []struct { + name string + cfg *SafeOutputsConfig + config *ApproveWorkflowRunConfig + want string + }{ + { + name: "handler token wins", + cfg: &SafeOutputsConfig{GitHubToken: "global-token"}, + config: &ApproveWorkflowRunConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{GitHubToken: "handler-token"}}, + want: "handler-token", + }, + { + name: "handler app token wins", + cfg: &SafeOutputsConfig{GitHubToken: "global-token"}, + config: &ApproveWorkflowRunConfig{BaseSafeOutputConfig: BaseSafeOutputConfig{GitHubApp: app, GitHubToken: "handler-token"}}, + want: "${{ steps.approve-workflow-run-app-token.outputs.token }}", + }, + { + name: "global app token fallback", + cfg: &SafeOutputsConfig{GitHubApp: app, GitHubToken: "global-token"}, + config: &ApproveWorkflowRunConfig{}, + want: "${{ steps.safe-outputs-app-token.outputs.token }}", + }, + { + name: "global token fallback", + cfg: &SafeOutputsConfig{GitHubToken: "global-token"}, + config: &ApproveWorkflowRunConfig{}, + want: "global-token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := resolveApproveWorkflowRunGitHubToken(tt.cfg, tt.config); got != tt.want { + t.Fatalf("resolveApproveWorkflowRunGitHubToken() = %q, want %q", got, tt.want) + } + }) + } +} + +func sortedHandlerKeys(registry map[string]handlerBuilder) []string { + keys := make([]string, 0, len(registry)) + for key := range registry { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/pkg/workflow/safe_outputs_handler_registry_workflow.go b/pkg/workflow/safe_outputs_handler_registry_workflow.go new file mode 100644 index 00000000000..778d53f33ab --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_workflow.go @@ -0,0 +1,192 @@ +package workflow + +// workflowHandlerRegistry contains CI, workflow-triggering, artifact, and coverage handler builders. +var workflowHandlerRegistry = map[string]handlerBuilder{ + "approve_workflow_run": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.ApproveWorkflowRun == nil { + return nil + } + c := cfg.ApproveWorkflowRun + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddDefault("comment", c.Comment). + AddStringSlice("allowed_repos", c.AllowedRepos). + AddTemplatableJSONSlice("allowed_pull_requests", c.AllowedPullRequests). + AddStringSlice("allowed_workflows", c.AllowedWorkflows). + AddStringSlice("protected_files", getAllManifestFiles()). + AddStringSlice("protected_path_prefixes", getProtectedPathPrefixes()). + AddDefault("protect_top_level_dot_folders", true). + AddStringSlice("_protected_files_exclude", c.ProtectedFilesExclude). + AddIfNotEmpty("github-token", resolveApproveWorkflowRunGitHubToken(cfg, c)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "create-code-scanning-alert", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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("target", c.Target). + AddIfNotEmpty("name", c.Name). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) + if c.Output != nil { + builder. + AddIfNotEmpty("output_title", c.Output.Title). + AddIfNotEmpty("output_summary", c.Output.Summary) + } + // Use resolveHandlerGitHubToken so the per-handler github-app pattern is consistent + // with all other handlers: when github-app is set the compiler mints a dedicated + // {key}-app-token step; otherwise fall back to the explicit github-token. + builder.AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "create-check-run", c.GitHubToken)) + return builder.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). + AddTemplatableStringSlice("allowed_repos", c.AllowedRepos). + AddTemplatableStringSlice("allowed_refs", c.AllowedRefs) + + // 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", resolveHandlerGitHubToken(c.GitHubApp, "dispatch-workflow", c.GitHubToken)) + builder.AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubTokenWithStepID(tool.GitHubApp, dispatchRepositoryToolAppTokenStepID(toolKey), tool.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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.AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)) + return builder.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", resolveHandlerGitHubToken(c.GitHubApp, "autofix-code-scanning-alert", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(c.Staged)). + Build() + }, + "upload_code_coverage": func(cfg *SafeOutputsConfig) map[string]any { + if cfg.UploadCodeCoverage == nil { + return nil + } + c := cfg.UploadCodeCoverage + return newHandlerConfigBuilder(). + AddTemplatableInt("max", c.Max). + AddIfNotEmpty("github-token", resolveHandlerGitHubToken(c.GitHubApp, "upload-code-coverage", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "upload-asset", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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", resolveHandlerGitHubToken(c.GitHubApp, "upload-artifact", c.GitHubToken)). + AddTemplatableBool("staged", templatableBoolPtrToStringPtr(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() + }, +}