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
4 changes: 2 additions & 2 deletions .claude/skills/pm-dispatch/references/triage-duties.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 产品卡)或点名所护的已发布面;
Expand Down Expand Up @@ -97,6 +98,5 @@
- 轮次报告设呈裁清单专节(卡、推荐、所引权威),⛔ 无代裁清单、无追认表。
- 回翻条款:实施中发现契约终究要动 ⇒ dev 停手,卡回决策箱,⛔ 永不静默重裁。
- 请示纪律:方向性授权覆盖整条执行链一并做完,⛔ 不逐项回问。
- 中间状态与限流/重试类运维细节只进座位贴和触发器,⛔ 不弹给维护者。
- 汇报只在里程碑、真阻塞、被问到时。
- 汇报只在里程碑、真阻塞、被问到时;中间状态与限流/重试类细节只进座位贴和触发器。
- 人工地板项(发版、天花板、契约扩大)仍需明确字句,但攒批不逐项弹。
49 changes: 45 additions & 4 deletions scripts/ci/scheduled-full-run-card.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
68 changes: 54 additions & 14 deletions scripts/ci/scheduled-full-run.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -561,19 +565,22 @@ 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' };
const rows = resolutions ?? [];
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 };
Expand Down Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down Expand Up @@ -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 })) };
Expand All @@ -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(',')}`);
Expand Down Expand Up @@ -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' } });
Expand Down
Loading