-
Notifications
You must be signed in to change notification settings - Fork 501
Unify create-* close-older fields via shared CloseOlderConfig embed
#54656
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
2252d61
beb334f
487a674
727498e
587eeed
b1525ba
5e9aab4
591e10c
8989476
21e4c50
5598aa2
36615c0
774b8e9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,6 +13,44 @@ type CreateParseOptions struct { | |
| HandleExpires bool | ||
| } | ||
|
|
||
| // CloseOlderConfig holds shared close-older settings across create entity handlers. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/codebase-design] 💡 Options to address the leaky seamThe
Option 3 is the cleanest for a truly internal field, but requires a small struct-tag change. @copilot please address this. |
||
| 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. | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/codebase-design] 💡 Why this matters
Consider either:
@copilot please address this. |
||
| // 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: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/tdd] The new tests in
create_close_older_config_test.gocover the happy path (alias maps toEnabled/Key) but do not test the absence case: when neitherclose-older-issuesnorclose-older-enabledis set,config.CloseOlderConfig.Enabledshould benil.💡 Suggested additional test cases
Also worth testing:
close-older-issues: falsemaps toEnabled = strPtr("false")(not nil), which is whatisCloseOlderPullRequestsEnableddepends on to correctly returnfalse.@copilot please address this.