From f3d1fbbfc5ea08db49d5360b1be4397e76b07655 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:18:44 +0000 Subject: [PATCH 1/2] Initial plan From 3f99319c46e6bafd0c562e873dc2a2240349209d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:39:11 +0000 Subject: [PATCH 2/2] Split safe output handler registry Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/safe_outputs_handler_registry.go | 1097 +---------------- ...uts_handler_registry_artifacts_projects.go | 117 ++ ...afe_outputs_handler_registry_automation.go | 121 ++ .../safe_outputs_handler_registry_dispatch.go | 139 +++ .../safe_outputs_handler_registry_issues.go | 347 ++++++ ..._outputs_handler_registry_pull_requests.go | 339 +++++ .../safe_outputs_handler_registry_test.go | 80 ++ pkg/workflow/safe_outputs_handler_tokens.go | 52 + 8 files changed, 1210 insertions(+), 1082 deletions(-) create mode 100644 pkg/workflow/safe_outputs_handler_registry_artifacts_projects.go create mode 100644 pkg/workflow/safe_outputs_handler_registry_automation.go create mode 100644 pkg/workflow/safe_outputs_handler_registry_dispatch.go create mode 100644 pkg/workflow/safe_outputs_handler_registry_issues.go create mode 100644 pkg/workflow/safe_outputs_handler_registry_pull_requests.go create mode 100644 pkg/workflow/safe_outputs_handler_registry_test.go create mode 100644 pkg/workflow/safe_outputs_handler_tokens.go diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index d32536b2487..167b00afc27 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -1,1091 +1,24 @@ package workflow -import "github.com/github/gh-aw/pkg/logger" - -var handlerRegistryLog = logger.New("workflow:safe_outputs_handler_registry") - -// resolveHandlerGitHubToken returns the effective GitHub token expression for a handler. -// When app is non-nil (a per-handler github-app is configured), the compiler has already -// minted a dedicated token step whose ID is "{handlerKey}-app-token"; this function returns -// the expression that references that step's output. Otherwise it falls back to the explicit -// github-token, if any. -// -// The step referenced here is generated by the general per-handler loop in -// buildHandlerManagerStep, which iterates safeOutputHandlers and mints a token for each -// handler whose BaseSafeOutputConfig.GitHubApp is set. -func resolveHandlerGitHubToken(app *GitHubAppConfig, handlerKey, fallbackToken string) string { - if app != nil && handlerSupportsPerHandlerGitHubAppToken(handlerKey) { - handlerRegistryLog.Printf("Using per-handler GitHub App token for %s", handlerKey) - return resolveHandlerGitHubTokenWithStepID(app, handlerKey+"-app-token", fallbackToken) - } - return fallbackToken -} - -// resolveApproveWorkflowRunGitHubToken returns an explicitly configured token for -// workflow-run approval. GitHub's default Actions token cannot approve fork PR -// workflow runs, so this handler must never fall back to it. -func resolveApproveWorkflowRunGitHubToken(cfg *SafeOutputsConfig, config *ApproveWorkflowRunConfig) string { - if token := resolveHandlerGitHubToken(config.GitHubApp, "approve-workflow-run", config.GitHubToken); token != "" { - return token - } - if cfg.GitHubApp != nil { - return "${{ steps.safe-outputs-app-token.outputs.token }}" - } - return cfg.GitHubToken -} - -func resolveHandlerGitHubTokenWithStepID(app *GitHubAppConfig, stepID, fallbackToken string) string { - if app != nil && stepID != "" { - //nolint:gosec // G101: False positive - this is a GitHub Actions expression template, not a hardcoded credential - return "${{ steps." + stepID + ".outputs.token }}" - } - return fallbackToken -} - -func handlerSupportsPerHandlerGitHubAppToken(handlerKey string) bool { - handler, ok := getSafeOutputHandlerByKey(handlerKey) - supported := ok && handler.PermissionBuilder != nil - if !supported { - handlerRegistryLog.Printf("Handler %s does not support per-handler GitHub App tokens (registered=%v)", handlerKey, ok) - } - return supported +import "maps" + +var handlerRegistryCategoryBuilders = []func() map[string]handlerBuilder{ + buildIssueAndDiscussionHandlerRegistry, + buildPullRequestHandlerRegistry, + buildRepositoryAutomationHandlerRegistry, + buildWorkflowDispatchAndReportingHandlerRegistry, + buildArtifactAndProjectHandlerRegistry, } // 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) - } +var handlerRegistry = buildHandlerRegistry() - // 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() - }, - "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) - } - 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) - } - 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 - }, +func buildHandlerRegistry() map[string]handlerBuilder { + registry := make(map[string]handlerBuilder) + for _, buildCategory := range handlerRegistryCategoryBuilders { + maps.Copy(registry, buildCategory()) + } + return registry } diff --git a/pkg/workflow/safe_outputs_handler_registry_artifacts_projects.go b/pkg/workflow/safe_outputs_handler_registry_artifacts_projects.go new file mode 100644 index 00000000000..5074401ce61 --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_artifacts_projects.go @@ -0,0 +1,117 @@ +package workflow + +func buildArtifactAndProjectHandlerRegistry() map[string]handlerBuilder { //nolint:largefunc // Declarative handler registry. + return map[string]handlerBuilder{ + "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() + }, + "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() + }, + // 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) + } + 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_automation.go b/pkg/workflow/safe_outputs_handler_registry_automation.go new file mode 100644 index 00000000000..0e0987943de --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_automation.go @@ -0,0 +1,121 @@ +package workflow + +func buildRepositoryAutomationHandlerRegistry() map[string]handlerBuilder { //nolint:largefunc // Declarative handler registry. + return 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() + }, + "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_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() + }, + "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() + }, + "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() + }, + } +} diff --git a/pkg/workflow/safe_outputs_handler_registry_dispatch.go b/pkg/workflow/safe_outputs_handler_registry_dispatch.go new file mode 100644 index 00000000000..d9f5e34cd43 --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_dispatch.go @@ -0,0 +1,139 @@ +package workflow + +func buildWorkflowDispatchAndReportingHandlerRegistry() map[string]handlerBuilder { //nolint:largefunc // Declarative handler registry. + return map[string]handlerBuilder{ + "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() + }, + "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_issues.go b/pkg/workflow/safe_outputs_handler_registry_issues.go new file mode 100644 index 00000000000..264d5d08ed2 --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_issues.go @@ -0,0 +1,347 @@ +package workflow + +func buildIssueAndDiscussionHandlerRegistry() map[string]handlerBuilder { //nolint:largefunc // Declarative handler registry. + return 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 + }, + "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() + }, + "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() + }, + "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 + }, + } +} 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..36a293cafc5 --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_pull_requests.go @@ -0,0 +1,339 @@ +package workflow + +func buildPullRequestHandlerRegistry() map[string]handlerBuilder { //nolint:largefunc // Declarative handler registry. + return map[string]handlerBuilder{ + "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() + }, + "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_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 { //nolint:largefunc // Existing handler configuration is intentionally kept together. + 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() + }, + } +} 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..f26abd26a53 --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_registry_test.go @@ -0,0 +1,80 @@ +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandlerRegistryCategoriesAssembleCompleteRegistryExactlyOnce(t *testing.T) { + expectedKeys := map[string]struct{}{ + "add_comment": {}, + "add_labels": {}, + "add_reviewer": {}, + "approve_workflow_run": {}, + "assign_milestone": {}, + "assign_to_agent": {}, + "assign_to_user": {}, + "autofix_code_scanning_alert": {}, + "call_workflow": {}, + "close_discussion": {}, + "close_issue": {}, + "close_pull_request": {}, + "create_agent_session": {}, + "create_check_run": {}, + "create_code_scanning_alert": {}, + "create_discussion": {}, + "create_issue": {}, + "create_project": {}, + "create_project_status_update": {}, + "create_pull_request": {}, + "create_pull_request_review_comment": {}, + "create_report_incomplete_issue": {}, + "dismiss_pull_request_review": {}, + "dispatch_repository": {}, + "dispatch_workflow": {}, + "hide_comment": {}, + "link_sub_issue": {}, + "mark_pull_request_as_ready_for_review": {}, + "merge_pull_request": {}, + "missing_data": {}, + "missing_tool": {}, + "noop": {}, + "push_to_pull_request_branch": {}, + "remove_labels": {}, + "replace_label": {}, + "reply_to_pull_request_review_comment": {}, + "report_incomplete": {}, + "resolve_pull_request_review_thread": {}, + "set_issue_field": {}, + "set_issue_type": {}, + "submit_pull_request_review": {}, + "unassign_from_user": {}, + "update_discussion": {}, + "update_issue": {}, + "update_project": {}, + "update_pull_request": {}, + "update_release": {}, + "upload_artifact": {}, + "upload_asset": {}, + "upload_code_coverage": {}, + } + + categoryCounts := make(map[string]int) + for _, buildCategory := range handlerRegistryCategoryBuilders { + for key := range buildCategory() { + categoryCounts[key]++ + } + } + + require.Len(t, handlerRegistry, len(expectedKeys)) + require.Len(t, categoryCounts, len(expectedKeys)) + for key := range expectedKeys { + assert.Equal(t, 1, categoryCounts[key], "handler %q must occur in exactly one category", key) + assert.Contains(t, handlerRegistry, key) + } + for key := range handlerRegistry { + assert.Contains(t, expectedKeys, key) + } +} diff --git a/pkg/workflow/safe_outputs_handler_tokens.go b/pkg/workflow/safe_outputs_handler_tokens.go new file mode 100644 index 00000000000..cf40f569692 --- /dev/null +++ b/pkg/workflow/safe_outputs_handler_tokens.go @@ -0,0 +1,52 @@ +package workflow + +import "github.com/github/gh-aw/pkg/logger" + +var handlerRegistryLog = logger.New("workflow:safe_outputs_handler_registry") + +// resolveHandlerGitHubToken returns the effective GitHub token expression for a handler. +// When app is non-nil (a per-handler github-app is configured), the compiler has already +// minted a dedicated token step whose ID is "{handlerKey}-app-token"; this function returns +// the expression that references that step's output. Otherwise it falls back to the explicit +// github-token, if any. +// +// The step referenced here is generated by the general per-handler loop in +// buildHandlerManagerStep, which iterates safeOutputHandlers and mints a token for each +// handler whose BaseSafeOutputConfig.GitHubApp is set. +func resolveHandlerGitHubToken(app *GitHubAppConfig, handlerKey, fallbackToken string) string { + if app != nil && handlerSupportsPerHandlerGitHubAppToken(handlerKey) { + handlerRegistryLog.Printf("Using per-handler GitHub App token for %s", handlerKey) + return resolveHandlerGitHubTokenWithStepID(app, handlerKey+"-app-token", fallbackToken) + } + return fallbackToken +} + +// resolveApproveWorkflowRunGitHubToken returns an explicitly configured token for +// workflow-run approval. GitHub's default Actions token cannot approve fork PR +// workflow runs, so this handler must never fall back to it. +func resolveApproveWorkflowRunGitHubToken(cfg *SafeOutputsConfig, config *ApproveWorkflowRunConfig) string { + if token := resolveHandlerGitHubToken(config.GitHubApp, "approve-workflow-run", config.GitHubToken); token != "" { + return token + } + if cfg.GitHubApp != nil { + return "${{ steps.safe-outputs-app-token.outputs.token }}" + } + return cfg.GitHubToken +} + +func resolveHandlerGitHubTokenWithStepID(app *GitHubAppConfig, stepID, fallbackToken string) string { + if app != nil && stepID != "" { + //nolint:gosec // G101: False positive - this is a GitHub Actions expression template, not a hardcoded credential + return "${{ steps." + stepID + ".outputs.token }}" + } + return fallbackToken +} + +func handlerSupportsPerHandlerGitHubAppToken(handlerKey string) bool { + handler, ok := getSafeOutputHandlerByKey(handlerKey) + supported := ok && handler.PermissionBuilder != nil + if !supported { + handlerRegistryLog.Printf("Handler %s does not support per-handler GitHub App tokens (registered=%v)", handlerKey, ok) + } + return supported +}