diff --git a/actions/setup/js/close_issue.cjs b/actions/setup/js/close_issue.cjs index 47ca716ab2..4294858ad0 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").toLowerCase(), }); 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..9e56df8dd2 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..dd122ee365 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 {