From e8965fb4c2c3f4948aba66790bd3d49ed9ae1028 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 04:39:19 +0000 Subject: [PATCH 1/3] Initial plan From a262b4a67d47adcf940fef0af290510b7ff16676 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 25 Feb 2026 04:53:36 +0000 Subject: [PATCH 2/3] feat: add duplicate state_reason to close-issue safe output - Add state_reason field to close_issue MCP tool spec (safe_outputs_tools.json) with enum values: completed, not_planned, duplicate - Update close_issue.cjs to read state_reason from item (AI output) or config (workflow frontmatter), defaulting to 'completed' - Add StateReason field to CloseEntityConfig Go struct - Include state_reason in close_issue handler config builder - Update documentation to list duplicate as a valid state reason - Add tests for state_reason functionality in close_issue.test.cjs Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/close_issue.cjs | 13 ++- actions/setup/js/close_issue.test.cjs | 105 ++++++++++++++++++ actions/setup/js/safe_outputs_tools.json | 5 + .../content/docs/reference/safe-outputs.md | 3 +- pkg/workflow/close_entity_helpers.go | 1 + pkg/workflow/compiler_safe_outputs_config.go | 1 + 6 files changed, 123 insertions(+), 5 deletions(-) diff --git a/actions/setup/js/close_issue.cjs b/actions/setup/js/close_issue.cjs index 47ca716ab2..7704996ba4 100644 --- a/actions/setup/js/close_issue.cjs +++ b/actions/setup/js/close_issue.cjs @@ -59,14 +59,16 @@ async function addIssueComment(github, owner, repo, issueNumber, message) { * @param {string} owner - Repository owner * @param {string} repo - Repository name * @param {number} issueNumber - Issue number + * @param {string} [stateReason] - The reason for closing: "completed", "not_planned", or "duplicate" * @returns {Promise<{number: number, html_url: string, title: string}>} Issue details */ -async function closeIssue(github, owner, repo, issueNumber) { +async function closeIssue(github, owner, repo, issueNumber, stateReason) { const { data: issue } = await github.rest.issues.update({ owner, repo, issue_number: issueNumber, state: "closed", + state_reason: stateReason || "completed", }); return issue; @@ -83,12 +85,13 @@ async function main(config = {}) { const requiredTitlePrefix = config.required_title_prefix || ""; const maxCount = config.max || 10; const comment = config.comment || ""; + const configStateReason = config.state_reason || "completed"; const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); // Check if we're in staged mode const isStaged = process.env.GH_AW_SAFE_OUTPUTS_STAGED === "true"; - core.info(`Close issue configuration: max=${maxCount}`); + core.info(`Close issue configuration: max=${maxCount}, state_reason=${configStateReason}`); if (requiredLabels.length > 0) { core.info(`Required labels: ${requiredLabels.join(", ")}`); } @@ -259,8 +262,10 @@ async function main(config = {}) { core.info(`Issue #${issueNumber} was already closed, comment added successfully`); closedIssue = issue; } else { - core.info(`Closing issue #${issueNumber} in ${itemRepo}`); - closedIssue = await closeIssue(github, repoParts.owner, repoParts.repo, issueNumber); + // Use item-level state_reason if provided, otherwise fall back to config-level default + const stateReason = item.state_reason || configStateReason; + core.info(`Closing issue #${issueNumber} in ${itemRepo} with state_reason=${stateReason}`); + closedIssue = await closeIssue(github, repoParts.owner, repoParts.repo, issueNumber, stateReason); core.info(`✓ Issue #${issueNumber} closed successfully: ${closedIssue.html_url}`); } diff --git a/actions/setup/js/close_issue.test.cjs b/actions/setup/js/close_issue.test.cjs index 449e69f030..87e105b8f0 100644 --- a/actions/setup/js/close_issue.test.cjs +++ b/actions/setup/js/close_issue.test.cjs @@ -597,5 +597,110 @@ describe("close_issue", () => { expect(updateCalls[0].owner).toBe("github"); expect(updateCalls[0].repo).toBe("gh-aw"); }); + + it("should use default state_reason 'completed' when not specified", async () => { + const handler = await main({ max: 10 }); + const updateCalls = []; + + mockGithub.rest.issues.update = async params => { + updateCalls.push(params); + return { + data: { + number: params.issue_number, + title: "Test Issue", + html_url: `https://github.com/${params.owner}/${params.repo}/issues/${params.issue_number}`, + }, + }; + }; + + const result = await handler({ issue_number: 100, body: "Closing" }, {}); + + expect(result.success).toBe(true); + expect(updateCalls[0].state_reason).toBe("completed"); + }); + + it("should use item-level state_reason 'duplicate' when specified in message", async () => { + const handler = await main({ max: 10 }); + const updateCalls = []; + + mockGithub.rest.issues.update = async params => { + updateCalls.push(params); + return { + data: { + number: params.issue_number, + title: "Test Issue", + html_url: `https://github.com/${params.owner}/${params.repo}/issues/${params.issue_number}`, + }, + }; + }; + + const result = await handler({ issue_number: 100, body: "Duplicate of #50", state_reason: "duplicate" }, {}); + + expect(result.success).toBe(true); + expect(updateCalls[0].state_reason).toBe("duplicate"); + }); + + it("should use item-level state_reason 'not_planned' when specified in message", async () => { + const handler = await main({ max: 10 }); + const updateCalls = []; + + mockGithub.rest.issues.update = async params => { + updateCalls.push(params); + return { + data: { + number: params.issue_number, + title: "Test Issue", + html_url: `https://github.com/${params.owner}/${params.repo}/issues/${params.issue_number}`, + }, + }; + }; + + const result = await handler({ issue_number: 100, body: "Won't fix", state_reason: "not_planned" }, {}); + + expect(result.success).toBe(true); + expect(updateCalls[0].state_reason).toBe("not_planned"); + }); + + it("should use config-level state_reason as default for all closes", async () => { + const handler = await main({ max: 10, state_reason: "duplicate" }); + const updateCalls = []; + + mockGithub.rest.issues.update = async params => { + updateCalls.push(params); + return { + data: { + number: params.issue_number, + title: "Test Issue", + html_url: `https://github.com/${params.owner}/${params.repo}/issues/${params.issue_number}`, + }, + }; + }; + + const result = await handler({ issue_number: 100, body: "Duplicate" }, {}); + + expect(result.success).toBe(true); + expect(updateCalls[0].state_reason).toBe("duplicate"); + }); + + it("should prefer item-level state_reason over config-level default", async () => { + const handler = await main({ max: 10, state_reason: "not_planned" }); + const updateCalls = []; + + mockGithub.rest.issues.update = async params => { + updateCalls.push(params); + return { + data: { + number: params.issue_number, + title: "Test Issue", + html_url: `https://github.com/${params.owner}/${params.repo}/issues/${params.issue_number}`, + }, + }; + }; + + const result = await handler({ issue_number: 100, body: "Duplicate of #50", state_reason: "duplicate" }, {}); + + expect(result.success).toBe(true); + expect(updateCalls[0].state_reason).toBe("duplicate"); + }); }); }); diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 576bbd5529..56140139ac 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -132,6 +132,11 @@ "issue_number": { "type": ["number", "string"], "description": "Issue number to close. This is the numeric ID from the GitHub URL (e.g., 901 in github.com/owner/repo/issues/901). If omitted, closes the issue that triggered this workflow (requires an issue event trigger)." + }, + "state_reason": { + "type": "string", + "enum": ["completed", "not_planned", "duplicate"], + "description": "The reason for closing the issue. Use 'completed' for resolved issues, 'not_planned' for issues that won't be addressed, or 'duplicate' for duplicate issues. Defaults to 'completed'." } }, "additionalProperties": false diff --git a/docs/src/content/docs/reference/safe-outputs.md b/docs/src/content/docs/reference/safe-outputs.md index b64a921dcd..7e07c301e7 100644 --- a/docs/src/content/docs/reference/safe-outputs.md +++ b/docs/src/content/docs/reference/safe-outputs.md @@ -215,11 +215,12 @@ safe-outputs: required-title-prefix: "[bot]" # only close matching prefix max: 20 # max closures (default: 1) target-repo: "owner/repo" # cross-repository + state-reason: "duplicate" # completed (default), not_planned, duplicate ``` **Target**: `"triggering"` (requires issue event), `"*"` (any issue), or number (specific issue). -**State Reasons**: `completed`, `not_planned`, `reopened` (default: `completed`). +**State Reasons**: `completed`, `not_planned`, `duplicate` (default: `completed`). Can also be set per-item in agent output. ### Comment Creation (`add-comment:`) diff --git a/pkg/workflow/close_entity_helpers.go b/pkg/workflow/close_entity_helpers.go index a9f2399390..e00d9f7144 100644 --- a/pkg/workflow/close_entity_helpers.go +++ b/pkg/workflow/close_entity_helpers.go @@ -69,6 +69,7 @@ type CloseEntityConfig struct { SafeOutputTargetConfig `yaml:",inline"` SafeOutputFilterConfig `yaml:",inline"` SafeOutputDiscussionFilterConfig `yaml:",inline"` // Only used for discussions + StateReason string `yaml:"state-reason,omitempty"` // Only used for issues } // CloseEntityJobParams holds the parameters needed to build a close entity job diff --git a/pkg/workflow/compiler_safe_outputs_config.go b/pkg/workflow/compiler_safe_outputs_config.go index c4861df8b6..09ce85b149 100644 --- a/pkg/workflow/compiler_safe_outputs_config.go +++ b/pkg/workflow/compiler_safe_outputs_config.go @@ -197,6 +197,7 @@ var handlerRegistry = map[string]handlerBuilder{ AddIfNotEmpty("required_title_prefix", c.RequiredTitlePrefix). AddIfNotEmpty("target-repo", c.TargetRepoSlug). AddStringSlice("allowed_repos", c.AllowedRepos). + AddIfNotEmpty("state_reason", c.StateReason). Build() }, "close_discussion": func(cfg *SafeOutputsConfig) map[string]any { From 80aa2aa4550c811c19e6aa092a12a81cf5c66972 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 24 Feb 2026 21:34:10 -0800 Subject: [PATCH 3/3] fix: uppercase state_reason enum constants in close_issue tool schema (#18269) --- actions/setup/js/close_issue.cjs | 6 +++--- actions/setup/js/close_issue.test.cjs | 16 ++++++++-------- actions/setup/js/safe_outputs_tools.json | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/actions/setup/js/close_issue.cjs b/actions/setup/js/close_issue.cjs index 7704996ba4..4294858ad0 100644 --- a/actions/setup/js/close_issue.cjs +++ b/actions/setup/js/close_issue.cjs @@ -59,7 +59,7 @@ async function addIssueComment(github, owner, repo, issueNumber, message) { * @param {string} owner - Repository owner * @param {string} repo - Repository name * @param {number} issueNumber - Issue number - * @param {string} [stateReason] - The reason for closing: "completed", "not_planned", or "duplicate" + * @param {string} [stateReason] - The reason for closing: "COMPLETED", "NOT_PLANNED", or "DUPLICATE" * @returns {Promise<{number: number, html_url: string, title: string}>} Issue details */ async function closeIssue(github, owner, repo, issueNumber, stateReason) { @@ -68,7 +68,7 @@ async function closeIssue(github, owner, repo, issueNumber, stateReason) { repo, issue_number: issueNumber, state: "closed", - state_reason: stateReason || "completed", + state_reason: (stateReason || "COMPLETED").toLowerCase(), }); return issue; @@ -85,7 +85,7 @@ async function main(config = {}) { const requiredTitlePrefix = config.required_title_prefix || ""; const maxCount = config.max || 10; const comment = config.comment || ""; - const configStateReason = config.state_reason || "completed"; + const configStateReason = config.state_reason || "COMPLETED"; const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config); // Check if we're in staged mode diff --git a/actions/setup/js/close_issue.test.cjs b/actions/setup/js/close_issue.test.cjs index 87e105b8f0..9e56df8dd2 100644 --- a/actions/setup/js/close_issue.test.cjs +++ b/actions/setup/js/close_issue.test.cjs @@ -598,7 +598,7 @@ describe("close_issue", () => { expect(updateCalls[0].repo).toBe("gh-aw"); }); - it("should use default state_reason 'completed' when not specified", async () => { + it("should use default state_reason 'COMPLETED' when not specified", async () => { const handler = await main({ max: 10 }); const updateCalls = []; @@ -619,7 +619,7 @@ describe("close_issue", () => { expect(updateCalls[0].state_reason).toBe("completed"); }); - it("should use item-level state_reason 'duplicate' when specified in message", async () => { + it("should use item-level state_reason 'DUPLICATE' when specified in message", async () => { const handler = await main({ max: 10 }); const updateCalls = []; @@ -634,13 +634,13 @@ describe("close_issue", () => { }; }; - const result = await handler({ issue_number: 100, body: "Duplicate of #50", state_reason: "duplicate" }, {}); + const result = await handler({ issue_number: 100, body: "Duplicate of #50", state_reason: "DUPLICATE" }, {}); expect(result.success).toBe(true); expect(updateCalls[0].state_reason).toBe("duplicate"); }); - it("should use item-level state_reason 'not_planned' when specified in message", async () => { + it("should use item-level state_reason 'NOT_PLANNED' when specified in message", async () => { const handler = await main({ max: 10 }); const updateCalls = []; @@ -655,14 +655,14 @@ describe("close_issue", () => { }; }; - const result = await handler({ issue_number: 100, body: "Won't fix", state_reason: "not_planned" }, {}); + const result = await handler({ issue_number: 100, body: "Won't fix", state_reason: "NOT_PLANNED" }, {}); expect(result.success).toBe(true); expect(updateCalls[0].state_reason).toBe("not_planned"); }); it("should use config-level state_reason as default for all closes", async () => { - const handler = await main({ max: 10, state_reason: "duplicate" }); + const handler = await main({ max: 10, state_reason: "DUPLICATE" }); const updateCalls = []; mockGithub.rest.issues.update = async params => { @@ -683,7 +683,7 @@ describe("close_issue", () => { }); it("should prefer item-level state_reason over config-level default", async () => { - const handler = await main({ max: 10, state_reason: "not_planned" }); + const handler = await main({ max: 10, state_reason: "NOT_PLANNED" }); const updateCalls = []; mockGithub.rest.issues.update = async params => { @@ -697,7 +697,7 @@ describe("close_issue", () => { }; }; - const result = await handler({ issue_number: 100, body: "Duplicate of #50", state_reason: "duplicate" }, {}); + const result = await handler({ issue_number: 100, body: "Duplicate of #50", state_reason: "DUPLICATE" }, {}); expect(result.success).toBe(true); expect(updateCalls[0].state_reason).toBe("duplicate"); diff --git a/actions/setup/js/safe_outputs_tools.json b/actions/setup/js/safe_outputs_tools.json index 56140139ac..dd122ee365 100644 --- a/actions/setup/js/safe_outputs_tools.json +++ b/actions/setup/js/safe_outputs_tools.json @@ -135,8 +135,8 @@ }, "state_reason": { "type": "string", - "enum": ["completed", "not_planned", "duplicate"], - "description": "The reason for closing the issue. Use 'completed' for resolved issues, 'not_planned' for issues that won't be addressed, or 'duplicate' for duplicate issues. Defaults to 'completed'." + "enum": ["COMPLETED", "NOT_PLANNED", "DUPLICATE"], + "description": "The reason for closing the issue. Use 'COMPLETED' for resolved issues, 'NOT_PLANNED' for issues that won't be addressed, or 'DUPLICATE' for duplicate issues. Defaults to 'COMPLETED'." } }, "additionalProperties": false