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
13 changes: 9 additions & 4 deletions actions/setup/js/close_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(", ")}`);
}
Expand Down Expand Up @@ -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}`);
}

Expand Down
105 changes: 105 additions & 0 deletions actions/setup/js/close_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
});
5 changes: 5 additions & 0 deletions actions/setup/js/safe_outputs_tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/src/content/docs/reference/safe-outputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:`)

Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/close_entity_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions pkg/workflow/compiler_safe_outputs_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading