Skip to content

Commit 5bbb9dc

Browse files
committed
wip(pm): fleet-write relay reports created/moved card numbers as check-run annotations
Claude-Session: https://claude.ai/code/session_01FNKm1SmPpuJASnbjxWGtsJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 30af17e commit 5bbb9dc

2 files changed

Lines changed: 285 additions & 20 deletions

File tree

‎scripts/pm/fleet-write/dispatch.mjs‎

Lines changed: 201 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -176,12 +176,14 @@
176176
* and an issue the last re-list still lacks is `unfound` — exit 6,
177177
* UNCONFIRMED, read the board — exactly what it meant before.
178178
*
179-
* Why a re-list and not the run's own report of the number it created: the
180-
* executor prints `#number url` to the job log and the step summary, and a
181-
* seat container reads neither — the job-log endpoint answers 302 to blob
182-
* storage that the egress proxy refuses (CONNECT 403), and the job's check run
183-
* carries a null `output.summary`. Making the run report it somewhere a seat
184-
* CAN read is a change to what the relay emits, not to this read-back.
179+
* The re-list is the FALLBACK. The run knows the number it created, and a
180+
* seat container reads neither the job log (its endpoint answers 302 to blob
181+
* storage the egress proxy refuses, CONNECT 403) nor the step summary (the
182+
* job's check run carries a null `output.summary`) — but it does read the
183+
* check run's ANNOTATIONS, so the run reports the number there (next section).
184+
* An `issue_create` whose action the run's annotation names is read back AT
185+
* that number: one GET, no list, no re-list. Only an action no annotation
186+
* names takes the re-list above.
185187
*
186188
* How the callers read the new outcomes — one exit vocabulary, no caller edited:
187189
* - `failure` + `notStored` keeps the state every caller already reads as "the
@@ -196,6 +198,38 @@
196198
* - a stroke carrying no body (labels, assignees, state, a transfer, the
197199
* GraphQL ops) reads nothing back and its outcome is unchanged.
198200
*
201+
* ## The run's annotations — the numbers a seat CAN read
202+
*
203+
* `execute.mjs` prints, for every action whose op is in `ANNOTATED_OPS` (the
204+
* ops that create or move a card) and whose request LANDED, ONE workflow
205+
* command — a `notice` titled `fleet-write <op>` whose message is
206+
* `fleet-write action=<i> op=<op> number=<n> url=<url>`, the number and url
207+
* taken from the platform's ANSWER to that request (the created issue's
208+
* `number` / `html_url`, the `transferIssue` answer's `issue.number` /
209+
* `issue.url`), never from the request. The runner turns it into an
210+
* annotation on the job's check run. After a success run carrying such an
211+
* op, `sendFleetWrite` reads them — `GET /repos/{board}/actions/runs/{id}/jobs`
212+
* (a job's id is its check run's id, measured) then
213+
* `GET /repos/{board}/check-runs/{id}/annotations`, both measured readable
214+
* from a seat container — and hands them on as `result.annotations`; a
215+
* caller that needs a card's number (`issue-create.mjs`, `issue-transfer.mjs`)
216+
* reads it there first. `relayAnnotationMessage` spells the message and
217+
* `parseRelayAnnotation` reads it — ONE spelling for the writer and every
218+
* reader; the executor emits only a message the parser reads back as what it
219+
* meant. A parsed row is used only when the stroke's action at its index has
220+
* its op and its url names the repository that op lands the card on
221+
* (`matchRunAnnotations`); two different rows for one action are neither.
222+
*
223+
* Absent is an ordinary state, never a failure, and always said: a relay
224+
* older than the emission, an annotation read that does not answer, or an
225+
* action past the platform's cap — 10 notice annotations per step and 50 per
226+
* job (actions/toolkit `docs/problem-matchers.md`), the rest dropped without
227+
* a word, so of a stroke carrying more than ten such ops only the first ten
228+
* are named. Each reader then takes its fallback: the re-list here, the
229+
* redirect or the title in `issue-transfer.mjs`, the title in
230+
* `issue-create.mjs`. ⛔ No second read path beyond that fallback, and ⛔ not
231+
* a gate: an annotation only ever replaces a search for a number.
232+
*
199233
* ## Exit codes — capture them BEFORE any pipe
200234
*
201235
* 0 the run completed with conclusion `success`, and every body it wrote
@@ -525,7 +559,7 @@ export const BODY_OPS = Object.freeze(OP_NAMES.filter((op) => [...OPS[op].requir
525559
export const READ_BACK_LOCATORS = Object.freeze({
526560
issue_patch: Object.freeze({ found: 'address', where: 'GET /repos/{repo}/issues/{issue}' }),
527561
comment_edit: Object.freeze({ found: 'address', where: 'GET /repos/{repo}/issues/comments/{comment_id}' }),
528-
issue_create: Object.freeze({ found: 'key', where: 'the newest issue created since the dispatch whose title is the title sent, the list re-read on ISSUE_CREATE_RELIST_DELAYS_MS before unfound' }),
562+
issue_create: Object.freeze({ found: 'key', where: "the issue at the number the run's annotation names; absent that, the newest issue created since the dispatch whose title is the title sent, the list re-read on ISSUE_CREATE_RELIST_DELAYS_MS before unfound" }),
529563
pr_create: Object.freeze({ found: 'key', where: 'the newest pull request on the head sent, created since the dispatch' }),
530564
comment: Object.freeze({ found: 'content', where: 'the newest comment on the issue, created since the dispatch, whose stored body holds the bytes sent' }),
531565
});
@@ -631,6 +665,88 @@ export function unverifiedText(result, tool = 'fleet-write') {
631665
);
632666
}
633667

668+
// ---------------------------------------------------------------------------
669+
// The run's annotations — pure halves (the header's annotations section is the authority)
670+
// ---------------------------------------------------------------------------
671+
672+
/**
673+
* The ops whose landing the executor reports as an annotation — every op that
674+
* creates or moves a card, and nothing else — each with the repository it
675+
* lands the card on, which a parsed row's url is held to. `execute.mjs` reads
676+
* a number and url out of each one's answer (`ANNOTATION_ANSWERS`, pinned to
677+
* these keys by its self-test).
678+
*/
679+
export const ANNOTATED_OPS = Object.freeze({
680+
issue_create: Object.freeze({ lands: (action, repo) => repo }),
681+
transfer: Object.freeze({ lands: (action) => action?.target_repo }),
682+
});
683+
684+
/** The word a relay annotation's message starts with — and its title, before the op. */
685+
export const RELAY_ANNOTATION_PREFIX = 'fleet-write';
686+
687+
const RELAY_ANNOTATION_SHAPE = new RegExp(`^${RELAY_ANNOTATION_PREFIX} action=([1-9][0-9]*) op=([a-z_]+) number=([1-9][0-9]*) url=(https://\\S+)$`);
688+
/** An issue's web url — group 1 its repository, group 2 its number. */
689+
const ISSUE_URL_SHAPE = /^https:\/\/[^/\s]+\/([^/\s]+\/[^/\s]+)\/issues\/([1-9][0-9]*)$/;
690+
691+
/** The message of the one annotation the executor emits for a landed annotated op. Pure. */
692+
export function relayAnnotationMessage({ action, op, number, url }) {
693+
return `${RELAY_ANNOTATION_PREFIX} action=${action} op=${op} number=${number} url=${url}`;
694+
}
695+
696+
/**
697+
* An annotation's `message` as `{ action, op, number, url, repo }`, or null —
698+
* for anything else on the check run (the runner's own notices, an action's
699+
* deprecation warnings), an op outside `ANNOTATED_OPS`, or a url that is not
700+
* an issue url carrying the same number. Pure; never throws.
701+
*/
702+
export function parseRelayAnnotation(message) {
703+
const m = RELAY_ANNOTATION_SHAPE.exec(typeof message === 'string' ? message : '');
704+
if (!m || !Object.hasOwn(ANNOTATED_OPS, m[2])) return null;
705+
const at = ISSUE_URL_SHAPE.exec(m[4]);
706+
if (!at || at[2] !== m[3]) return null;
707+
return { action: Number(m[1]), op: m[2], number: Number(m[3]), url: m[4], repo: at[1] };
708+
}
709+
710+
/**
711+
* The parsed rows a stroke may use: a row whose action index holds an action
712+
* of its op, and whose url names the repository that op lands the card on.
713+
* Anything else is `ignored`, with why — and so are BOTH rows of an action
714+
* two different rows name: a reader never picks between two answers. Pure.
715+
*/
716+
export function matchRunAnnotations(payload, parsed) {
717+
const actions = Array.isArray(payload?.actions) ? payload.actions : [];
718+
const ignored = [];
719+
const byAction = new Map();
720+
for (const row of Array.isArray(parsed) ? parsed : []) {
721+
const action = actions[row.action - 1];
722+
if (!action || action.op !== row.op) {
723+
ignored.push({ ...row, why: `the stroke's action ${row.action} is ${action ? action.op : 'absent'}, not ${row.op}` });
724+
continue;
725+
}
726+
const lands = String(ANNOTATED_OPS[row.op].lands(action, payload.repo) ?? '');
727+
if (lands.toLowerCase() !== row.repo.toLowerCase()) {
728+
ignored.push({ ...row, why: `its url names ${row.repo}, and ${row.op} lands the card on ${lands || 'no repository'}` });
729+
continue;
730+
}
731+
byAction.set(row.action, [...(byAction.get(row.action) ?? []), row]);
732+
}
733+
const rows = [];
734+
for (const [action, list] of byAction) {
735+
const distinct = new Set(list.map((r) => `${r.number} ${r.url}`));
736+
if (distinct.size > 1) ignored.push(...list.map((r) => ({ ...r, why: `${distinct.size} different annotations name action ${action}` })));
737+
else rows.push(list[0]);
738+
}
739+
rows.sort((a, b) => a.action - b.action);
740+
return { rows, ignored };
741+
}
742+
743+
/** A job's check run id — from its `check_run_url`, else its own id (measured equal). Pure. */
744+
export function checkRunIdOf(job) {
745+
const m = /\/check-runs\/([1-9][0-9]*)$/.exec(String(job?.check_run_url ?? ''));
746+
if (m) return Number(m[1]);
747+
return Number.isInteger(job?.id) && job.id > 0 ? job.id : null;
748+
}
749+
634750
// ---------------------------------------------------------------------------
635751
// Transport — the one POST, paced; the reads around it are not.
636752
// ---------------------------------------------------------------------------
@@ -687,15 +803,66 @@ async function listAll(api, path, t, maxPages = 10) {
687803
return { ok: true, rows };
688804
}
689805

806+
/**
807+
* The relay annotations on a run's check runs — its jobs, then each job's
808+
* check-run annotations, every message through `parseRelayAnnotation` (the
809+
* rest of the check run's annotations are ignored). Returns `{ state, parsed,
810+
* why }`: `read`, or `unread` naming the call that did not answer, with
811+
* whatever was parsed before it. Reads only — never paced; never throws.
812+
*/
813+
export async function readRunAnnotations(runId, deps = {}) {
814+
const api = deps.api ?? DEFAULT_API;
815+
const t = { fetch: deps.fetch, token: deps.token };
816+
const said = (r) => `${r.call} -> HTTP ${r.status}${r.detail ? ` (${r.detail})` : ''}`;
817+
const jobs = await rest(api, `/repos/${RELAY_REPO}/actions/runs/${runId}/jobs?per_page=100`, {}, t);
818+
if (jobs.status !== 200 || !Array.isArray(jobs.json?.jobs)) return { state: 'unread', parsed: [], why: said(jobs) };
819+
const parsed = [];
820+
for (const job of jobs.json.jobs) {
821+
const id = checkRunIdOf(job);
822+
if (id === null) continue;
823+
const r = await rest(api, `/repos/${RELAY_REPO}/check-runs/${id}/annotations?per_page=100`, {}, t);
824+
if (r.status !== 200 || !Array.isArray(r.json)) return { state: 'unread', parsed, why: said(r) };
825+
for (const a of r.json) {
826+
const row = parseRelayAnnotation(a?.message);
827+
if (row) parsed.push(row);
828+
}
829+
}
830+
return { state: 'read', parsed, why: '' };
831+
}
832+
833+
/**
834+
* The annotations a stroke's success run carries, matched to its actions and
835+
* said: one line per row, per ignored row, per annotated action no row names,
836+
* and one when the read did not answer. A stroke with no annotated op reads
837+
* nothing (`state: 'none'`). Returns `{ state, rows, ignored, why }`.
838+
*/
839+
async function runAnnotationsFor(payload, run, deps) {
840+
const annotated = payload.actions.map((a, i) => ({ action: i + 1, op: a.op })).filter((a) => Object.hasOwn(ANNOTATED_OPS, a.op));
841+
if (!annotated.length) return { state: 'none', rows: [], ignored: [], why: '' };
842+
const read = await readRunAnnotations(run.id, deps);
843+
const { rows, ignored } = matchRunAnnotations(payload, read.parsed);
844+
const log = deps.log;
845+
for (const r of rows) log(`fleet-write: run ${run.id}'s annotation names action ${r.action} ${r.op} → ${r.repo}#${r.number} ${r.url} (the platform's own answer).`);
846+
for (const r of ignored) log(`fleet-write: run ${run.id}'s annotation for action ${r.action} ${r.op} (#${r.number}) is ignored — ${r.why}.`);
847+
if (read.state === 'unread') log(`fleet-write: run ${run.id}'s annotations could not be read — ${read.why}; a number no annotation above names is read from the board instead.`);
848+
for (const a of annotated) {
849+
if (!rows.some((r) => r.action === a.action)) log(`fleet-write: no annotation on run ${run.id} names action ${a.action} ${a.op} — its number is read from the board instead (the fallback).`);
850+
}
851+
return { state: read.state, rows, ignored, why: read.why };
852+
}
853+
690854
/**
691855
* Read back every body a completed stroke wrote and judge it against the bytes
692856
* sent. Returns `{ state, rows }` — `state` from `strokeReadBackState`. Never
693857
* throws on a status: a read that fails makes its row `unverified`, with the
694858
* call and status in `why`. `taken` keeps two actions of one stroke from being
695-
* judged against the same created object. `sleep` and `log` serve the
696-
* `issue_create` re-list alone (`ISSUE_CREATE_RELIST_DELAYS_MS`).
859+
* judged against the same created object. `annotations` are the run's matched
860+
* rows (`matchRunAnnotations`): an `issue_create` one of them names is read
861+
* at that number, never listed. `sleep` and `log` serve the `issue_create`
862+
* re-list alone (`ISSUE_CREATE_RELIST_DELAYS_MS`), which only an
863+
* `issue_create` no annotation names takes.
697864
*/
698-
export async function readBackStroke(payload, { dispatchedAt }, deps = {}) {
865+
export async function readBackStroke(payload, { dispatchedAt, annotations = [] }, deps = {}) {
699866
const targets = readBackTargets(payload);
700867
if (!targets.length) return { state: 'none', rows: [] };
701868
const api = deps.api ?? DEFAULT_API;
@@ -719,6 +886,18 @@ export async function readBackStroke(payload, { dispatchedAt }, deps = {}) {
719886
rows.push(r.status === 200 && r.json ? judged(where, r.json.body) : unread(target, where, r));
720887
continue;
721888
}
889+
// An `issue_create` the run's annotation names is read AT that number — the platform's own answer to the create:
890+
// one GET, no list, no re-list (header). A read that fails is `unread`, as any addressed read is.
891+
const named = target.op === 'issue_create' ? annotations.find((a) => a.op === target.op && a.action === target.action) : undefined;
892+
if (named) {
893+
const where = `${repo}#${named.number}`;
894+
const r = await rest(api, `/repos/${repo}/issues/${named.number}`, {}, t);
895+
if (r.status === 200 && r.json) {
896+
if (r.json.id !== undefined) taken.add(r.json.id);
897+
rows.push({ ...judged(where, r.json.body), foundBy: 'annotation' });
898+
} else rows.push({ ...unread(target, where, r), foundBy: 'annotation' });
899+
continue;
900+
}
722901
if (target.op === 'issue_create' || target.op === 'pr_create') {
723902
const isPr = target.op === 'pr_create';
724903
const head = isPr ? (String(target.head).includes(':') ? String(target.head) : `${repo.split('/')[0]}:${target.head}`) : null;
@@ -751,7 +930,7 @@ export async function readBackStroke(payload, { dispatchedAt }, deps = {}) {
751930
continue;
752931
}
753932
taken.add(hit.id);
754-
rows.push(judged(`${repo}#${hit.number}`, hit.body));
933+
rows.push({ ...judged(`${repo}#${hit.number}`, hit.body), ...(isPr ? {} : { foundBy: 'list' }) });
755934
continue;
756935
}
757936
// `comment`: only the body finds a new comment, so a body that matches nothing is unverified, never not-stored.
@@ -783,7 +962,8 @@ export async function readBackStroke(payload, { dispatchedAt }, deps = {}) {
783962
* `success` (the run succeeded and every body reads back as sent) · `failure` (the run completed otherwise, or — `notStored:
784963
* true` — it succeeded and the read-back measured a body the board does not hold as sent) · `unverified` (it succeeded and a
785964
* body could not be read back) · `no-run` · `timeout` · `refused` (the dispatch itself). `readBack` is `readBackStroke`'s
786-
* answer, present once the run succeeded. Never throws on an HTTP status.
965+
* answer, present once the run succeeded, and so is `annotations` — `{ state, rows, ignored, why }`, the run's
966+
* annotations matched to the stroke (`state` `none` when it carries no annotated op). Never throws on an HTTP status.
787967
*/
788968
export async function sendFleetWrite(payload, deps = {}) {
789969
const api = deps.api ?? DEFAULT_API;
@@ -842,18 +1022,21 @@ export async function sendFleetWrite(payload, deps = {}) {
8421022
const done = { ...base, run, status: 204, verdict: 'ok', dispatchedAt };
8431023
if (!ok) return { ...done, state: 'failure', ok: false, detail: `conclusion ${run.conclusion}` };
8441024

1025+
// ── the run's annotations — the numbers it created or moved, where a seat CAN read them (header) ─────
1026+
const annotations = await runAnnotationsFor(payload, run, { api, fetch: deps.fetch, token: deps.token, log });
1027+
8451028
// ── read it back — the run's success is the executor's, not the write's ─────
846-
const readBack = await readBackStroke(payload, { dispatchedAt }, { api, fetch: deps.fetch, token: deps.token, judge: deps.judge, sleep, log });
1029+
const readBack = await readBackStroke(payload, { dispatchedAt, annotations: annotations.rows }, { api, fetch: deps.fetch, token: deps.token, judge: deps.judge, sleep, log });
8471030
for (const row of readBack.rows) log(readBackLine(row));
8481031
if (readBack.state === 'not-stored') {
8491032
const first = readBack.rows.find((r) => r.verdict === 'not-stored');
850-
return { ...done, state: 'failure', ok: false, notStored: true, readBack, detail: `conclusion success, but NOT STORED — action ${first.action} (${first.op} ${first.where}) first differs from the bytes sent at byte ${first.offset}` };
1033+
return { ...done, state: 'failure', ok: false, notStored: true, readBack, annotations, detail: `conclusion success, but NOT STORED — action ${first.action} (${first.op} ${first.where}) first differs from the bytes sent at byte ${first.offset}` };
8511034
}
8521035
if (readBack.state === 'unverified') {
8531036
const first = readBack.rows.find((r) => r.verdict === 'unverified');
854-
return { ...done, state: 'unverified', ok: false, readBack, detail: `conclusion success, but UNVERIFIED — action ${first.action} (${first.op}): ${first.why ?? first.cls}` };
1037+
return { ...done, state: 'unverified', ok: false, readBack, annotations, detail: `conclusion success, but UNVERIFIED — action ${first.action} (${first.op}): ${first.why ?? first.cls}` };
8551038
}
856-
return { ...done, state: 'success', ok: true, readBack, detail: `conclusion ${run.conclusion}` };
1039+
return { ...done, state: 'success', ok: true, readBack, annotations, detail: `conclusion ${run.conclusion}` };
8571040
}
8581041

8591042
/** The exit a tool takes from a result that is not `success`. */
@@ -1778,7 +1961,8 @@ export async function main(argv, deps = {}) {
17781961
not_stored: result.notStored === true,
17791962
run: result.run,
17801963
dispatched_at: new Date(result.dispatchedAt).toISOString(),
1781-
read_back: (result.readBack?.rows ?? []).map((r) => ({ action: r.action, op: r.op, where: r.where, verdict: r.verdict, class: r.cls, first_difference_byte: r.offset ?? null, sent_bytes: r.sentBytes ?? null, stored_bytes: r.storedBytes ?? null })),
1964+
read_back: (result.readBack?.rows ?? []).map((r) => ({ action: r.action, op: r.op, where: r.where, verdict: r.verdict, class: r.cls, first_difference_byte: r.offset ?? null, sent_bytes: r.sentBytes ?? null, stored_bytes: r.storedBytes ?? null, found_by: r.foundBy ?? null })),
1965+
annotations: (result.annotations?.rows ?? []).map((a) => ({ action: a.action, op: a.op, number: a.number, url: a.url })),
17821966
}),
17831967
);
17841968
}

0 commit comments

Comments
 (0)