diff --git a/.claude/skills/pm-dispatch/references/triage-duties.md b/.claude/skills/pm-dispatch/references/triage-duties.md index c6a4ac159d2..ebbb11e1d17 100644 --- a/.claude/skills/pm-dispatch/references/triage-duties.md +++ b/.claude/skills/pm-dispatch/references/triage-duties.md @@ -33,6 +33,7 @@ - 行首 `Ruling-ref:` 引本例外裁决原话;⛔ 不改 `domain:*`、不定 p0(归 main-red)、不碰人工地板。 - 分诊每轮读上轮以来新贴的自分诊记录,逐条回复确认或改判;复审前席位定级有效。 - 分类动作每张三选一,外加一个修复通道;裁定写方向、给六态之一。 +- 停 `pm:blocked` 同笔在卡正文写行首 `Blocked-by: #N`(issue/PR 号;full-run 卡写评论),散文不算。 - 方向在 ADR/本卡裁决/不可重裁规则:贴 `check-prior-rulings.mjs` 的 `Prior rulings read:` 行,或不点。 - `pm:queue` = 有具名落点或复现的具体缺陷,无可问之事;⛔ 工具/门禁修复不由此进。 - `tooling` 入 `pm:queue` 仅凭三门之一:首行带 `Unblocks: #N`(open 产品卡)或点名所护的已发布面; @@ -97,6 +98,5 @@ - 轮次报告设呈裁清单专节(卡、推荐、所引权威),⛔ 无代裁清单、无追认表。 - 回翻条款:实施中发现契约终究要动 ⇒ dev 停手,卡回决策箱,⛔ 永不静默重裁。 - 请示纪律:方向性授权覆盖整条执行链一并做完,⛔ 不逐项回问。 -- 中间状态与限流/重试类运维细节只进座位贴和触发器,⛔ 不弹给维护者。 -- 汇报只在里程碑、真阻塞、被问到时。 +- 汇报只在里程碑、真阻塞、被问到时;中间状态与限流/重试类细节只进座位贴和触发器。 - 人工地板项(发版、天花板、契约扩大)仍需明确字句,但攒批不逐项弹。 diff --git a/scripts/ci/scheduled-full-run-card.mjs b/scripts/ci/scheduled-full-run-card.mjs index 25f07fee766..81f1d8e7d41 100644 --- a/scripts/ci/scheduled-full-run-card.mjs +++ b/scripts/ci/scheduled-full-run-card.mjs @@ -105,8 +105,9 @@ export async function fileCardStep({ github, context, core, now = () => new Date // The DECISION is the module's, driven offline by its self-test // against a mutable board (one red run files one card; the next // refreshes it and files no second; a refresh of a card whose - // every blocker is a closed LOCAL card moves `pm:blocked` to - // `pm:queue`). What is left here is thin callbacks over the API. + // every blocker is a closed LOCAL card, a PR one merged, moves + // `pm:blocked` to `pm:queue`). What is left here is thin callbacks + // over the API. const outcome = await mod.fileOrRefreshCard({ identity, body, @@ -138,7 +139,9 @@ export async function fileCardStep({ github, context, core, now = () => new Date getIssue: async ({ repo: target, number }) => { const [targetOwner, targetRepo] = target.split('/'); const res = await github.rest.issues.get({ owner: targetOwner, repo: targetRepo, issue_number: number }); - return { state: res.data.state, closed_at: res.data.closed_at }; + // A PR number answers `closed` merged or not; `merged_at` tells them apart. + const pr = res.data.pull_request ? { pull_request: { merged_at: res.data.pull_request.merged_at ?? null } } : {}; + return { state: res.data.state, closed_at: res.data.closed_at, ...pr }; }, // The swap: an additive POST and ONE remove by name. addLabels: async ({ number, labels }) => { @@ -362,6 +365,43 @@ const SELF_TEST_CASES = [ { output: ['number', '430'] }, ], }, + { + label: 'a pm:blocked card whose blocker is a PR closed unmerged is held: the PR shape reaches the judgement, no label write', + run: watchedRun(), + api: { + 'actions.listWorkflowRuns': async () => previousGreenRun, + 'repos.compareCommitsWithBasehead': async () => compareOf(0), + 'issues.listForRepo': async () => ({ + data: [{ + number: 440, + title: mod.cardIdentity('CI').title, + body: 'old\n\nBlocked-by: #601\n', + state: 'open', + labels: [{ name: 'bug' }, { name: 'pm:blocked' }], + assignees: [], + }], + }), + 'issues.listComments': async () => ({ data: [] }), + 'issues.get': async () => ({ + data: { state: 'closed', closed_at: '2026-10-26T20:00:00Z', pull_request: { merged_at: null } }, + }), + 'issues.update': async () => ({ data: {} }), + }, + calls: [ + LOOKUP, COMPARE, SCAN, + { api: 'issues.listComments', params: { ...OWNER_REPO, issue_number: 440, per_page: 100, page: 1 } }, + { api: 'issues.get', params: { ...OWNER_REPO, issue_number: 601 } }, + { api: 'issues.update', params: { ...OWNER_REPO, issue_number: 440 } }, + ], + body: renderArgs({ compareUrl: COMPARE_URL }), + reports: [ + { info: `refreshed ${mod.cardIdentity('CI').marker} card #440` }, + { info: 'block: held -- #601 closed unmerged (a PR that never landed)' }, + { notice: 'CI is red on the scheduled full run — card #440 refreshed.' }, + { output: ['action', 'refreshed'] }, + { output: ['number', '440'] }, + ], + }, ]; // The roster is a LITERAL the table is checked against (see @@ -375,9 +415,10 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'a compare past the endpoint cap is marked truncated, and the watched workflow names the card': 1, 'an open card is refreshed in place: one issues.update, no create, no label write': 1, 'a pm:blocked card whose only blocker closed reaches every callback: comments, issue read, add then remove': 1, + 'a pm:blocked card whose blocker is a PR closed unmerged is held: the PR shape reaches the judgement, no label write': 1, }); // Deleting a roster entry silences its floor as surely as zeroing it. -const SELF_TEST_BATTERY_FLOOR = 7; +const SELF_TEST_BATTERY_FLOOR = 8; /** Doubles of what github-script hands the step, recording every call. */ function doubles(caseDef) { diff --git a/scripts/ci/scheduled-full-run.mjs b/scripts/ci/scheduled-full-run.mjs index a49a1e20d2f..7238b0a799f 100644 --- a/scripts/ci/scheduled-full-run.mjs +++ b/scripts/ci/scheduled-full-run.mjs @@ -504,9 +504,9 @@ export async function fileOrRefreshCard({ // THE RULE. On a refresh of a card carrying `pm:blocked`, every `Blocked-by:` // target the card names -- body AND comments, line-initial, read by the // patrol's own reader -- is resolved. When EVERY one is a LOCAL card that has -// closed, no `Unlock-action:` line names another unlock, and the card has no -// assignee, the refresh swaps `pm:blocked` for `pm:queue` and says so in the -// refreshed body. Anything else changes no label: +// closed (a PR: merged), no `Unlock-action:` line names another unlock, and the +// card has no assignee, the refresh swaps `pm:blocked` for `pm:queue` and says +// so in the refreshed body. Anything else changes no label: // // not pm:blocked nothing read at all // no target named H4's row (a block with no line), not this refresh's @@ -520,6 +520,10 @@ export async function fileOrRefreshCard({ // objectstack consumes objectui through the // `.objectui-sha` pin, so the next hourly red is the // SAME failure until the pin moves -- never released +// a PR target CLOSED abandoned, not landed (#27846): the issues API answers +// UNMERGED a PR number `closed` whether or not it merged, and +// only `pull_request.merged_at` tells the two apart -- +// never released // an `Unlock-action:` the card names an unlock other than closure // line (any channel) (`references/state-machine.md`, `pm:blocked`), so // closure is not its exit -- never released. Any value @@ -561,7 +565,8 @@ export const QUEUE_LABEL = 'pm:queue'; // The pure verdict over resolved targets. `resolutions` are // `blockerTargetsFor`'s targets, each carrying `local` and `state` -// ('open' | 'closed' | 'unresolved') -- the shape H19 judges. +// ('open' | 'closed' | 'unresolved') -- the shape H19 judges -- and, on a PR +// that closed without merging, `unmerged: true`. // `unlockActions` are the card's `Unlock-action:` values, both channels. export function staleBlockVerdict({ issue, resolutions, unlockActions = [] }) { if (!labelNames(issue ?? {}).includes(BLOCKED_LABEL)) return { verdict: 'not-blocked' }; @@ -569,11 +574,13 @@ export function staleBlockVerdict({ issue, resolutions, unlockActions = [] }) { if (rows.length === 0) return { verdict: 'no-target' }; const open = rows.filter((r) => r.state === 'open'); const unread = rows.filter((r) => r.state !== 'open' && r.state !== 'closed'); + // A PR closed unmerged never landed its fix anywhere: it withholds. + const unmerged = rows.filter((r) => r.state === 'closed' && r.unmerged === true); // Closed THERE is not consumable HERE: a closed cross-repo target withholds. const upstream = rows.filter((r) => r.state === 'closed' && r.local !== true); const declared = [...(unlockActions ?? [])]; - if (open.length > 0 || unread.length > 0 || upstream.length > 0 || declared.length > 0) { - return { verdict: 'held', open, unread, upstream, unlockActions: declared }; + if (open.length > 0 || unread.length > 0 || unmerged.length > 0 || upstream.length > 0 || declared.length > 0) { + return { verdict: 'held', open, unread, unmerged, upstream, unlockActions: declared }; } const assignees = (issue?.assignees ?? []).map((a) => String(a?.login ?? a)); if (assignees.length > 0) return { verdict: 'assigned', assignees }; @@ -611,10 +618,14 @@ export async function judgeCardBlock({ issue, ownerRepo, listComments, getIssue, try { const got = await getIssue({ repo: target.repo, number: target.number }); const state = got?.state === 'closed' || got?.state === 'open' ? got.state : 'unresolved'; + // A PR answers in the issues API's PR shape; a closed one with no + // `merged_at` was abandoned (a missing `merged_at` reads the same: held). + const unmerged = state === 'closed' && got?.pull_request != null && !got.pull_request.merged_at; resolutions.push({ ...target, state, closedAt: got?.closed_at ?? null, + ...(unmerged ? { unmerged: true } : {}), detail: state === 'unresolved' ? `answered state '${got?.state}'` : null, }); } catch (error) { @@ -627,12 +638,15 @@ export async function judgeCardBlock({ issue, ownerRepo, listComments, getIssue, const targetName = (r) => (r.local ? `#${r.number}` : r.key); -// What each target reads as on the summary row. A closed cross-repo target is -// NAMED as the reason it holds, never shown as a bare `closed`. -const targetReading = (r) => - r.state === 'closed' && r.local !== true - ? `${targetName(r)} closed upstream (not an unlock criterion for this consumer)` - : `${targetName(r)} ${r.state}`; +// What each target reads as on the summary row. A closed cross-repo target, and +// a PR closed unmerged, are NAMED as the reason they hold, never a bare `closed`. +const targetReading = (r) => { + if (r.state === 'closed' && r.unmerged === true) return `${targetName(r)} closed unmerged (a PR that never landed)`; + if (r.state === 'closed' && r.local !== true) { + return `${targetName(r)} closed upstream (not an unlock criterion for this consumer)`; + } + return `${targetName(r)} ${r.state}`; +}; // One line for the run log: the verdict and what it was read from. function summariseBlock(block) { @@ -1726,7 +1740,7 @@ async function selfTest() { // log of every call the refresh makes, in order. const blockBoard = ({ labels = [...CARD_LABELS, BLOCKED_LABEL], assignees = [], comments = [], states = {}, cardBody = OLD_BODY, - commentPages = null, failComments = null, failAdd = null, failRemove = null, + commentPages = null, failComments = null, failAdd = null, failRemove = null, pulls = {}, } = {}) => { const card = { number: 23885, title: OPEN_CI_TITLE, body: cardBody, labels: labels.map((name) => ({ name })), assignees: assignees.map((login) => ({ login })) }; @@ -1751,7 +1765,9 @@ async function selfTest() { calls.push(`getIssue ${repo}#${number}`); const state = states[`${repo}#${number}`]; if (!state) throw notFound('Not Found'); - return { state, closed_at: state === 'closed' ? '2026-10-16T13:20:00Z' : null }; + // A number in `pulls` is a PR, answered in the issues API's PR shape. + const pr = Object.hasOwn(pulls, `${repo}#${number}`) ? { pull_request: { merged_at: pulls[`${repo}#${number}`] } } : {}; + return { state, closed_at: state === 'closed' ? '2026-10-16T13:20:00Z' : null, ...pr }; }, addLabels: async ({ number, labels: add }) => { calls.push(`addLabels #${number} ${add.join(',')}`); @@ -1947,6 +1963,30 @@ async function selfTest() { states: { 'objectstack-ai/objectstack#23908': 'closed' } }); if ((await b.refresh()).block !== 'expired') throw new Error('body channel: a body-borne line was not read'); }); + // A park on a PR (#27846): triage names the PR itself, and the issues API + // answers it `closed` whether it merged or was abandoned. MERGED releases + // the card, in either channel; closed UNMERGED holds it and says why; OPEN + // holds it. + await check(async () => { + const PR = 'objectstack-ai/objectstack#24305'; + for (const where of [{ cardBody: `${OLD_BODY}\nBlocked-by: #24305` }, { comments: ['- **Blocked-by: #24305.**'] }]) { + const b = blockBoard({ ...where, states: { [PR]: 'closed' }, pulls: { [PR]: '2026-10-16T13:20:00Z' } }); + const outcome = await b.refresh(); + if (outcome.block !== 'expired' || b.names() !== sorted([...CARD_LABELS, QUEUE_LABEL])) { + throw new Error(`merged PR: expected the card released, got '${outcome.block}' holding ${b.names()}`); + } + } + const abandoned = blockBoard({ cardBody: `${OLD_BODY}\nBlocked-by: #24305`, + states: { [PR]: 'closed' }, pulls: { [PR]: null } }); + const held = await abandoned.refresh(); + untouched(abandoned, held, 'held'); + if (!held.blockSummary.includes('#24305 closed unmerged')) { + throw new Error(`unmerged PR: the summary does not name the abandoned PR as the reason (${held.blockSummary})`); + } + if (abandoned.card.body !== NEW_BODY) throw new Error('unmerged PR: a held block wrote a note into the body'); + const open = blockBoard({ cardBody: `${OLD_BODY}\nBlocked-by: #24305`, states: { [PR]: 'open' }, pulls: { [PR]: null } }); + untouched(open, await open.refresh(), 'held'); + }); // A card naming only itself names no blocker. await check(async () => { const b = blockBoard({ comments: ['Blocked-by: #23885'], states: { 'objectstack-ai/objectstack#23885': 'open' } });