Skip to content
Merged
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
26 changes: 26 additions & 0 deletions actions/setup/js/create_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,32 @@ describe("create_issue", () => {
expect(mockGithub.rest.issues.create).toHaveBeenCalledTimes(1);
});

it("should accept stringified boolean and integer deduplication values from workflow expressions", async () => {
const exactMatchHandler = await main({
deduplicate_by_title: "true",
});

const firstExact = await exactMatchHandler({ title: "Duplicate title" });
const secondExact = await exactMatchHandler({ title: "Duplicate title" });

expect(firstExact.success).toBe(true);
expect(secondExact.success).toBe(true);
expect(secondExact.dropped_duplicate).toBe(true);
expect(secondExact.dedup_source).toBe("within-run");

const fuzzyHandler = await main({
deduplicate_by_title: "1",
});

const firstFuzzy = await fuzzyHandler({ title: "Fix login bug" });
const secondFuzzy = await fuzzyHandler({ title: "Fix login bag" });

expect(firstFuzzy.success).toBe(true);
expect(secondFuzzy.success).toBe(true);
expect(secondFuzzy.dropped_duplicate).toBe(true);
expect(secondFuzzy.duplicate_distance).toBe(1);
});

it("should drop duplicates that already exist in the repository", async () => {
mockGithub.rest.search.issuesAndPullRequests
.mockResolvedValueOnce({
Expand Down
11 changes: 10 additions & 1 deletion actions/setup/js/issue_title_dedup.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,19 @@ function parseDeduplicateByTitle(value) {
if (value === true) {
return { enabled: true, maxDistance: 0 };
}
if (value === "false") {
return { enabled: false, maxDistance: 0 };
}
if (value === "true") {
return { enabled: true, maxDistance: 0 };
}
if (typeof value === "string" && /^\d+$/.test(value)) {
value = Number.parseInt(value, 10);
}
if (typeof value === "number" && Number.isFinite(value) && Number.isInteger(value) && value >= 0 && value <= MAX_DEDUPLICATE_BY_TITLE_DISTANCE) {
return { enabled: true, maxDistance: value };
}
throw new Error(`deduplicate-by-title must be a boolean or a non-negative integer (0-${MAX_DEDUPLICATE_BY_TITLE_DISTANCE})`);
throw new Error(`deduplicate-by-title must be a boolean, a boolean-like string, or a non-negative integer (0-${MAX_DEDUPLICATE_BY_TITLE_DISTANCE})`);
}

/**
Expand Down
44 changes: 44 additions & 0 deletions docs/adr/43345-templatable-bool-or-int-type-for-config-fields.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ADR-43345: Introduce TemplatableBoolOrInt for Config Fields Accepting Boolean, Integer, or GitHub Actions Expressions

**Date**: 2026-07-04
**Status**: Draft
**Deciders**: Unknown

---

### Context

The `deduplicate-by-title` field in `CreateIssuesConfig` was typed as `any`, which caused the field's value to be silently dropped when building the emitted handler config for the `create_issue` safe output. Additionally, users need to pass GitHub Actions expression strings (e.g., `${{ inputs.dedup }}`) to this field so that deduplication mode can be configured dynamically at runtime; the previous `any` type and schema definition only accepted booleans and integers, rejecting expression strings at validation time. A type-safe, serialization-aware representation was needed to correctly parse all three input forms (boolean, integer 0–100, or expression string) and emit them as their appropriate JSON types (JSON boolean, JSON number, or JSON string respectively).

### Decision

We decided to introduce a new string-backed type `TemplatableBoolOrInt` in `pkg/workflow/templatables.go` with custom YAML and JSON marshaling. The type stores all three forms internally as a Go string (`"true"`, `"false"`, a decimal integer, or a `${{ … }}` expression), then emits the correct native JSON value via `MarshalJSON`/`ToValue`. `CreateIssuesConfig.DeduplicateByTitle` was changed from `any` to `*TemplatableBoolOrInt`, and the handler registry was updated to call a new `AddTemplatableBoolOrInt` builder method, completing the emission path that was previously silently broken.

### Alternatives Considered

#### Alternative 1: Keep `any` type and add post-parse type-switch conversion

The `AddBoolOrInt` builder method already performs a type-switch on `any` values to emit booleans or integers. The emission bug could have been fixed by simply wiring `DeduplicateByTitle` into `AddBoolOrInt` without changing the field type. However, `any` carries no type constraints at parse time, so schema validation alone blocks expression strings from entering the field. Expression strings would have required additional ad-hoc handling that bypasses the type system and is invisible in struct definitions.

#### Alternative 2: Explicit union struct (`struct { Bool *bool; Int *int; Expr *string }`)

A struct with three optional fields makes the valid value space explicit in the type system with no custom marshalers beyond implementing `yaml.Unmarshaler`. This is more verbose and requires callers to inspect which field is set. It also complicates the JSON schema `$defs` entry and adds branching in every emission site. The string-backed approach centralises all conversion logic inside the type and allows the schema to reference a single `$defs/templatable_bool_or_int` entry.

### Consequences

#### Positive
- `deduplicate-by-title` is now correctly emitted in the handler config for all three input forms, fixing the silent-drop bug that caused deduplication to never activate when configured.
- Users can pass GitHub Actions expression strings (e.g., `${{ inputs.dedup }}`) to `deduplicate-by-title`; the value is stored and emitted as a JSON string for runtime evaluation, enabling dynamic configuration without a schema change.
- The JSON schema `$defs/templatable_bool_or_int` definition is reusable for future fields that need the same three-way acceptance.

#### Negative
- `TemplatableBoolOrInt` introduces a custom string type with non-trivial YAML and JSON unmarshal/marshal logic that must be maintained and kept in sync with the schema definition.
- Integer range validation (0–100) is baked into the type's unmarshal methods, coupling the generic type to a specific field's constraints; reusing the type for a field with a different integer range would require a new type or a parameterised constructor.

#### Neutral
- All existing callsites that previously used `typeutil.ParseIntValue` to read `DeduplicateByTitle` have been updated to access the `*TemplatableBoolOrInt` pointer directly.
- Four new unit tests cover the boolean, integer, expression, and nil cases end-to-end through the config-generation pipeline.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
31 changes: 22 additions & 9 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -5146,17 +5146,12 @@
]
},
"deduplicate-by-title": {
"description": "Title-based deduplication for create-issue. Set to true for exact title matching, or provide a non-negative integer to deduplicate by Levenshtein edit distance (e.g., 1 allows one-character differences). Applies within-run and against open/recently-closed repository issues.",
"oneOf": [
{
"type": "boolean"
},
"allOf": [
{
"type": "integer",
"minimum": 0,
"maximum": 100
"$ref": "#/$defs/templatable_bool_or_int"
}
]
],
"description": "Title-based deduplication for create-issue. Set to true for exact title matching, or provide a non-negative integer (0–100) to deduplicate by Levenshtein edit distance (e.g., 1 allows one-character differences). Accepts a GitHub Actions expression that resolves to a boolean or integer at runtime. Applies within-run and against open/recently-closed repository issues."

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.

[/diagnosing-bugs] Using both $ref and description on the same schema node may not behave as expected in all JSON Schema validators — the JSON Schema spec says sibling keywords alongside $ref are ignored in draft-07 (which is commonly used by editor tooling).

The description you want will likely be shadowed by the one in the templatable_bool_or_int $defs entry. Consider whether the field-specific description should live in the $defs entry itself, or if you need to use allOf to merge.

💡 Option

Move the field description into the $defs entry, or use allOf:

"deduplicate-by-title": {
  "allOf": [{ "": "#//templatable_bool_or_int" }],
  "description": "Title-based deduplication..."
}

Or keep the description only on the $defs definition and drop the override here.

@copilot please address this.

},
"target-repo": {
"type": "string",
Expand Down Expand Up @@ -11801,6 +11796,24 @@
}
]
},
"templatable_bool_or_int": {
"description": "A boolean or non-negative integer (0–100) value that may also be specified as a GitHub Actions expression string that resolves to a boolean or integer at runtime (e.g. '${{ inputs.dedup }}').",
"oneOf": [
{
"type": "boolean"
},
{
"type": "integer",
"minimum": 0,
"maximum": 100
},
{
"type": "string",
"pattern": "^\\$\\{\\{.*\\}\\}$",
"description": "GitHub Actions expression that resolves to a boolean or integer at runtime"
}
]
},
"positive_integer_with_km_suffix_or_expression": {
"type": "string",
"oneOf": [
Expand Down
167 changes: 163 additions & 4 deletions pkg/workflow/compile_outputs_issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import (
"testing"

"github.com/github/gh-aw/pkg/testutil"
"github.com/github/gh-aw/pkg/typeutil"
)

// assertTokenInProcessSafeOutputsEnv verifies that a given environment variable name
Expand Down Expand Up @@ -200,9 +199,9 @@ This workflow tests the output configuration parsing.
}
}

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)
deduplicateByTitle := workflowData.SafeOutputs.CreateIssues.DeduplicateByTitle
if deduplicateByTitle == nil || string(*deduplicateByTitle) != "1" {
t.Errorf("Expected deduplicate-by-title to parse as '1', got %#v", deduplicateByTitle)
}
}

Expand Down Expand Up @@ -495,3 +494,163 @@ This workflow tests that copilot assignment is wired in consolidated safe output
t.Error("Did not expect legacy assign_copilot_to_created_issues output wiring in safe_outputs job")
}
}

// TestDeduplicateByTitleBoolean verifies that deduplicate-by-title: true is emitted correctly.
func TestDeduplicateByTitleBoolean(t *testing.T) {
tmpDir := testutil.TempDir(t, "dedup-bool-test")

testContent := `---
on: workflow_dispatch
permissions:
contents: read
engine: copilot
strict: false
safe-outputs:
create-issue:
max: 1
deduplicate-by-title: true
---

Create test issues.
`
testFile := filepath.Join(tmpDir, "test-dedup-bool.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler()
if err := compiler.CompileWorkflow(testFile); err != nil {
t.Fatalf("Failed to compile: %v", err)
}

lockContent, err := os.ReadFile(filepath.Join(tmpDir, "test-dedup-bool.lock.yml"))
if err != nil {
t.Fatal(err)
}

if !strings.Contains(string(lockContent), `\"deduplicate_by_title\":true`) {

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 test name says "true/false is emitted correctly" but only true is exercised — false is never verified.

This is a meaningful edge case: false disables deduplication explicitly (vs. omitting the field), and the emission path has a separate code path for it. A regression that emits the string "false" instead of JSON boolean false would go undetected.

💡 Suggested extension

Add a companion test or subtest for deduplicate-by-title: false:

if !strings.Contains(string(lockContent), `\"deduplicate_by_title\":false`) {
    t.Errorf("Expected deduplicate_by_title:false in handler config, got:\n%s", lockContent)
}

@copilot please address this.

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.

Missing false coverage: the docstring says this test verifies true/false but only true is exercised; false (explicit disable) is never tested anywhere in the suite.

💡 Why this matters and what to add

false and nil have distinct semantics: nil omits the field entirely (handler uses its default), while false explicitly passes deduplicate_by_title: false to the handler, disabling deduplication even if the handler would otherwise default it on. The false path through both ToValue() and MarshalJSON() is trivially correct, but untested paths drift silently.

Add a compile-level check alongside the existing true test:

// TestDeduplicateByTitleFalse verifies that deduplicate-by-title: false emits the
// JSON boolean false (not omits the field).
func TestDeduplicateByTitleFalse(t *testing.T) {
    tmpDir := testutil.TempDir(t, "dedup-false-test")
    testContent := `---
on: workflow_dispatch
permissions:
  contents: read
engine: copilot
strict: false
safe-outputs:
  create-issue:
    max: 1
    deduplicate-by-title: false
---

Create test issues.
`
    // ... compile, read lock, assert:
    if !strings.Contains(string(lockContent), `\"deduplicate_by_title\":false`) {
        t.Errorf("Expected deduplicate_by_title:false in handler config, got:\n%s", lockContent)
    }
}

A parallel unit test in safe_outputs_config_generation_test.go (alongside TestGenerateSafeOutputsConfigDeduplicateByTitleBool) would also close the gap at the config-generation layer.

t.Errorf("Expected deduplicate_by_title:true in handler config, got:\n%s", lockContent)
}
}

// TestDeduplicateByTitleFalse verifies that deduplicate-by-title: false is emitted correctly.
func TestDeduplicateByTitleFalse(t *testing.T) {
tmpDir := testutil.TempDir(t, "dedup-false-test")

testContent := `---
on: workflow_dispatch
permissions:
contents: read
engine: copilot
strict: false
safe-outputs:
create-issue:
max: 1
deduplicate-by-title: false
---

Create test issues.
`
testFile := filepath.Join(tmpDir, "test-dedup-false.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler()
if err := compiler.CompileWorkflow(testFile); err != nil {
t.Fatalf("Failed to compile: %v", err)
}

lockContent, err := os.ReadFile(filepath.Join(tmpDir, "test-dedup-false.lock.yml"))
if err != nil {
t.Fatal(err)
}

if !strings.Contains(string(lockContent), `\"deduplicate_by_title\":false`) {
t.Errorf("Expected deduplicate_by_title:false in handler config, got:\n%s", lockContent)
}
}

// TestDeduplicateByTitleInteger verifies that deduplicate-by-title: <int> is emitted correctly.
func TestDeduplicateByTitleInteger(t *testing.T) {
tmpDir := testutil.TempDir(t, "dedup-int-test")

testContent := `---
on: workflow_dispatch
permissions:
contents: read
engine: copilot
strict: false
safe-outputs:
create-issue:
max: 1
deduplicate-by-title: 2
---

Create test issues.
`
testFile := filepath.Join(tmpDir, "test-dedup-int.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler()
if err := compiler.CompileWorkflow(testFile); err != nil {
t.Fatalf("Failed to compile: %v", err)
}

lockContent, err := os.ReadFile(filepath.Join(tmpDir, "test-dedup-int.lock.yml"))
if err != nil {
t.Fatal(err)
}

if !strings.Contains(string(lockContent), `\"deduplicate_by_title\":2`) {
t.Errorf("Expected deduplicate_by_title:2 in handler config, got:\n%s", lockContent)
}
}

// TestDeduplicateByTitleExpression verifies that deduplicate-by-title accepts
// GitHub Actions expressions and emits them as a JSON string so the runtime
// can evaluate them.
func TestDeduplicateByTitleExpression(t *testing.T) {
tmpDir := testutil.TempDir(t, "dedup-expr-test")

testContent := `---
on:
workflow_dispatch:
inputs:
dedup:
type: boolean
default: true
permissions:
contents: read
engine: copilot
strict: false
safe-outputs:
create-issue:
max: 1
deduplicate-by-title: ${{ inputs.dedup }}
---

Create test issues.
`
testFile := filepath.Join(tmpDir, "test-dedup-expr.md")
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
t.Fatal(err)
}

compiler := NewCompiler()
if err := compiler.CompileWorkflow(testFile); err != nil {
t.Fatalf("Failed to compile: %v", err)
}

lockContent, err := os.ReadFile(filepath.Join(tmpDir, "test-dedup-expr.lock.yml"))
if err != nil {
t.Fatal(err)
}

// GH Actions expressions are emitted as JSON strings so they can be evaluated at runtime.
if !strings.Contains(string(lockContent), `\"deduplicate_by_title\":\"${{ inputs.dedup }}\"`) {
t.Errorf("Expected deduplicate_by_title expression in handler config, got:\n%s", lockContent)
}
}
16 changes: 16 additions & 0 deletions pkg/workflow/compiler_safe_outputs_builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,22 @@ func (b *handlerConfigBuilder) AddBoolPtr(key string, value *bool) *handlerConfi
return b
}

// AddTemplatableBoolOrInt adds a TemplatableBoolOrInt field to the handler config.
//
// The stored JSON value depends on the content of *value:
// - "true" → JSON boolean true
// - "false" → JSON boolean false
// - a numeric string (e.g. "1") → JSON number
// - any other string (GitHub Actions expression) → JSON string evaluated at runtime
// - nil → field is omitted
func (b *handlerConfigBuilder) AddTemplatableBoolOrInt(key string, value *TemplatableBoolOrInt) *handlerConfigBuilder {
if value == nil {
return b
}
b.config[key] = value.ToValue()
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 {
Expand Down
30 changes: 15 additions & 15 deletions pkg/workflow/create_issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,21 @@ var createIssueLog = logger.New("workflow:create_issue")
// CreateIssuesConfig holds configuration for creating GitHub issues from agent output
type CreateIssuesConfig struct {
BaseSafeOutputConfig `yaml:",inline"`
TitlePrefix string `yaml:"title-prefix,omitempty"`
RequireTemporaryID bool `yaml:"require-temporary-id,omitempty"` // When true, create_issue tool calls must include temporary_id.
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
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.
TitlePrefix string `yaml:"title-prefix,omitempty"`
RequireTemporaryID bool `yaml:"require-temporary-id,omitempty"` // When true, create_issue tool calls must include temporary_id.
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
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)
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
Expand Down
Loading
Loading