diff --git a/docs/adr/54656-unify-close-older-config-via-shared-embed.md b/docs/adr/54656-unify-close-older-config-via-shared-embed.md new file mode 100644 index 00000000000..3bdf1d3ebf7 --- /dev/null +++ b/docs/adr/54656-unify-close-older-config-via-shared-embed.md @@ -0,0 +1,50 @@ +# ADR-54656: Unify close-older Config Fields via Shared Struct Embed + +**Date**: 2026-08-22 +**Status**: Draft +**Deciders**: pelikhan, copilot-swe-agent + +--- + +### Context + +`create-issue`, `create-discussion`, and `create-pull-request` each independently declared close-older enable (`CloseOlderIssues`, `CloseOlderDiscussions`, `CloseOlderPullRequests`) and key (`CloseOlderKey`) fields on their respective config structs. Downstream handler consumers in `safe_outputs_handler_registry.go` accessed these fields through parallel but structurally identical code paths. Any behavioural change to close-older logic required coordinated edits across three config struct definitions and three consumer call sites, with no compiler enforcement that they stayed in sync. + +### Decision + +We will introduce a shared `CloseOlderConfig` struct in `pkg/workflow/create_entity_helpers.go` containing canonical `Enabled *string` and `Key string` fields, and embed it inline into `CreateIssuesConfig`, `CreateDiscussionsConfig`, and `CreatePullRequestsConfig`. Existing public YAML keys (`close-older-issues`, `close-older-discussions`, `close-older-pull-requests`) are preserved: `Enabled` is tagged `yaml:"-"` so it is never directly settable from workflow frontmatter, and is instead populated after unmarshaling via `closeOlderEnabledFromConfigData`, which reads the already-preprocessed entity-specific value out of the raw config map. All downstream consumers are updated to read from `CloseOlderConfig.Enabled` and `CloseOlderConfig.Key`. + +### Alternatives Considered + +#### Alternative 1: Shared accessor functions without struct consolidation + +Keep per-entity fields on each config struct but introduce a shared interface or helper functions to access them uniformly. This would allow downstream code to call a common accessor rather than field-by-field paths, without changing the struct layout. + +**Why not chosen**: The struct duplication itself is the problem — the accessors would hide it but not eliminate it. New close-older options would still require changes in three places. The embed approach removes that duplication at the type level, giving the compiler the ability to catch missed updates. + +#### Alternative 2: Unified YAML key without backward-compatible aliasing + +Replace the three entity-specific YAML keys with a single `close-older-enabled` key across all handler configs, removing the aliasing step. + +**Why not chosen**: This would be a breaking change for all existing workflow YAML files that use `close-older-issues`, `close-older-discussions`, or `close-older-pull-requests` keys. The aliasing approach achieves consolidation internally while keeping the public API stable, which is required for backward compatibility. + +### Consequences + +#### Positive +- Single source of truth for close-older configuration: `CloseOlderConfig` is defined once and embedded by reference everywhere. +- Downstream consumers (`safe_outputs_handler_registry.go`) read from a uniform path (`c.CloseOlderConfig.Enabled`, `c.CloseOlderConfig.Key`) regardless of the originating handler type. +- New close-older fields only need to be added to `CloseOlderConfig` to propagate across all three create handlers automatically. +- Parser coverage for all three aliasing paths is validated by dedicated tests in `create_close_older_config_test.go`. + +#### Negative +- Post-unmarshal population introduces a small amount of indirection: `Enabled` is not set by YAML unmarshaling directly but by an explicit call to `closeOlderEnabledFromConfigData` in each handler's `postUnmarshal` callback, reading the already-preprocessed entity-specific key. A reader unfamiliar with this pattern may be confused that `CloseOlderConfig.Enabled` has no YAML tag despite being populated from YAML input. +- The embed uses `yaml:",inline"`, which means YAML tags on `CloseOlderConfig` fields coexist in the same namespace as the parent struct's tags; tag conflicts in future fields require care. + +#### Neutral +- Existing per-entity YAML keys continue to work unchanged; no migration of consumer configs is required. +- The `close-older-key` YAML tag is shared across all three handlers through the embed, making its semantics consistent by construction. +- `isCloseOlderPullRequestsEnabled` is updated to dereference `config.CloseOlderConfig.Enabled` instead of the old `config.CloseOlderPullRequests` field. + +--- + +*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* diff --git a/pkg/workflow/compiler_safe_outputs_config_handlers_test.go b/pkg/workflow/compiler_safe_outputs_config_handlers_test.go index 963f057b1a2..ef040a062f0 100644 --- a/pkg/workflow/compiler_safe_outputs_config_handlers_test.go +++ b/pkg/workflow/compiler_safe_outputs_config_handlers_test.go @@ -279,7 +279,9 @@ func TestHandlerConfigBooleanFields(t *testing.T) { name: "close older discussions", safeOutputs: &SafeOutputsConfig{ CreateDiscussions: &CreateDiscussionsConfig{ - CloseOlderDiscussions: strPtr("true"), + CloseOlderConfig: CloseOlderConfig{ + Enabled: strPtr("true"), + }, }, }, checkField: "create_discussion", diff --git a/pkg/workflow/compiler_safe_outputs_config_test.go b/pkg/workflow/compiler_safe_outputs_config_test.go index 7df314ca975..bb35ac9f5ff 100644 --- a/pkg/workflow/compiler_safe_outputs_config_test.go +++ b/pkg/workflow/compiler_safe_outputs_config_test.go @@ -64,10 +64,12 @@ func TestAddHandlerManagerConfigEnvVar(t *testing.T) { BaseSafeOutputConfig: BaseSafeOutputConfig{ Max: strPtr("2"), }, - Category: "general", - TitlePrefix: "[Discussion] ", - Labels: []string{"ai"}, - CloseOlderDiscussions: strPtr("true"), + Category: "general", + TitlePrefix: "[Discussion] ", + Labels: []string{"ai"}, + CloseOlderConfig: CloseOlderConfig{ + Enabled: strPtr("true"), + }, }, }, checkContains: []string{ diff --git a/pkg/workflow/create_close_older_config_test.go b/pkg/workflow/create_close_older_config_test.go new file mode 100644 index 00000000000..3efb3f42d62 --- /dev/null +++ b/pkg/workflow/create_close_older_config_test.go @@ -0,0 +1,117 @@ +//go:build !integration + +package workflow + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseCreateIssuesConfigMapsCloseOlderConfig(t *testing.T) { + compiler := NewCompiler(WithFailFast(true)) + config := compiler.parseCreateIssuesConfig(map[string]any{ + "create-issue": map[string]any{ + "close-older-issues": true, + "close-older-key": "issue-key", + }, + }) + + require.NotNil(t, config) + require.NotNil(t, config.Enabled) + assert.Equal(t, "true", *config.Enabled) + assert.Equal(t, "issue-key", config.Key) +} + +func TestParseCreateDiscussionsConfigMapsCloseOlderConfig(t *testing.T) { + compiler := NewCompiler(WithFailFast(true)) + config := compiler.parseCreateDiscussionsConfig(map[string]any{ + "create-discussion": map[string]any{ + "close-older-discussions": "${{ true }}", + "close-older-key": "discussion-key", + }, + }) + + require.NotNil(t, config) + require.NotNil(t, config.Enabled) + assert.Equal(t, "${{ true }}", *config.Enabled) + assert.Equal(t, "discussion-key", config.Key) +} + +func TestParseCreatePullRequestsConfigMapsCloseOlderConfig(t *testing.T) { + compiler := NewCompiler(WithFailFast(true)) + config := compiler.parseCreatePullRequestsConfig(map[string]any{ + "create-pull-request": map[string]any{ + "close-older-pull-requests": true, + "close-older-key": "pull-request-key", + }, + }) + + require.NotNil(t, config) + require.NotNil(t, config.Enabled) + assert.Equal(t, "true", *config.Enabled) + assert.Equal(t, "pull-request-key", config.Key) +} + +func TestParseCreateIssuesConfigNoCloseOlderWhenAbsent(t *testing.T) { + compiler := NewCompiler(WithFailFast(true)) + config := compiler.parseCreateIssuesConfig(map[string]any{ + "create-issue": map[string]any{}, + }) + + require.NotNil(t, config) + assert.Nil(t, config.Enabled, "Enabled should be nil when not set") + assert.Empty(t, config.Key) +} + +func TestParseCreateIssuesConfigCloseOlderExplicitFalse(t *testing.T) { + compiler := NewCompiler(WithFailFast(true)) + config := compiler.parseCreateIssuesConfig(map[string]any{ + "create-issue": map[string]any{ + "close-older-issues": false, + }, + }) + + require.NotNil(t, config) + require.NotNil(t, config.Enabled, "Enabled should be set (not nil) when explicitly false") + assert.Equal(t, "false", *config.Enabled) +} + +func TestParseCreateDiscussionsConfigNoCloseOlderWhenAbsent(t *testing.T) { + compiler := NewCompiler(WithFailFast(true)) + config := compiler.parseCreateDiscussionsConfig(map[string]any{ + "create-discussion": map[string]any{}, + }) + + require.NotNil(t, config) + assert.Nil(t, config.Enabled, "Enabled should be nil when not set") + assert.Empty(t, config.Key) +} + +func TestParseCreatePullRequestsConfigNoCloseOlderWhenAbsent(t *testing.T) { + compiler := NewCompiler(WithFailFast(true)) + config := compiler.parseCreatePullRequestsConfig(map[string]any{ + "create-pull-request": map[string]any{}, + }) + + require.NotNil(t, config) + assert.Nil(t, config.Enabled, "Enabled should be nil when not set") + assert.Empty(t, config.Key) +} + +// TestParseCreateIssuesConfigCloseOlderEnabledIsNotAPublicKey verifies that the internal +// canonical field name (matching the shared CloseOlderConfig.Enabled tag prior to this +// change) is not accepted directly as a workflow-authored YAML key, since Enabled is now +// tagged yaml:"-" and only populated via closeOlderEnabledFromConfigData. +func TestParseCreateIssuesConfigCloseOlderEnabledIsNotAPublicKey(t *testing.T) { + compiler := NewCompiler(WithFailFast(true)) + config := compiler.parseCreateIssuesConfig(map[string]any{ + "create-issue": map[string]any{ + "close-older-enabled": true, + }, + }) + + require.NotNil(t, config) + assert.Nil(t, config.Enabled, "close-older-enabled must not be a supported public YAML key") +} diff --git a/pkg/workflow/create_discussion.go b/pkg/workflow/create_discussion.go index 203c7515e61..4eae9f86b3d 100644 --- a/pkg/workflow/create_discussion.go +++ b/pkg/workflow/create_discussion.go @@ -14,17 +14,16 @@ var discussionLog = logger.New("workflow:create_discussion") type CreateDiscussionsConfig struct { BaseSafeOutputConfig `yaml:",inline"` SafeOutputAllowedLabelsConfig `yaml:",inline"` - TitlePrefix string `yaml:"title-prefix,omitempty"` - Category string `yaml:"category,omitempty"` // Discussion category ID or name - MinBodyLength int `yaml:"min-body-length,omitempty"` // Minimum required discussion body length before footer/markers - Labels []string `yaml:"labels,omitempty"` // Labels to attach to discussions and match when closing older ones - TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository discussions - AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that discussions can be created in - CloseOlderDiscussions *string `yaml:"close-older-discussions,omitempty"` // When true, close older discussions with same title prefix or labels as outdated - CloseOlderKey string `yaml:"close-older-key,omitempty"` // Optional explicit deduplication key for close-older matching. When set, uses gh-aw-close-key marker instead of workflow-id markers. - RequiredCategory string `yaml:"required-category,omitempty"` // Required category for matching when close-older-discussions is enabled - Expires int `yaml:"expires,omitempty"` // Hours until the discussion expires and should be automatically closed - FallbackToIssue *bool `yaml:"fallback-to-issue,omitempty"` // When true (default), fallback to create-issue if discussion creation fails due to permissions. + TitlePrefix string `yaml:"title-prefix,omitempty"` + Category string `yaml:"category,omitempty"` // Discussion category ID or name + MinBodyLength int `yaml:"min-body-length,omitempty"` // Minimum required discussion body length before footer/markers + Labels []string `yaml:"labels,omitempty"` // Labels to attach to discussions and match when closing older ones + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository discussions + AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that discussions can be created in + CloseOlderConfig `yaml:",inline"` // Shared close-older settings; Enabled is sourced from close-older-discussions. + RequiredCategory string `yaml:"required-category,omitempty"` // Required category for matching when close-older-discussions is enabled + Expires int `yaml:"expires,omitempty"` // Hours until the discussion expires and should be automatically closed + FallbackToIssue *bool `yaml:"fallback-to-issue,omitempty"` // When true (default), fallback to create-issue if discussion creation fails due to permissions. } // parseCreateDiscussionsConfig handles create-discussion configuration @@ -44,7 +43,9 @@ func (c *Compiler) parseCreateDiscussionsConfig(outputMap map[string]any) *Creat return &CreateDiscussionsConfig{} }, nil, - func(_ map[string]any, config *CreateDiscussionsConfig, expiresDisabled bool) { + func(configData map[string]any, config *CreateDiscussionsConfig, expiresDisabled bool) { + config.Enabled = closeOlderEnabledFromConfigData(configData, "close-older-discussions") + // Set default max if not specified if config.Max == nil { config.Max = defaultIntStr(1) @@ -93,7 +94,7 @@ func (c *Compiler) parseCreateDiscussionsConfig(outputMap map[string]any) *Creat if len(config.AllowedRepos) > 0 { discussionLog.Printf("Allowed repos configured: %v", config.AllowedRepos) } - if config.CloseOlderDiscussions != nil { + if config.Enabled != nil { discussionLog.Print("Close older discussions flag set") if config.RequiredCategory != "" { discussionLog.Printf("Required category for close older discussions: %q", config.RequiredCategory) diff --git a/pkg/workflow/create_entity_helpers.go b/pkg/workflow/create_entity_helpers.go index 04e6691c0b6..c85af3cc111 100644 --- a/pkg/workflow/create_entity_helpers.go +++ b/pkg/workflow/create_entity_helpers.go @@ -13,6 +13,44 @@ type CreateParseOptions struct { HandleExpires bool } +// CloseOlderConfig holds shared close-older settings across create entity handlers. +type CloseOlderConfig struct { + // Enabled is intentionally not a YAML field (yaml:"-"): it must not be settable + // directly from workflow frontmatter. It is populated after unmarshaling via + // closeOlderEnabledFromConfigData, keeping each entity's canonical key + // (e.g. close-older-issues) as the sole public YAML surface. + Enabled *string `yaml:"-"` + Key string `yaml:"close-older-key,omitempty"` // Optional explicit deduplication key for close-older matching. When set, uses gh-aw-close-key marker instead of workflow-id markers. +} + +// closeOlderEnabledFromConfigData reads the close-older enabled value from sourceKey in +// configData (normalized to a string form by preprocessBoolFieldAsString, which callers +// must have already run for sourceKey) and returns a pointer suitable for +// CloseOlderConfig.Enabled, or nil when sourceKey was not set. Also tolerates a raw bool +// (e.g. if called before preprocessing) as a defensive fallback. Intended to be called +// from postUnmarshal callbacks, once per entity handler. +func closeOlderEnabledFromConfigData(configData map[string]any, sourceKey string) *string { + if configData == nil { + return nil + } + value, exists := configData[sourceKey] + if !exists { + return nil + } + switch v := value.(type) { + case string: + return &v + case bool: + str := "false" + if v { + str = "true" + } + return &str + default: + return nil + } +} + // parseCreateEntityConfig parses create-* config scaffolding shared by issue/discussion/PR handlers. // // Parameters: diff --git a/pkg/workflow/create_issue.go b/pkg/workflow/create_issue.go index 62b26972075..09150a5ece8 100644 --- a/pkg/workflow/create_issue.go +++ b/pkg/workflow/create_issue.go @@ -20,11 +20,10 @@ type CreateIssuesConfig struct { DeduplicateByTitle *TemplatableBoolOrInt `yaml:"deduplicate-by-title,omitempty"` // When true or 0, deduplicate by exact title match. When set to a positive integer N, also allow fuzzy matches up to edit distance N. When false or omitted, disable title-based deduplication. Accepts GitHub Actions expressions. TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository issues AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that issues can be created in - CloseOlderIssues *string `yaml:"close-older-issues,omitempty"` // When true, close older issues with same title prefix or labels as "not planned" - CloseOlderKey string `yaml:"close-older-key,omitempty"` // Optional explicit deduplication key for close-older matching. When set, uses gh-aw-close-key marker instead of workflow-id markers. - GroupByDay *string `yaml:"group-by-day,omitempty"` // When true, if an open issue was already created today (UTC), post new content as a comment on it instead of creating a duplicate. Works best with close-older-issues: true. - Expires int `yaml:"expires,omitempty"` // Hours until the issue expires and should be automatically closed - Group *string `yaml:"group,omitempty"` // If true, group issues as sub-issues under a parent issue (workflow ID is used as group identifier) + CloseOlderConfig `yaml:",inline"` // Shared close-older settings; Enabled is sourced from close-older-issues. + GroupByDay *string `yaml:"group-by-day,omitempty"` // When true, if an open issue was already created today (UTC), post new content as a comment on it instead of creating a duplicate. Works best with close-older-issues: true. + Expires int `yaml:"expires,omitempty"` // Hours until the issue expires and should be automatically closed + Group *string `yaml:"group,omitempty"` // If true, group issues as sub-issues under a parent issue (workflow ID is used as group identifier) } // parseCreateIssuesConfig handles create-issue configuration @@ -47,7 +46,9 @@ func (c *Compiler) parseCreateIssuesConfig(outputMap map[string]any) *CreateIssu coerceStringOrArrayFields(configData, []string{"assignees"}, createIssueLog) return true }, - func(_ map[string]any, config *CreateIssuesConfig, expiresDisabled bool) { + func(configData map[string]any, config *CreateIssuesConfig, expiresDisabled bool) { + config.Enabled = closeOlderEnabledFromConfigData(configData, "close-older-issues") + // Set default max if not specified if config.Max == nil { config.Max = defaultIntStr(1) diff --git a/pkg/workflow/create_pull_request.go b/pkg/workflow/create_pull_request.go index 1f7832ac11b..fe59a15502f 100644 --- a/pkg/workflow/create_pull_request.go +++ b/pkg/workflow/create_pull_request.go @@ -25,10 +25,10 @@ func getFallbackAsIssue(config *CreatePullRequestsConfig) bool { // value, including GitHub Actions expressions like "${{ ... }}", is treated as enabled. // Used for compile-time permission calculation. func isCloseOlderPullRequestsEnabled(config *CreatePullRequestsConfig) bool { - if config == nil || config.CloseOlderPullRequests == nil { + if config == nil || config.Enabled == nil { return false } - v := *config.CloseOlderPullRequests + v := *config.Enabled return v != "" && v != "false" && v != "0" } @@ -90,48 +90,46 @@ func isTemplatableStagedExpression(value *TemplatableBool) bool { // CreatePullRequestsConfig holds configuration for creating GitHub pull requests from agent output type CreatePullRequestsConfig struct { - BaseSafeOutputConfig `yaml:",inline"` - SafeOutputAllowedLabelsConfig `yaml:",inline"` - BranchPrefix string `yaml:"branch-prefix,omitempty"` // Optional prefix for the pull request branch name (e.g. "signed/"). Applied before the agent-specified or auto-generated branch name. - PreCreate bool `yaml:"pre-create,omitempty"` // Experimental. Pre-create a draft pull request in the activation job and reuse it for the agent output. - TitlePrefix string `yaml:"title-prefix,omitempty"` - RequireTemporaryID bool `yaml:"require-temporary-id,omitempty"` // When true, create_pull_request tool calls must include temporary_id. - Labels []string `yaml:"labels,omitempty"` - Reviewers []string `yaml:"reviewers,omitempty"` // List of users/bots to assign as reviewers to the pull request. Accepts a static list or a single GitHub Actions expression. - TeamReviewers []string `yaml:"team-reviewers,omitempty"` // List of team slugs to assign as team reviewers to the pull request. Accepts a static list or a single GitHub Actions expression. - Assignees []string `yaml:"assignees,omitempty"` // List of users to assign to the created pull request and any fallback issue. Accepts a static list or a single GitHub Actions expression. - FallbackLabels []string `yaml:"fallback-labels,omitempty"` // List of labels to apply to fallback issues created when PR creation cannot proceed. If omitted, fallback issues reuse PR labels. - Draft *string `yaml:"draft,omitempty"` // Pointer to distinguish between unset (nil), literal bool, and expression values - IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn" (default), "error", or "ignore" - AllowEmpty *string `yaml:"allow-empty,omitempty"` // Allow creating PR without patch file or with empty patch (useful for preparing feature branches) - TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository pull requests - HeadRepoSlug string `yaml:"head-repo,omitempty"` // Head repository in format "owner/repo" for fork-backed pull requests; defaults to target-repo when unset - HeadGitHubToken string `yaml:"head-github-token,omitempty"` // GitHub token used for branch writes to the head repository when it differs from the target repo - HeadGitHubApp *GitHubAppConfig `yaml:"-"` // GitHub App used to mint the head token for fork branch writes; parsed manually to support app-id alias - AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that pull requests can be created in (additionally to the target-repo) - AllowedBaseBranches []string `yaml:"allowed-base-branches,omitempty"` // List of allowed base branch globs (e.g. "release/*"). Enables agent-provided `base` override when configured. - AllowedBranches []string `yaml:"allowed-branches,omitempty"` // List of allowed source branch globs (e.g. "feature/*"). Branch in create_pull_request payload must match when configured. - Stacked *bool `yaml:"stacked,omitempty"` // When false, rejects any pull request whose base branch is not the default base branch. Defaults to true; set to false on GitHub Enterprise Server instances without stacked pull request support. - MaxPatchSize int `yaml:"max-patch-size,omitempty"` // Maximum allowed patch size in KB for create-pull-request only. Overrides safe-outputs.max-patch-size when set. - MaxPatchFiles int `yaml:"max-patch-files,omitempty"` // Maximum allowed unique files in create-pull-request patch only. Overrides safe-outputs.max-patch-files when set. - Expires int `yaml:"expires,omitempty"` // Hours until the pull request expires and should be automatically closed (only for same-repo PRs) - AutoMerge *string `yaml:"auto-merge,omitempty"` // Enable auto-merge for the pull request; accepts true/false or merge method strings squash|merge|rebase - BaseBranch string `yaml:"base-branch,omitempty"` // Base branch for the pull request (defaults to github.ref_name if not specified) - - FallbackAsIssue *bool `yaml:"fallback-as-issue,omitempty"` // When true (default), creates an issue if PR creation fails. When false, no fallback occurs and issues: write permission is not requested. - AutoCloseIssue *string `yaml:"auto-close-issue,omitempty"` // Auto-add "Fixes #N" closing keyword when triggered from an issue (default: true). Set to false to prevent auto-closing the triggering issue on PR merge. Accepts a boolean or a GitHub Actions expression. - GithubTokenForExtraEmptyCommit string `yaml:"github-token-for-extra-empty-commit,omitempty"` // Token used to push an empty commit to trigger CI events. Use a PAT or "app" for GitHub App auth. - ManifestFilesPolicy *string `yaml:"protected-files,omitempty"` // Controls protected-file protection: "request_review" (default) creates a PR and submits a REQUEST_CHANGES review, "blocked" hard-blocks, "allowed" permits all changes, and "fallback-to-issue" creates a review issue instead of a PR. - ProtectedFilesExclude []string `yaml:"-"` // Files/prefixes to exclude from the default protected list (from object-form protected-files.exclude). Not sourced from YAML directly; populated during pre-processing. - AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for create. Checked independently of protected-files; both checks must pass. - ExcludedFiles []string `yaml:"excluded-files,omitempty"` // List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. - PreserveBranchName bool `yaml:"preserve-branch-name,omitempty"` // When true, skips the random salt suffix on agent-specified branch names. Invalid characters are still replaced for security; casing is always preserved. Useful when CI enforces branch naming conventions (e.g. Jira keys in uppercase). - RecreateRef bool `yaml:"recreate-ref,omitempty"` // When true (and preserve-branch-name is true), allows the handler to force-delete an existing remote branch ref and recreate it from the agent's local HEAD. When false (default), an existing remote branch causes a fallback to issue (or push_failed). Useful for long-lived reusable branches whose previous PR was merged. - PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "bundle" (default, uses git bundle and preserves merge topology/per-commit metadata) or "am" (uses git format-patch). - SignedCommits *bool `yaml:"signed-commits,omitempty"` // When false, skips GitHub GraphQL signed commits and pushes the local git history directly. Default is true. - AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. - CloseOlderPullRequests *string `yaml:"close-older-pull-requests,omitempty"` // When true, close older open pull requests with the same workflow-id marker when a new one is created. Capped at 10 closures per run. - CloseOlderKey string `yaml:"close-older-key,omitempty"` // Optional explicit deduplication key for close-older matching. When set, uses gh-aw-close-key marker instead of workflow-id markers. + BaseSafeOutputConfig `yaml:",inline"` + SafeOutputAllowedLabelsConfig `yaml:",inline"` + BranchPrefix string `yaml:"branch-prefix,omitempty"` // Optional prefix for the pull request branch name (e.g. "signed/"). Applied before the agent-specified or auto-generated branch name. + PreCreate bool `yaml:"pre-create,omitempty"` // Experimental. Pre-create a draft pull request in the activation job and reuse it for the agent output. + TitlePrefix string `yaml:"title-prefix,omitempty"` + RequireTemporaryID bool `yaml:"require-temporary-id,omitempty"` // When true, create_pull_request tool calls must include temporary_id. + Labels []string `yaml:"labels,omitempty"` + Reviewers []string `yaml:"reviewers,omitempty"` // List of users/bots to assign as reviewers to the pull request. Accepts a static list or a single GitHub Actions expression. + TeamReviewers []string `yaml:"team-reviewers,omitempty"` // List of team slugs to assign as team reviewers to the pull request. Accepts a static list or a single GitHub Actions expression. + Assignees []string `yaml:"assignees,omitempty"` // List of users to assign to the created pull request and any fallback issue. Accepts a static list or a single GitHub Actions expression. + FallbackLabels []string `yaml:"fallback-labels,omitempty"` // List of labels to apply to fallback issues created when PR creation cannot proceed. If omitted, fallback issues reuse PR labels. + Draft *string `yaml:"draft,omitempty"` // Pointer to distinguish between unset (nil), literal bool, and expression values + IfNoChanges string `yaml:"if-no-changes,omitempty"` // Behavior when no changes to push: "warn" (default), "error", or "ignore" + AllowEmpty *string `yaml:"allow-empty,omitempty"` // Allow creating PR without patch file or with empty patch (useful for preparing feature branches) + TargetRepoSlug string `yaml:"target-repo,omitempty"` // Target repository in format "owner/repo" for cross-repository pull requests + HeadRepoSlug string `yaml:"head-repo,omitempty"` // Head repository in format "owner/repo" for fork-backed pull requests; defaults to target-repo when unset + HeadGitHubToken string `yaml:"head-github-token,omitempty"` // GitHub token used for branch writes to the head repository when it differs from the target repo + HeadGitHubApp *GitHubAppConfig `yaml:"-"` // GitHub App used to mint the head token for fork branch writes; parsed manually to support app-id alias + AllowedRepos []string `yaml:"allowed-repos,omitempty"` // List of additional repositories that pull requests can be created in (additionally to the target-repo) + AllowedBaseBranches []string `yaml:"allowed-base-branches,omitempty"` // List of allowed base branch globs (e.g. "release/*"). Enables agent-provided `base` override when configured. + AllowedBranches []string `yaml:"allowed-branches,omitempty"` // List of allowed source branch globs (e.g. "feature/*"). Branch in create_pull_request payload must match when configured. + Stacked *bool `yaml:"stacked,omitempty"` // When false, rejects any pull request whose base branch is not the default base branch. Defaults to true; set to false on GitHub Enterprise Server instances without stacked pull request support. + MaxPatchSize int `yaml:"max-patch-size,omitempty"` // Maximum allowed patch size in KB for create-pull-request only. Overrides safe-outputs.max-patch-size when set. + MaxPatchFiles int `yaml:"max-patch-files,omitempty"` // Maximum allowed unique files in create-pull-request patch only. Overrides safe-outputs.max-patch-files when set. + Expires int `yaml:"expires,omitempty"` // Hours until the pull request expires and should be automatically closed (only for same-repo PRs) + AutoMerge *string `yaml:"auto-merge,omitempty"` // Enable auto-merge for the pull request; accepts true/false or merge method strings squash|merge|rebase + BaseBranch string `yaml:"base-branch,omitempty"` // Base branch for the pull request (defaults to github.ref_name if not specified) + FallbackAsIssue *bool `yaml:"fallback-as-issue,omitempty"` // When true (default), creates an issue if PR creation fails. When false, no fallback occurs and issues: write permission is not requested. + AutoCloseIssue *string `yaml:"auto-close-issue,omitempty"` // Auto-add "Fixes #N" closing keyword when triggered from an issue (default: true). Set to false to prevent auto-closing the triggering issue on PR merge. Accepts a boolean or a GitHub Actions expression. + GithubTokenForExtraEmptyCommit string `yaml:"github-token-for-extra-empty-commit,omitempty"` // Token used to push an empty commit to trigger CI events. Use a PAT or "app" for GitHub App auth. + ManifestFilesPolicy *string `yaml:"protected-files,omitempty"` // Controls protected-file protection: "request_review" (default) creates a PR and submits a REQUEST_CHANGES review, "blocked" hard-blocks, "allowed" permits all changes, and "fallback-to-issue" creates a review issue instead of a PR. + ProtectedFilesExclude []string `yaml:"-"` // Files/prefixes to exclude from the default protected list (from object-form protected-files.exclude). Not sourced from YAML directly; populated during pre-processing. + AllowedFiles []string `yaml:"allowed-files,omitempty"` // Strict allowlist of glob patterns for files eligible for create. Checked independently of protected-files; both checks must pass. + ExcludedFiles []string `yaml:"excluded-files,omitempty"` // List of glob patterns for files to exclude from the patch using git :(exclude) pathspecs. Matching files are stripped by git at generation time and will not appear in the commit or be subject to allowed-files or protected-files checks. + PreserveBranchName bool `yaml:"preserve-branch-name,omitempty"` // When true, skips the random salt suffix on agent-specified branch names. Invalid characters are still replaced for security; casing is always preserved. Useful when CI enforces branch naming conventions (e.g. Jira keys in uppercase). + RecreateRef bool `yaml:"recreate-ref,omitempty"` // When true (and preserve-branch-name is true), allows the handler to force-delete an existing remote branch ref and recreate it from the agent's local HEAD. When false (default), an existing remote branch causes a fallback to issue (or push_failed). Useful for long-lived reusable branches whose previous PR was merged. + PatchFormat string `yaml:"patch-format,omitempty"` // Transport format for packaging changes: "bundle" (default, uses git bundle and preserves merge topology/per-commit metadata) or "am" (uses git format-patch). + SignedCommits *bool `yaml:"signed-commits,omitempty"` // When false, skips GitHub GraphQL signed commits and pushes the local git history directly. Default is true. + AllowWorkflows bool `yaml:"allow-workflows,omitempty"` // When true, adds workflows: write to the GitHub App token. Requires safe-outputs.github-app to be configured. + CloseOlderConfig `yaml:",inline"` // Shared close-older settings; Enabled is sourced from close-older-pull-requests. } // parseCreatePullRequestsConfig handles only create-pull-request (singular) configuration @@ -204,6 +202,8 @@ func (c *Compiler) parseCreatePullRequestsConfig(outputMap map[string]any) *Crea return true }, func(configData map[string]any, config *CreatePullRequestsConfig, expiresDisabled bool) { + config.Enabled = closeOlderEnabledFromConfigData(configData, "close-older-pull-requests") + if expiresDisabled { createPRLog.Print("Pull request expiration disabled") } diff --git a/pkg/workflow/safe_outputs_handler_registry.go b/pkg/workflow/safe_outputs_handler_registry.go index ae3b4305c12..dedb5e7fb1b 100644 --- a/pkg/workflow/safe_outputs_handler_registry.go +++ b/pkg/workflow/safe_outputs_handler_registry.go @@ -72,8 +72,11 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("assignees", c.Assignees). AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddTemplatableBool("group", c.Group). - AddTemplatableBool("close_older_issues", c.CloseOlderIssues). - AddIfNotEmpty("close_older_key", c.CloseOlderKey). + // 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)). @@ -117,8 +120,9 @@ var handlerRegistry = map[string]handlerBuilder{ AddStringSlice("labels", c.Labels). AddStringSlice("allowed_labels", c.AllowedLabels). AddStringSlice("allowed_repos", c.AllowedRepos). - AddTemplatableBool("close_older_discussions", c.CloseOlderDiscussions). - AddIfNotEmpty("close_older_key", c.CloseOlderKey). + // 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). @@ -588,8 +592,9 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfTrue("recreate_ref", c.RecreateRef). AddIfNotEmpty("patch_format", c.PatchFormat). AddBoolPtr("signed_commits", c.SignedCommits). - AddTemplatableBool("close_older_pull_requests", c.CloseOlderPullRequests). - AddIfNotEmpty("close_older_key", c.CloseOlderKey). + // 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 c.PreCreate { builder.