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
74 changes: 58 additions & 16 deletions actions/setup/js/handle_agent_failure.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,24 @@ const ALLOWED_FILES_ERROR_RE = /^(?<summary>.*outside the allowed-files list) \(

/**
* Parse action failure issue expiration from environment.
* @returns {number} Expiration in hours (defaults to 168 when unset/invalid)
*
* A value of "0" is an explicit signal from the compiler that no maintenance
* workflow will exist to enforce expiration, so expiration must be disabled
* (no expiration marker is written to failure issues). Missing/invalid values
* fall back to the 168-hour default for backwards compatibility with older
* generated lock files that always set a positive value.
* @returns {number} Expiration in hours (0 means disabled; defaults to 168 when unset/invalid)
*/
function getActionFailureIssueExpiresHours() {
const raw = process.env.GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS || "";
const parsed = Number.parseInt(raw, 10);
if (Number.isInteger(parsed) && parsed > 0) {
return parsed;
if (raw === "") {
return DEFAULT_ACTION_FAILURE_ISSUE_EXPIRES_HOURS;
}
if (raw === "0") {
return 0;
}
if (/^[1-9]\d*$/.test(raw)) {
return Number(raw);
}
return DEFAULT_ACTION_FAILURE_ISSUE_EXPIRES_HOURS;
}
Expand Down Expand Up @@ -590,24 +601,55 @@ async function ensureParentIssue(previousParentNumber = null, ownerOverride, rep
const existingIssue = searchResult.data.items[0];
core.info(`Found existing parent issue #${existingIssue.number}: ${existingIssue.html_url}`);

// Check the sub-issue count
const subIssueCount = await getSubIssueCount(owner, repo, existingIssue.number);
// Enforce the parent issue's own expiration marker: an expired parent
// must not keep receiving new sub-issues, mirroring isReusableFailureIssue's
// handling of individual per-run failure issues.
let existingBody;
if (typeof existingIssue.body === "string") {
existingBody = existingIssue.body;
} else {
// The search API response may omit or truncate the body field; fetch the
// full issue to reliably read the expiration marker.
try {
const issueResult = await github.rest.issues.get({
owner,
repo,
issue_number: existingIssue.number,
});
existingBody = issueResult.data.body || "";
} catch (error) {
core.warning(`Could not fetch parent issue #${existingIssue.number} body: ${getErrorMessage(error)}. Continuing without expiration marker check.`);
existingBody = "";
}
}
const parentExpirationDate = extractExpirationDate(existingBody);

if (subIssueCount !== null && subIssueCount >= MAX_SUB_ISSUES) {
core.warning(`Parent issue #${existingIssue.number} has ${subIssueCount} sub-issues (max: ${MAX_SUB_ISSUES})`);
core.info(`Creating a new parent issue (previous parent #${existingIssue.number} is full)`);
if (parentExpirationDate && parentExpirationDate.getTime() <= Date.now()) {

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] The expiration check uses <= Date.now() (expired at or before now), which is correct. However, the branch that fetches the full issue body on a truncated search result has no error handling — if github.rest.issues.get throws (e.g. rate-limit, network error), the exception propagates uncaught up through ensureParentIssue, potentially aborting the entire grouped-issue flow rather than gracefully falling back to creating a new parent.

💡 Suggestion
let existingBody;
try {
  if (typeof existingIssue.body === "string") {
    existingBody = existingIssue.body;
  } else {
    const issueResult = await github.rest.issues.get({ owner, repo, issue_number: existingIssue.number });
    existingBody = issueResult.data.body || "";
  }
} catch (err) {
  core.warning(`Could not fetch body for parent issue #${existingIssue.number}: ${err.message}. Treating as unexpired.`);
  existingBody = "";
}

Treating a fetch failure as "not expired" is conservative and mirrors the existing pattern of falling through to sub-issue count checks when data is unavailable.

@copilot please address this.

core.info(`Parent issue #${existingIssue.number} has expired (expired ${parentExpirationDate.toISOString()})`);
core.info(`Creating a new parent issue (previous parent #${existingIssue.number} has expired)`);

// Fall through to create a new parent issue, passing the previous parent number
previousParentNumber = existingIssue.number;
Comment on lines 631 to 632
} else {
// Parent issue is within limits, return it
if (subIssueCount !== null) {
core.info(`Parent issue has ${subIssueCount} sub-issues (within limit of ${MAX_SUB_ISSUES})`);
// Check the sub-issue count
const subIssueCount = await getSubIssueCount(owner, repo, existingIssue.number);

if (subIssueCount !== null && subIssueCount >= MAX_SUB_ISSUES) {
core.warning(`Parent issue #${existingIssue.number} has ${subIssueCount} sub-issues (max: ${MAX_SUB_ISSUES})`);
core.info(`Creating a new parent issue (previous parent #${existingIssue.number} is full)`);

// Fall through to create a new parent issue, passing the previous parent number
previousParentNumber = existingIssue.number;
} else {
// Parent issue is within limits, return it
if (subIssueCount !== null) {
core.info(`Parent issue has ${subIssueCount} sub-issues (within limit of ${MAX_SUB_ISSUES})`);
}
return {
number: existingIssue.number,
node_id: existingIssue.node_id,
};
}
return {
number: existingIssue.number,
node_id: existingIssue.node_id,
};
}
}
} catch (error) {
Expand Down
123 changes: 121 additions & 2 deletions actions/setup/js/handle_agent_failure.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,20 @@ describe("handle_agent_failure", () => {
expect(getActionFailureIssueExpiresHours()).toBe(48);
});

it("returns default for invalid values", () => {
it("returns 0 (disabled) when the compiler explicitly opts out of expiration", () => {
process.env.GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS = "0";
expect(getActionFailureIssueExpiresHours()).toBe(168);
expect(getActionFailureIssueExpiresHours()).toBe(0);
});

it("returns default for invalid values", () => {
process.env.GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS = "invalid";
expect(getActionFailureIssueExpiresHours()).toBe(168);
});

it("returns default for malformed values with numeric prefixes", () => {
process.env.GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS = "0invalid";
expect(getActionFailureIssueExpiresHours()).toBe(168);
});
});

describe("buildFailureIssueTitle", () => {
Expand Down Expand Up @@ -704,6 +712,117 @@ describe("handle_agent_failure", () => {
expect(searchMock).toHaveBeenCalledWith(expect.objectContaining({ q: expect.stringContaining('"[aw] Failed runs"') }));
});

it("creates a new parent issue when the existing parent issue has expired", async () => {
const createCommentMock = vi.fn();
const createIssueMock = vi.fn(async ({ title }) => ({
data: {
number: title === "[aw] Failed runs" ? 300 : 301,
html_url: `https://github.com/owner/repo/issues/${title === "[aw] Failed runs" ? 300 : 301}`,
node_id: title === "[aw] Failed runs" ? "I_parent_new" : "I_child",
},
}));
const expiredParentBody = "This issue tracks failures.\n\n> - [x] expires <!-- gh-aw-expires: 2000-01-01T00:00:00.000Z --> on Jan 1, 2000, 12:00 AM UTC";
const searchMock = vi.fn(async ({ q }) => {
if (q.includes("is:pr")) {
return { data: { total_count: 0, items: [] } };
}
if (q.includes('"[aw] Failed runs"')) {
return {
data: {
total_count: 1,
items: [{ number: 199, html_url: "https://github.com/owner/repo/issues/199", node_id: "I_parent_old", body: expiredParentBody }],
},
};
}
return { data: { total_count: 0, items: [] } };
});

process.env.GH_AW_GROUP_REPORTS = "true";

const graphqlMock = vi.fn(async () => ({ repository: { issue: { subIssues: { totalCount: 0 } } } }));

global.github = {
rest: {
search: {
issuesAndPullRequests: searchMock,
},
issues: {
create: createIssueMock,
createComment: createCommentMock,
},
pulls: { get: vi.fn() },
},
graphql: graphqlMock,
};

await main();

// github.rest.issues.create is always invoked with a single options object
// (see github.rest.issues.create({...}) call sites in handle_agent_failure.cjs),
// so destructuring the first call argument yields the options object itself.
const parentCreateCall = createIssueMock.mock.calls.map(([call]) => call).find(call => call.title === "[aw] Failed runs");
expect(parentCreateCall).toBeDefined();
expect(parentCreateCall.body).toContain("previous parent issue #199");
// Expired parent must not be reused: getSubIssueCount must not be queried
// for the expired parent #199, since the expiration check short-circuits first.
expect(graphqlMock).not.toHaveBeenCalledWith(expect.stringContaining("subIssues"), expect.objectContaining({ issueNumber: 199 }));
});

it("does not abort grouped handling when fetching parent issue body fails", async () => {
const createCommentMock = vi.fn();
const createIssueMock = vi.fn(async ({ title }) => ({
data: {
number: title === "[aw] Failed runs" ? 300 : 301,
html_url: `https://github.com/owner/repo/issues/${title === "[aw] Failed runs" ? 300 : 301}`,
node_id: title === "[aw] Failed runs" ? "I_parent_new" : "I_child",
},
}));
const searchMock = vi.fn(async ({ q }) => {
if (q.includes("is:pr")) {
return { data: { total_count: 0, items: [] } };
}
if (q.includes('"[aw] Failed runs"')) {
return {
data: {
total_count: 1,
items: [{ number: 199, html_url: "https://github.com/owner/repo/issues/199", node_id: "I_parent_old", body: null }],
},
};
}
return { data: { total_count: 0, items: [] } };
});
const getIssueMock = vi.fn(async () => {
throw new Error("transient API failure");
});
const graphqlMock = vi.fn(async () => ({ repository: { issue: { subIssues: { totalCount: 1 } } } }));

process.env.GH_AW_GROUP_REPORTS = "true";

global.github = {
rest: {
search: {
issuesAndPullRequests: searchMock,
},
issues: {
get: getIssueMock,
create: createIssueMock,
createComment: createCommentMock,
},
pulls: { get: vi.fn() },
},
graphql: graphqlMock,
};

await main();

expect(getIssueMock).toHaveBeenCalledWith(expect.objectContaining({ issue_number: 199 }));
expect(global.core.warning).toHaveBeenCalledWith(expect.stringContaining("Could not fetch parent issue #199 body"));
const parentCreateCall = createIssueMock.mock.calls.map(([call]) => call).find(call => call.title === "[aw] Failed runs");
expect(parentCreateCall).toBeUndefined();
expect(createIssueMock).toHaveBeenCalledOnce();
expect(createCommentMock).not.toHaveBeenCalled();
});

it("escapes workflow IDs before searching for legacy XML marker matches", async () => {
const createCommentMock = vi.fn(async () => ({ data: { id: 1001 } }));
const createIssueMock = vi.fn();
Expand Down
45 changes: 45 additions & 0 deletions docs/adr/51425-opt-in-semantics-for-action-failure-issue-expiry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ADR-51425: Opt-In Semantics for Action-Failure Issue Expiry and Maintenance Generation

**Date**: 2026-08-08
**Status**: Draft
**Deciders**: pelikhan, Copilot SWE Agent

---

### Context

gh-aw always injects a `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS` value into compiled lock files (defaulting to 168 hours), but `scanWorkflowsForExpires` only considered explicit safe-output expiry when deciding whether to generate `agentics-maintenance.yml`. This meant that repositories relying solely on the implicit action-failure default would get failure issues tagged with an expiration marker that no scheduled `close-expired-issues` job would ever enforce. Grouped parent issues received the same unenforceable marker. Because `safe-outputs.report-failure-as-issue` defaults to `true`, naively treating the 168-hour default as an unconditional maintenance trigger would force `agentics-maintenance.yml` into essentially every repository with a gh-aw workflow — violating the opt-in posture established for no-op issue expiry in #37965.

### Decision

We will treat `maintenance.action_failure_issue_expires` in `aw.json` as a pure opt-in. Only an explicitly configured value (detected via a secondary `map[string]json.RawMessage` pass in `UnmarshalJSON` that Go's `omitempty` cannot provide) causes `scanWorkflowsForExpires` to count action-failure expiry as a maintenance trigger and include it in the minimum-expires calculation. When `scanWorkflowsForExpires` returns `hasExpires=false` (no recognized expiry source, including no explicit action-failure config), the compiler patches already-generated lock files to replace the implicit `"168"` marker with `"0"`. The runtime treats `"0"` as "expiration disabled" and omits the marker from failure issues. When another recognized expiry source does trigger maintenance, the implicit marker is preserved because the generic `close-expired-issues` sweeper will enforce it. Parent issue reuse additionally checks the existing parent's expiration marker before sub-issue count, mirroring existing per-run issue logic.

### Alternatives Considered

#### Alternative 1: Make the implicit 168-hour default an unconditional maintenance trigger

Treat the implicit `action_failure_issue_expires` default the same as an explicit value: if any workflow could report failure as an issue, always generate `agentics-maintenance.yml`. This removes the producer/consumer gap without requiring users to touch `aw.json`. Rejected because `report-failure-as-issue` defaults to `true`, which would cause every repository with a gh-aw workflow to receive a scheduled maintenance workflow — exactly the behavior the existing opt-in posture for no-op issue expiry (#37965, #38627) was designed to prevent.

#### Alternative 2: Remove the implicit 168-hour default; require explicit configuration for any expiry

Eliminate the implicit default entirely and only write an expiry marker when `action_failure_issue_expires` is explicitly set. This is a simpler contract but is a breaking change: existing compiled lock files and any documentation referring to the 168-hour default would be invalidated. It would also break backwards compatibility with older lock files (which always contain a positive value). Rejected in favor of the current approach, which preserves the default for repositories that have another maintenance source.

### Consequences

#### Positive
- Failure issues no longer advertise expiration deadlines that no scheduled job will enforce, eliminating a silent correctness gap.
- Repositories that don't need scheduled maintenance are not forced to adopt it; the opt-in posture matches the precedent set for no-op issue expiry.
- Explicit `action_failure_issue_expires` values participate correctly in the minimum-expires calculation for the `close-expired-issues` cron schedule.
- Expired grouped parent issues are now detected and bypassed before sub-issue count is checked, preventing indefinite accumulation of sub-issues under an expired parent.

#### Negative
- `UnmarshalJSON` for `RepoConfig` now performs two JSON unmarshal passes on the maintenance object (one typed, one into `map[string]json.RawMessage`) to distinguish explicit from absent fields — a non-obvious pattern that future maintainers may find surprising.
- The runtime meaning of `GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS="0"` changes from "fall back to 168" to "expiration disabled." The change is backwards-safe because compiler-generated lock files previously never contained `"0"`, but the new semantics must be documented and kept in sync with the compiler's patching logic.

#### Neutral
- Side-repository (`failure-issue-repo`) maintenance coverage is explicitly left as a follow-up; the current fix addresses only the primary-repository case.
- The `anyWorkflowMayReportFailureAsIssue` helper introduces a new scan over `workflowDataList` but is only called from within `scanWorkflowsForExpires`, which is already O(n) over the same list.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
2 changes: 2 additions & 0 deletions docs/src/content/docs/reference/ephemerals.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,8 @@ Customize the runner:

`action_failure_issue_expires` sets expiration, in hours, for failure issues opened by the conclusion job, including grouped parent issues when `group-reports: true`. The default is `168` (7 days).

Explicitly setting `action_failure_issue_expires` in `aw.json` is treated as an opt-in: it causes `agentics-maintenance.yml` to be generated (if not already generated by another expiring safe output) so the scheduled `close-expired-entities` job can enforce it. If `action_failure_issue_expires` is left unset and no other workflow output requires scheduled maintenance, the implicit 168-hour default is not written into failure issues, since there would be no scheduled job to close them; failure issues are created without an expiration marker in that case.

If `.github/workflows/aw.json` is present but cannot be loaded, parsed, or validated, compilation keeps the default `168`-hour expiration and emits a warning that identifies the config path and fallback value.

`disabled_jobs` lets you omit specific maintenance jobs from the generated workflow. Job IDs are case-insensitive, and `_` / `-` are treated equivalently.
Expand Down
Loading
Loading