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
12 changes: 11 additions & 1 deletion actions/setup/js/close_issue.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -171,16 +171,26 @@ async function closeIssue(github, owner, repo, issueNumber, stateReason, intentM
const hasIntentMetadata = Boolean(intentMetadata && Object.keys(intentMetadata).length > 0);
if (useIssueIntent && hasIntentMetadata) {
try {
core.debug("Attempting issue-intent close request");
const { data: issue } = await github.request("PATCH /repos/{owner}/{repo}/issues/{issue_number}", {
owner,
repo,
issue_number: issueNumber,
state: { value: "closed", ...intentMetadata },
state_reason: baseParams.state_reason,
});
core.debug("Issue-intent close request succeeded");
return issue;
} catch (error) {
core.warning(`Issue-intent close path unavailable, falling back to legacy close path: ${getErrorMessage(error)}`);
const errorRecord = error && typeof error === "object" ? /** @type {Record<string, unknown>} */ error : undefined;
const response = errorRecord?.response;
const status = response && typeof response === "object" && "status" in response ? response.status : errorRecord?.status;
const statusForLog = typeof status === "number" && Number.isInteger(status) ? status : "unknown";
if (status !== 404 && status !== 501) {
core.debug(`Issue-intent close request failed; legacy fallback disabled (status=${statusForLog})`);
throw error;
}
core.warning(`Issue-intent close endpoint unavailable (status=${statusForLog}); falling back to legacy close path`);
}
}

Expand Down
63 changes: 63 additions & 0 deletions actions/setup/js/close_issue.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe("close_issue", () => {
warning: () => {},
error: () => {},
debug: () => {},
debugs: [],
messages: [],
infos: [],
warnings: [],
Expand All @@ -34,6 +35,10 @@ describe("close_issue", () => {
mockCore.errors.push(msg);
mockCore.messages.push({ level: "error", message: msg });
};
mockCore.debug = msg => {
mockCore.debugs.push(msg);
mockCore.messages.push({ level: "debug", message: msg });
};

mockGithub = {
rest: {
Expand Down Expand Up @@ -173,6 +178,64 @@ describe("close_issue", () => {
expect(requestCalls[0].params.rationale).toBeUndefined();
expect(requestCalls[0].params.confidence).toBeUndefined();
expect(requestCalls[0].params.headers).toBeUndefined();
expect(mockCore.debugs).toContain("Attempting issue-intent close request");
expect(mockCore.debugs).toContain("Issue-intent close request succeeded");
});

it.each([403, 422, 500])("should fail without a legacy close when the intent request returns %i", async status => {
const handler = await main({ max: 10 });
let updateCalled = false;
mockGithub.request = async () => {
throw Object.assign(new Error("Intent rejected"), { status });
};
mockGithub.rest.issues.update = async () => {
updateCalled = true;
throw new Error("Legacy close must not be called");
};

const result = await handler({ issue_number: 456, body: "Closing this issue", rationale: "Confirmed" }, {});

expect(result.success).toBe(false);
expect(result.error).toContain("Intent rejected");
expect(updateCalled).toBe(false);
expect(mockCore.debugs).toContain(`Issue-intent close request failed; legacy fallback disabled (status=${status})`);
expect(mockCore.debugs.join(" ")).not.toContain("Intent rejected");
});

it.each([404, 501])("should fall back to a legacy close when the intent endpoint returns %i", async status => {
const handler = await main({ max: 10 });
let updateCalled = false;
mockGithub.request = async () => {
throw Object.assign(new Error("Intent endpoint unavailable"), { status });
};
mockGithub.rest.issues.update = async params => {
updateCalled = true;
return { data: { number: params.issue_number, title: "Test Issue", html_url: "https://github.com/test-owner/test-repo/issues/456" } };
};

const result = await handler({ issue_number: 456, body: "Closing this issue", rationale: "Confirmed" }, {});

expect(result.success).toBe(true);
expect(updateCalled).toBe(true);
expect(mockCore.warnings).toEqual([`Issue-intent close endpoint unavailable (status=${status}); falling back to legacy close path`]);
});

it("should fall back when the unavailable intent status is wrapped in the response", async () => {
const handler = await main({ max: 10 });
let updateCalled = false;
mockGithub.request = async () => {
throw Object.assign(new Error("Intent endpoint unavailable"), { response: { status: 404 } });
};
mockGithub.rest.issues.update = async params => {
updateCalled = true;
return { data: { number: params.issue_number, title: "Test Issue", html_url: "https://github.com/test-owner/test-repo/issues/456" } };
};

const result = await handler({ issue_number: 456, body: "Closing this issue", rationale: "Confirmed" }, {});

expect(result.success).toBe(true);
expect(updateCalled).toBe(true);
expect(mockCore.warnings).toEqual(["Issue-intent close endpoint unavailable (status=404); falling back to legacy close path"]);
});

it("should skip issue-intent metadata when explicitly disabled", async () => {
Expand Down
16 changes: 13 additions & 3 deletions docs/src/content/docs/specs/safe-outputs-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ sidebar:

# Safe Outputs MCP Gateway Specification

**Version**: 1.29.7<br>
**Version**: 1.29.8<br>
**Status**: Working Draft<br>
**Publication Date**: 2026-09-23<br>
**Publication Date**: 2026-10-01<br>
**Editor**: GitHub Agentic Workflows Team<br>
**This Version**: [safe-outputs-specification](/gh-aw/specs/safe-outputs-specification/)<br>
**Latest Published Version**: This document
Expand Down Expand Up @@ -2937,7 +2937,8 @@ For all Linear types, GraphQL source, endpoint, protocol, and host are implement
4. **Cross-Repository**: When `target-repo` is configured, operates on that repository (must be in `allowed-repos`).
5. **Footer Injection**: Appends attribution footer to the closing comment when configured.
6. **Body Suppression**: When `allow-body` is `false`, the handler MUST NOT post a closing comment. Any `body` value provided by the agent SHALL be discarded before the close operation is executed. The implementation MUST log a warning if a non-empty `body` value was discarded.
7. **Native Duplicate Marking**: When `duplicate_of` is provided and `state_reason` is `duplicate`, the handler SHALL call the GitHub `markAsDuplicate` GraphQL mutation to create a native "marked this as a duplicate of #X" timeline event. If the mutation fails (e.g., missing permissions or invalid reference), a warning is logged and the close operation continues. The `duplicate_of` value MUST be parsed and resolved to a valid issue node ID before calling the mutation.
7. **Issue-Intent Close**: When issue-intent metadata is present, the handler MUST submit the close through the issue-intent endpoint. If that request is rejected or otherwise fails, the safe-output item MUST fail and the handler MUST NOT retry through the legacy close endpoint, except for HTTP 404 or 501 responses, which MAY use the legacy endpoint as an availability fallback.
8. **Native Duplicate Marking**: When `duplicate_of` is provided and `state_reason` is `duplicate`, the handler SHALL call the GitHub `markAsDuplicate` GraphQL mutation to create a native "marked this as a duplicate of #X" timeline event. If the mutation fails (e.g., missing permissions or invalid reference), a warning is logged and the close operation continues. The `duplicate_of` value MUST be parsed and resolved to a valid issue node ID before calling the mutation.

**Configuration Parameters**:

Expand Down Expand Up @@ -2969,6 +2970,10 @@ For all Linear types, GraphQL source, endpoint, protocol, and host are implement

**CI-005**: Schema shaping, prompt instructions, and temporary-ID resolution MUST NOT replace or precede runtime target authorization. Agent-supplied target identifiers, including unresolved temporary IDs, MUST be ignored unless `target` is `"*"`.

**CI-006**: When issue-intent metadata is present, a non-404/non-501 failure from the issue-intent close request MUST fail the safe-output item and MUST NOT invoke the legacy close endpoint.

**CI-007**: The handler MAY invoke the legacy close endpoint only when the issue-intent close request returns HTTP 404 or 501, indicating that the intent endpoint may be unavailable.

**Required Permissions**:

*GitHub Actions Token*:
Expand Down Expand Up @@ -6052,6 +6057,11 @@ This specification revision aligns with directly relevant `CHANGELOG.md` entries
- **Earlier changelog entry**: status comments were decoupled from default AI reaction behavior; explicit `on.status-comment` configuration is required when status comments are desired.
- **Earlier changelog entry**: `command` trigger was renamed to `slash_command` with deprecation compatibility.

**Version 1.29.8** (2026-10-01):

- **Specified**: Issue-intent close failures MUST fail closed rather than bypassing review through the legacy close endpoint; only HTTP 404 and 501 responses MAY use the legacy endpoint as an availability fallback.
- **Updated**: Publication metadata to 1.29.8.

**Version 1.29.7** (2026-09-29):

- **Added**: Threat T8 "Private-to-Public Data Exposure" to Section 3.2, covering public Actions logs and public safe-output destinations.
Expand Down
Loading