diff --git a/pkg/workflow/compile_outputs_issue_test.go b/pkg/workflow/compile_outputs_issue_test.go index 384904e0f87..b51c438151b 100644 --- a/pkg/workflow/compile_outputs_issue_test.go +++ b/pkg/workflow/compile_outputs_issue_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/github/gh-aw/pkg/testutil" + "github.com/github/gh-aw/pkg/typeutil" ) // assertTokenInProcessSafeOutputsEnv verifies that a given environment variable name @@ -149,6 +150,7 @@ engine: claude strict: false safe-outputs: create-issue: + deduplicate-by-title: 1 title-prefix: "[genai] " labels: [copilot, automation] --- @@ -197,6 +199,11 @@ This workflow tests the output configuration parsing. t.Errorf("Expected label '%s' at index %d, got '%s'", expectedLabel, i, workflowData.SafeOutputs.CreateIssues.Labels[i]) } } + + deduplicateByTitle, ok := typeutil.ParseIntValue(workflowData.SafeOutputs.CreateIssues.DeduplicateByTitle) + if !ok || deduplicateByTitle != 1 { + t.Errorf("Expected deduplicate-by-title to parse as 1, got %#v", workflowData.SafeOutputs.CreateIssues.DeduplicateByTitle) + } } func TestOutputConfigEmpty(t *testing.T) { @@ -344,6 +351,7 @@ engine: claude strict: false safe-outputs: create-issue: + deduplicate-by-title: 1 title-prefix: "[genai] " labels: [copilot] --- @@ -410,6 +418,10 @@ This workflow tests the create-issue job generation. t.Error("Expected copilot label in handler config") } + if !strings.Contains(lockContent, `\"deduplicate_by_title\":1`) { + t.Error("Expected deduplicate_by_title in handler config") + } + // Verify job dependencies if !strings.Contains(lockContent, "needs:") { t.Error("Expected safe_outputs job to depend on main job") diff --git a/pkg/workflow/compiler_safe_outputs_builder.go b/pkg/workflow/compiler_safe_outputs_builder.go index c2a5ac15981..d85c7f5f3bc 100644 --- a/pkg/workflow/compiler_safe_outputs_builder.go +++ b/pkg/workflow/compiler_safe_outputs_builder.go @@ -1,6 +1,11 @@ package workflow -import "github.com/github/gh-aw/pkg/logger" +import ( + "math" + + "github.com/github/gh-aw/pkg/logger" + "github.com/github/gh-aw/pkg/typeutil" +) var safeOutputsBuilderLog = logger.New("workflow:safe_outputs_builder") @@ -71,6 +76,33 @@ func (b *handlerConfigBuilder) AddBoolPtr(key string, value *bool) *handlerConfi return b } +// AddBoolOrInt adds a boolean-or-integer field when the value is set. +// This preserves explicit false/0 values, which differ from an omitted field. +func (b *handlerConfigBuilder) AddBoolOrInt(key string, value any) *handlerConfigBuilder { + switch v := value.(type) { + case nil: + return b + case bool: + b.config[key] = v + return b + case float64: + if math.Trunc(v) != v { + safeOutputsBuilderLog.Printf("Ignoring non-integer float for %s: %v", key, v) + return b + } + if intValue, ok := typeutil.ParseIntValue(v); ok { + b.config[key] = intValue + return b + } + } + if intValue, ok := typeutil.ParseIntValue(value); ok { + b.config[key] = intValue + return b + } + safeOutputsBuilderLog.Printf("Ignoring unsupported bool-or-int value for %s: %T", key, value) + return b +} + // AddBoolPtrOrDefault adds a boolean field, using default if pointer is nil func (b *handlerConfigBuilder) AddBoolPtrOrDefault(key string, value *bool, defaultValue bool) *handlerConfigBuilder { if value != nil { diff --git a/pkg/workflow/create_issue.go b/pkg/workflow/create_issue.go index 4f09c97b05d..3e06c219d92 100644 --- a/pkg/workflow/create_issue.go +++ b/pkg/workflow/create_issue.go @@ -13,17 +13,18 @@ type CreateIssuesConfig struct { BaseSafeOutputConfig `yaml:",inline"` TitlePrefix string `yaml:"title-prefix,omitempty"` Labels []string `yaml:"labels,omitempty"` - AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones). - AllowedFields []string `yaml:"allowed-fields,omitempty"` // Optional list of allowed issue field names. If omitted or empty, any issue fields are allowed. Use ["*"] to explicitly allow all. - Assignees []string `yaml:"assignees,omitempty"` // List of users/bots to assign the issue to - 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) - Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. + AllowedLabels []string `yaml:"allowed-labels,omitempty"` // Optional list of allowed labels. If omitted, any labels are allowed (including creating new ones). + AllowedFields []string `yaml:"allowed-fields,omitempty"` // Optional list of allowed issue field names. If omitted or empty, any issue fields are allowed. Use ["*"] to explicitly allow all. + Assignees []string `yaml:"assignees,omitempty"` // List of users/bots to assign the issue to + DeduplicateByTitle any `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. + 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) + Footer *string `yaml:"footer,omitempty"` // Controls whether AI-generated footer is added. When false, visible footer is omitted but XML markers are kept. } // parseCreateIssuesConfig handles create-issue configuration diff --git a/pkg/workflow/safe_outputs_config.go b/pkg/workflow/safe_outputs_config.go index 8c205adec19..7395051719c 100644 --- a/pkg/workflow/safe_outputs_config.go +++ b/pkg/workflow/safe_outputs_config.go @@ -784,7 +784,7 @@ var handlerRegistry = map[string]handlerBuilder{ return nil } c := cfg.CreateIssues - return newHandlerConfigBuilder(). + builder := newHandlerConfigBuilder(). AddTemplatableInt("max", c.Max). AddStringSlice("allowed_labels", c.AllowedLabels). AddStringSlice("allowed_fields", c.AllowedFields). @@ -801,7 +801,8 @@ var handlerRegistry = map[string]handlerBuilder{ AddTemplatableBool("footer", getEffectiveFooterForTemplatable(c.Footer, cfg.Footer)). AddIfNotEmpty("github-token", c.GitHubToken). AddIfTrue("staged", c.Staged). - Build() + AddBoolOrInt("deduplicate_by_title", c.DeduplicateByTitle) + return builder.Build() }, "add_comment": func(cfg *SafeOutputsConfig) map[string]any { if cfg.AddComments == nil {