Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/smoke-drive.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions docs/adr/54656-unify-close-older-config-via-shared-embed.md
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.*
4 changes: 3 additions & 1 deletion pkg/workflow/compiler_safe_outputs_config_handlers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 6 additions & 4 deletions pkg/workflow/compiler_safe_outputs_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
117 changes: 117 additions & 0 deletions pkg/workflow/create_close_older_config_test.go
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{

Copy link
Copy Markdown
Contributor

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.go cover the happy path (alias maps to Enabled/Key) but do not test the absence case: when neither close-older-issues nor close-older-enabled is set, config.CloseOlderConfig.Enabled should be nil.

💡 Suggested additional test cases
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.CloseOlderConfig.Enabled, "Enabled should be nil when not set")
    assert.Empty(t, config.CloseOlderConfig.Key)
}

Also worth testing: close-older-issues: false maps to Enabled = strPtr("false") (not nil), which is what isCloseOlderPullRequestsEnabled depends on to correctly return false.

@copilot please address this.

"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")
}
27 changes: 14 additions & 13 deletions pkg/workflow/create_discussion.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions pkg/workflow/create_entity_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,44 @@ type CreateParseOptions struct {
HandleExpires bool
}

// CloseOlderConfig holds shared close-older settings across create entity handlers.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] close-older-enabled is described as "Internal canonical key" but is a valid public YAML key via the inline embed — any user who writes close-older-enabled: true in their workflow YAML will have it accepted silently.

💡 Options to address the leaky seam

The yaml:"close-older-enabled,omitempty" tag on an inline-embedded field means the key is always accepted in YAML, regardless of intent. Options:

  1. Document it as a supported alias — drop "Internal" from the comment, add it to schema docs.
  2. Validate and reject it — in the preprocess hook, detect close-older-enabled and emit a validation error pointing users to the entity-specific key.
  3. Use the yaml:"-" tag and populate programmatically — keep the field unexported to YAML, set it only through the alias function.

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.
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] setCloseOlderEnabledAlias silently skips when configData is nil, but never removes the source key after copying — if downstream YAML unmarshaling checks for close-older-issues as a bool field and close-older-enabled as a bool field, the same boolean coercion runs twice on the same value.

💡 Why this matters

BoolFields in CreateParseOptions lists both close-older-issues and close-older-enabled. The preprocess step coerces both to string form. Since setCloseOlderEnabledAlias copies the value without deleting the original, the coercion over close-older-issues is redundant — the field no longer exists on the struct, so it is harmless today, but a future handler that re-adds a field named close-older-issues would have it populated unexpectedly.

Consider either:

  • Deleting the source key after aliasing: delete(configData, sourceKey)
  • Or adding a comment explaining why the source key stays (no struct field → harmless orphan).

@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:
Expand Down
13 changes: 7 additions & 6 deletions pkg/workflow/create_issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Loading