Skip to content
Merged
7 changes: 7 additions & 0 deletions .changes/work-items/fix-parent-verify-close-6b2e91d4.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: minor
---

### Added

- `/work-items:work` in the issue lane has a verify-and-close mode for a parent whose sub-issues are all closed as completed. When `issue.json` carries `verify_close`, the run builds nothing and checks each of the parent's own done-when items, citing the evidence for each. It then stops `verified-done`, which closes the parent, or `verify-unmet`, which names the unmet items and parks the parent.
15 changes: 10 additions & 5 deletions .github/actions/check-trusted-trigger/check-trusted-trigger.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -432,19 +432,24 @@ export async function isTrustedClose(github, repo, closedIssue, humans, isApp =
if (here && (by.__typename === "Commit" || (by.__typename === "PullRequest" && by.merged === true))) {
return true;
}
return isApp(closer) && appDoneClose(github, repo, Number(closedIssue.number), isApp);
return isApp(closer) && appDoneClose(github, repo, Number(closedIssue.number), humans, isApp);
}

// Whether the lanes App's close of issue `number` is an already-done close
// whose evidence still holds (owner Q9, 2026-10-29): the App's last close
// comment names already-done, and doneEvidence re-reads that the pull request
// or commit it cites is on the default branch with a commit message that
// closes the issue. A report-delivered close, or any read that fails, counts
// for nothing.
async function appDoneClose(github, repo, number, isApp) {
// closes the issue. A verified-done close counts when parentDone re-reads that
// every sub-issue is closed as completed by a close this function trusts. A
// report-delivered close, or any read that fails, counts for nothing.
async function appDoneClose(github, repo, number, humans, isApp) {
try {
const close = await appCloseKind(github, repo, number, isApp);
const { doneEvidence } = await import(new URL("../../scripts/lib/done-evidence.mjs", import.meta.url).href);
const { doneEvidence, parentDone } = await import(new URL("../../scripts/lib/done-evidence.mjs", import.meta.url).href);
if (close?.stop === "verified-done") {
const trusted = async (child) => isTrustedClose(github, repo, await github("GET", `/repos/${repo}/issues/${child}`), humans, isApp);
return (await parentDone(github, `/repos/${repo}`, number, trusted)) === true;
}
return close?.stop === "already-done" && (await doneEvidence(github, `/repos/${repo}`, close.reason, number)) === true;
} catch {
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3120,6 +3120,28 @@ test("isTrustedClose: the lanes App's already-done close counts for nothing once
assert.equal(await isTrustedClose(routeClient(appCloseRoutes({ comments: reopened })), "o/r", APP_CLOSED, humans, isLanesApp), false, "the last close is a report");
});

// #12456 class A: the App's verified-done close of a finished parent counts
// when every sub-issue, read fresh, is closed as completed by a close this
// function trusts (here a listed human's); a not_planned or untrusted one, or
// no sub-issue, leaves it untrusted.
test("isTrustedClose: the lanes App's verified-done close counts only while every sub-issue's close holds", async () => {
const OWNER_ID = 101;
const humans = new Set([OWNER_ID]);
const child = (number, extra = {}) => ({ number, state: "closed", state_reason: "completed", closed_by: { id: OWNER_ID }, repository_url: "https://api.github.com/repos/o/r", ...extra });
const verified = (children) =>
routeClient({
...appCloseRoutes({ comments: [closeComment("verified-done", "verified-done: each done-when item checks out")] }),
"GET /repos/o/r/issues/77/sub_issues?per_page=100&page=1": children,
...Object.fromEntries(children.map((item) => [`GET /repos/o/r/issues/${item.number}`, item])),
});
assert.equal(await isTrustedClose(verified([child(80), child(81)]), "o/r", APP_CLOSED, humans, isLanesApp), true);
assert.equal(await isTrustedClose(verified([child(80), child(81, { state_reason: "not_planned" })]), "o/r", APP_CLOSED, humans, isLanesApp), false, "a not_planned sub-issue");
assert.equal(await isTrustedClose(verified([child(80), child(81, { closed_by: STRANGER })]), "o/r", APP_CLOSED, humans, isLanesApp), false, "an untrusted close");
assert.equal(await isTrustedClose(verified([]), "o/r", APP_CLOSED, humans, isLanesApp), false, "no sub-issue");
// A same-numbered issue here, closed by a listed human, never vouches for a sub-issue elsewhere.
assert.equal(await isTrustedClose(verified([child(80), child(81, { repository_url: "https://api.github.com/repos/o/other" })]), "o/r", APP_CLOSED, humans, isLanesApp), false, "a sub-issue in another repository");
});

test("isTrustedClose: the lanes App's report-delivered close counts for nothing", async () => {
const comments = [closeComment("report-delivered", "report-delivered: #5 shipped it")];
assert.equal(await isTrustedClose(routeClient(appCloseRoutes({ comments })), "o/r", APP_CLOSED, new Set(), isLanesApp), false);
Expand Down
7 changes: 4 additions & 3 deletions .github/actions/resolve-config/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,10 @@ inputs:
split-child:
description: >
`true` or `false`: whether the issue is a trusted size-split sub-issue
(admit.mjs trustedSplitChild, as work-resolve-issue's admit job reads
it). Required when the run reads the issue's dimensions; a Large child
then takes models.issue-dimensions.split-child-size.
(admit.mjs trustedSplitChild) or a finished parent (admit.mjs
finishedParent), as work-resolve-issue's admit job reads it. Required
when the run reads the issue's dimensions; a Large one then takes
models.issue-dimensions.split-child-size.
required: false
default: ""
model-rung:
Expand Down
40 changes: 30 additions & 10 deletions .github/scripts/intake-triage/admit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ import { isDecisionComment, selectTrustedIssueComments } from "../../actions/sel
import { PROTECTED_REFUSALS } from "../apply-patch/protected-paths.mjs";
import { OUTSIDE_ACTOR_LABEL } from "../pr-merge/merge-holds.mjs";
import { ADMIT_MARKER, refusalText, sayWhy as writeWhy } from "../lib/admission-comment.mjs";
import { subIssueLeftOpen } from "../lib/done-evidence.mjs";

export { ADMIT_MARKER, refusalText };
import { closingPulls } from "../work-resolve-issue/report-outcome.mjs";
Expand Down Expand Up @@ -1311,7 +1312,7 @@ export function admissionSettled(labels, events, isMark) {
// issue waited for. Until a verdict written after that close judges the issue
// against it, the wait carries `retriage` and admit() sends the issue back to
// intake; once one has, the issue's own criteria decide.
export function decide({ issue, verdict, rules, parent, blockers, trustedCloses, reported = new Map(), pulls = [], subIssues = [], doneBy = [], events, spec, schedule, humans, appId, repository, webHosts, public: isPublic = false, pushRefusals = issuePushRefusals(), workflows = workflowFiles(), charges = chargePattern(), chargeEdits = [] }) {
export function decide({ issue, verdict, rules, parent, blockers, trustedCloses, reported = new Map(), pulls = [], subIssues = [], finished = false, doneBy = [], events, spec, schedule, humans, appId, repository, webHosts, public: isPublic = false, pushRefusals = issuePushRefusals(), workflows = workflowFiles(), charges = chargePattern(), chargeEdits = [] }) {
const labels = names(issue.labels);
if (!labels.includes(READY)) {
return { verdict: "skip", reasons: [`no ${READY}`] };
Expand Down Expand Up @@ -1368,10 +1369,12 @@ export function decide({ issue, verdict, rules, parent, blockers, trustedCloses,
if (verdict.dimensions?.size === "large" && !rules.sizes.includes("large")) {
// An issue with sub-issues is split already (by intake or by hand): while
// one is open it waits on them below, and once all are closed it is
// refused, so it never parks as split pending every sweep.
// refused, so it never parks as split pending every sweep. A finished
// parent (`finished`, finishedParent) is neither: the lane's one run
// checks its own done-when and builds nothing, so Size does not bound it.
if (!((issue.sub_issues_summary?.total ?? 0) > 0)) {
parked.push(SIZE_SPLIT_PENDING);
} else if (openSubs.length === 0) {
} else if (openSubs.length === 0 && !finished) {
refuse.push(SIZE_SPLIT_DONE);
}
} else if (!rules.sizes.includes(verdict.dimensions?.size)) {
Expand Down Expand Up @@ -1491,8 +1494,9 @@ export function decide({ issue, verdict, rules, parent, blockers, trustedCloses,
}
// A merged Refs pull request since the newest decision or refinement may
// have done the work, so an agent checks it before a lane run repeats it;
// like any refusal no person must answer, it parks at status: confirmed.
if (doneBy.length > 0) {
// like any refusal no person must answer, it parks at status: confirmed. A
// finished parent's run is that check (verified-done).
if (doneBy.length > 0 && !finished) {
refuse.push(doneReason(doneBy));
}
const wait = [];
Expand Down Expand Up @@ -2235,10 +2239,9 @@ export async function splitLeftOpen({ github, repository, issue, comments, child
if (children.length === 0 || !(await isSplitParent({ github, repository, issue, comments, children, isApp, humans }))) {
return "it is not a split parent";
}
for (const child of children) {
if (child?.state !== "closed" || child.state_reason !== "completed" || !(await trusted(child.number))) {
return `sub-issue #${child?.number} is not closed as completed by a listed human or a merge`;
}
const childLeft = await subIssueLeftOpen(children, trusted, repository);
if (childLeft) {
return childLeft;
}
const taskList = baseTaskList();
const record = filedRecord(comments, isApp);
Expand Down Expand Up @@ -2272,6 +2275,21 @@ export async function splitParentClosed({ github, repository, issue, isApp, huma
return (await splitLeftOpen({ github, repository, issue, comments: await paginate(github, `${base}/comments`), children, isApp, humans, trusted })) === null;
}

// The numbers of the sub-issues of `issue` (as read, here) when it has any and
// every one is closed as completed by a close `trusted(number)` accepts
// (subIssueLeftOpen, the split parent's child rule), else null: a finished
// parent, which the issue lane takes at any Size for one run that checks its
// own done-when and closes it verified-done (#12456, class A). One sub-issue closed not_planned (class B, Q93) or open keeps it out.
// The summary only skips the listing for an issue with no sub-issue; each
// child's own state_reason decides. A failed read throws.
export async function finishedParent({ github, repository, issue, trusted }) {
if (!((issue?.sub_issues_summary?.total ?? 0) > 0)) {
return null;
}
const children = await paginate(github, `/repos/${repository}/issues/${issue.number}/sub_issues`);
return children.length > 0 && (await subIssueLeftOpen(children, trusted, repository)) === null ? children.map((child) => Number(child.number)) : null;
}

// admissionFacts' `isTrusted` for admit(): issue `number` here was closed by
// a listed human or a merge, by the lanes App as already-done with evidence
// that still holds (isTrustedClose), or is a split parent the lanes App
Expand Down Expand Up @@ -2356,6 +2374,7 @@ export async function admissionFacts({ github, repository, number, issue, rules,
// A failed read throws, like the reads above, so no verdict rests on a
// visibility it did not read (docs/outage-posture-audit.md, element 4).
const isPublic = (await github("GET", `/repos/${repository}`))?.visibility === "public";
const subIssues = await openSubIssues(github, base, issue);
return {
issue,
// Read for the admission comment's own earlier copy (sayWhy).
Expand All @@ -2367,7 +2386,8 @@ export async function admissionFacts({ github, repository, number, issue, rules,
trustedCloses,
reported,
pulls: await openPullsFor(github, repository, number),
subIssues: await openSubIssues(github, base, issue),
subIssues,
finished: subIssues.length === 0 && (await finishedParent({ github, repository, issue, trusted })) !== null,
doneBy: await (doneBy ?? mergedRefsReader({ github, repository, humans, appId }))(Number(number)),
events: await paginate(github, `${base}/events`),
public: isPublic,
Expand Down
38 changes: 37 additions & 1 deletion .github/scripts/intake-triage/admit.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { join } from "node:path";

import { tempDir } from "../../../lib/test-tmp.mjs";

import { ADMIT_MARKER, AGENT_READY, DEFERRED, DEFERRED_AHEAD_READS, PAID_EVAL, bodyUnpushablePaths, deferredAhead, refineDue, PATCH_ROW, RACE_WINDOW_MS, UNTRUSTED as UNTRUSTED_CLASS, admissionFacts, admissionsAhead, admissionsSince, admit, authorAdmitted, chargeEditsOf, chargeHits, closedByTrusted, chargePattern, decide, deferralLapsed, doneReason, isSplitParent, SIZE_SPLIT_DONE, SIZE_SPLIT_PENDING, issuePushRefusals, loadRules, mergedRefsAfter, mergedRefsReader, noteUnrunnable, openPullsNaming, pushRefusalsOf, readSchedule, refsNames, reportCleared, judgedSince, refusalText, retriageText, revisionWraps, splitParentClosed, titleWraps, unrunnableCriteria, unrunnableRulesOf, issueUnrunnableRules, untrustedHold, workCharges } from "./admit.mjs";
import { ADMIT_MARKER, AGENT_READY, DEFERRED, DEFERRED_AHEAD_READS, PAID_EVAL, bodyUnpushablePaths, deferredAhead, refineDue, PATCH_ROW, RACE_WINDOW_MS, UNTRUSTED as UNTRUSTED_CLASS, admissionFacts, admissionsAhead, admissionsSince, admit, authorAdmitted, chargeEditsOf, chargeHits, closedByTrusted, chargePattern, decide, deferralLapsed, doneReason, finishedParent, isSplitParent, SIZE_SPLIT_DONE, SIZE_SPLIT_PENDING, issuePushRefusals, loadRules, mergedRefsAfter, mergedRefsReader, noteUnrunnable, openPullsNaming, pushRefusalsOf, readSchedule, refsNames, reportCleared, judgedSince, refusalText, retriageText, revisionWraps, splitParentClosed, titleWraps, unrunnableCriteria, unrunnableRulesOf, issueUnrunnableRules, untrustedHold, workCharges } from "./admit.mjs";
import { REFINE_MARKER, render, sizeSplitPending, validateOutput } from "./refine.mjs";
import { VERDICT_MARKER } from "./apply-verdict.mjs";
import { TARGETS } from "../apply-patch/apply-patch.mjs";
Expand Down Expand Up @@ -2861,6 +2861,42 @@ describe("size split sub-issues", () => {
assert.deepEqual(decide(facts({ issue: unsplit, verdict: large })), { verdict: "wait", reasons: [SIZE_SPLIT_PENDING], pulls: [] });
});

// A finished parent (finishedParent: every sub-issue closed as completed by
// a trusted close) gets one verified-done run at any Size (#12456, class A);
// that run is the done-check a merged Refs pull request would park it for.
test("a finished parent at Size Large is admitted for its verify run; one with an open sub-issue still waits", () => {
const large = { ...VERDICT, dimensions: { ...VERDICT.dimensions, size: "large" } };
const split = issueOf({ sub_issues_summary: { total: 2, completed: 2, percent_completed: 100 } });
assert.deepEqual(decide(facts({ issue: split, verdict: large, finished: true })), { verdict: "admit", reasons: [] });
assert.deepEqual(decide(facts({ issue: split, verdict: large, finished: true, doneBy: [77] })), { verdict: "admit", reasons: [] });
assert.equal(decide(facts({ issue: split, verdict: large, doneBy: [77] })).verdict, "refuse");
assert.deepEqual(decide(facts({ issue: split, verdict: large, subIssues: [{ number: 8, state: "open" }] })), { verdict: "wait", reasons: ["sub-issue #8 is open"], pulls: [] });
});

test("finishedParent names the sub-issues only when each is closed as completed by a trusted close", async () => {
const child = (number, extra = {}) => ({ number, state: "closed", state_reason: "completed", repository_url: "https://api.github.com/repos/o/r", ...extra });
const run = async (children, trusted = async () => true, summary = { total: children.length, completed: children.length }) => {
const calls = [];
const github = async (method, path) => {
calls.push(path);
return path.includes("/sub_issues") ? children : null;
};
return { found: await finishedParent({ github, repository: "o/r", issue: { number: 5, sub_issues_summary: summary }, trusted }), calls };
};
assert.deepEqual((await run([child(8), child(9)])).found, [8, 9]);
assert.equal((await run([child(8), child(9, { state_reason: "not_planned" })])).found, null, "class B: a not_planned sub-issue");
assert.equal((await run([child(8), child(9, { state: "open", state_reason: null })])).found, null, "an open sub-issue");
assert.equal((await run([child(8), child(9)], async (number) => number !== 9)).found, null, "an untrusted close");
// `trusted` reads a number in this repository, so a sub-issue elsewhere
// with the same number never borrows that close.
const elsewhere = await run([child(8), child(9, { repository_url: "https://api.github.com/repos/o/other" })]);
assert.equal(elsewhere.found, null, "a sub-issue in another repository");
assert.equal((await run([child(8), child(9, { repository_url: undefined })])).found, null, "a sub-issue with no repository_url");
const none = await run([], undefined, { total: 0, completed: 0 });
assert.equal(none.found, null);
assert.deepEqual(none.calls, [], "no sub-issue costs no read");
});

test("a parent waiting on an open sub-issue keeps its open pull requests, so admission-deferred stays", () => {
const result = decide(facts({ issue: issueOf({ body: "Make the README better." }), spec: humanSpec(issueOf({ body: "Make the README better." })), pulls: [41], subIssues: [{ number: 8, state: "open" }] }));
assert.deepEqual(result, { verdict: "wait", reasons: ["open pull request #41 is for it", "sub-issue #8 is open"], pulls: [41] });
Expand Down
Loading
Loading