@feigi/fleet-ctl 3.97.0 → 3.98.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -18,7 +18,7 @@ argument-hint: [pr-number]
|
|
|
18
18
|
5. File each deferred finding: `gh issue create --label needs-triage` with the finding, `file:line`, why deferred, `Deferred from PR #<pr> review`, and **the finding's `dimension` in the issue body** — the review returns it on every finding, and a backlog that does not carry it cannot be attributed to the specialist that generated it. **The body also carries the evidence triage needs: the refuter's verdict — the `verdictPath` reason, or the review's votes — and the candidate remedy.** **Every issue you create is labelled `needs-triage`; only triage sets `ready-for-agent`** — and the label is never omitted; an unlabelled issue is invisible to both `candidates.mjs --require-label` and the cockpit's pool query, reachable only by a human. **A confirmed defect does not settle its remedy**, so confirming one earns it no other label: a confirmed defect with the remedy still open files `needs-triage`, and so does a finding you are unsure is broken at all. **A finding a refuter CHECKED and did not survive is neither** — it is not a confirmed defect, and you are not unsure whether anything is broken: you measured that nothing is. Refutations go as **one issue per review, titled `PR #<pr> review: the suggestion band, checked`, labelled `wontfix`, and CLOSED** — a run that files them open instead has to be corrected afterwards, as one was on 2026-08-27. Left open they read as queued agent work and rot into filing artifacts; a measured triage pass found a quarter of its `needs-triage` backlog was refuted records, bulk-closable by title shape. Closed is still a durable home — `check` reads the tracker at `--state all`, so a future run re-deriving the finding lands on the evidence rather than re-litigating it. Split the band only where an entry is **not** a refutation: a residual the refuter surfaced, or an out-of-scope finding its refuter let survive or never decided, is real deferred work — and real deferred work earns its own open issue only past a second bar. **Worth a claim: a finding whose own claim is that correct code could be shaped better — style, naming, layout, redundancy, a micro-simplification — never gets its own open issue.** **Neither does a gap in a prose guard — a test holding prose to its wording — unless something acts on the guarded text: an agent follows it as an instruction (a skill, a command, an agent definition, a script's header or usage text, `AGENTS.md`), or a program other than its own guard reads it.** Whether anything does is your judgement, as `claimKind` is, and a gap below this bar is recorded `claimKind: shape`. A gap in a guard's own code — a slow pattern, a dead branch, an unchecked spawn result — takes the stakes of the prose it guards, and a helper several guards share takes the highest of theirs. A missed wording already in the tree is no guard gap — that prose is wrong — and neither is an input already in the tree that trips a guard's code; each files on its own merits. Once the prose is corrected, the observed wording may join the guard's literal ban, never a reader built to catch wordings nobody has written. A below-bar finding's only open question is "is this worth doing?", which answers itself under the repriced triage bar: measured 2026-08-15→31, 133 issues were triage-closed `wontfix` in sixteen days, 121 of them review deferrals, mostly this shape. Append it to the same closed record issue under a `Below the claim bar` heading — `file:line`, dimension, the claim, and a re-open trigger per entry. Closed is a durable home for these too: `check` reads `--state all`, so a run re-deriving the finding lands on the record instead of re-filing — and a later review returning the same finding as `survived`, or a maintainer hitting it, is the promotion signal: file it open then, citing the record. **An in-scope rediscovery step 2 applied is that same promotion signal, and it files nothing** — so the record carries no mark unless you write one: run `check` over each finding step 2 applied as well, and where its tracker rows put one on a `Below the claim bar` entry, comment on that record issue, opening `Promoted — applied, not filed`, and name the entry, that its refuter let it through, and the commit carrying the fix. That comment is the promotion mark this path leaves, and it is **not** a cross-reference — measured on this tracker, a comment mints `cross-referenced` on the issue its own text *names*, never on the issue it sits under (a comment citing a second issue landed on that issue's timeline and nowhere else), so the fixed opening is what carries the signal instead. **The bar reads the finding's claim, never its review band** — a finding that alleges wrong behavior, whatever band it sat in, `unverified`-with-crashed-refuters included, is above this bar and files open, `needs-triage`, which is why a band cannot carry this decision. Do **not** reach for phase 0's **decided?** test here. That is a *consumption* gate, and whether a filed issue is decided enough to reach it is triage's call, never the filer's. **The cost of this rule is watched:** open `needs-triage` issues citing `Deferred from PR` above 40 on 3 consecutive run dates send the filing rule back to the maintainer — batch triage, a fleet triage role, or a different bar. **Dedupe through `~/.fleet/bin/fleet-run ledger.mjs check "<subject>"`, never a bare `gh issue list --search`** — the duplicate class that guard exists to catch was filed from *here*: the ledger row for the finding rediscovered five times in one measured run carries a reviewer's own source tag, `(review-pr-108)`. `check` reads this run's filed list *and* the tracker at `--state all`, so a closed duplicate still counts. Exit **1** is already filed in this run; exit **3** is a clean ledger with matching tracker rows that score against the subject — read the ones it prints and comment on the existing follow-up rather than filing a second issue. **Exit 0 is not automatically "safe to file"**: it covers `clean`, `soft-hit` and `unverified` alike, and an unreachable `gh` degrades to a ledger-only answer that says `TRACKER NOT CHECKED`, so read the stdout `verdict` to tell a tracker that was searched and clean from one nobody read — under `unverified`, search it yourself before filing — or, for the applied-promotion comment above, before treating a tracker nobody could read as a reason to skip it. **`soft-hit` is the answer that costs most to misread**: it means related rows were found and none of them was established as a duplicate — a near-miss above the floor, or tracker rows the scorer rates 0.00 — so read the rows it printed and decide, and never treat that exit 0 as permission. Exit **2** is a usage error, meaning nothing was checked at all: fix the invocation and re-run, never read it as a duplicate. Full exit-code and `verdict` semantics live in `$(~/.fleet/bin/fleet-run --root)/skills/run-team/references/ledger.md`, which `SKILL.md`'s wake section points at — read them there rather than restating them here. **The guard dedupes, it never drops:** a duplicate becomes a comment on the issue that already covers it, never a dropped finding. **Record every issue you create, at once:** immediately after each `gh issue create`, run `~/.fleet/bin/fleet-run ledger.mjs filed <N> "<subject>"` — `<N>` the number `gh issue create` printed, and **the same subject string you passed to `check`**, so the next `check` for that finding meets it in the filed list instead of scoring a rewording. Without it the guard's ledger half never holds a row, and its exit **1** can never fire. If `filed` fails, retry it once; if it fails again, leave the issue alone — it exists and is correct — and report it as `unrecorded: #N <subject>`, so the controller runs `filed` for it. Report `filed: #N <subject>` for every issue you created — under the fleet, in your report to the controller. **The one window the guard cannot see:** `check` → `gh issue create` → `filed` is three commands, and the ledger's lock covers each alone, so two filers can both read `clean` in the seconds before either records. That gap is accepted, never a reason to skip `filed`. Post the numbers as one PR comment.
|
|
19
19
|
**A duplicate that should hold an open deferral is a comment, never a relabel.** Where the guard lands a finding whose home is an open issue on an existing one that is open, cite the refuters' verdict — or the review's votes — in the comment you leave there, and change no label. Never relabel `ready-for-agent` down to `needs-triage`: a `ready-for-agent` issue already holds a `needs-triage` finding, because triage already promoted it, and a later run's crashed refuters are no reason to send it back. A closed one cannot hold it: file a new open issue citing it, as the promotion rule above does for the closed record.
|
|
20
20
|
**Name where each deferral went, in the disposition record.** Every entry you defer carries `issue`: the number of the issue you filed it to, commented it onto, or appended it to — the `PR #<pr> review: the suggestion band, checked` record included. A `suggestion`'s entry — an `unverified` finding with `refutersDispatched` of zero — also carries `verdictPath`, applied or deferred, in scope and, above the claim bar, out of scope: the absolute path of a file holding its refuter's verdict as JSON, `{"refuted": <boolean>, "reason": "<string>"}`, under your own `<scratch>/pr<N>/fix-XXXXXXXX/<finding>/` run root. An out-of-scope one whose refuter crashed before any verdict reached you still names the path its verdict would have taken there; out of scope, that file missing or holding no decision files the finding open `needs-triage`, while in scope it is a mismatch. `dispositions-check.mjs` reads each named issue back through `gh issue view --json labels,state,title` and each verdict file off disk; a mismatch names the row of its filing table the entry broke, so move or relabel that filing to the home the row names and run the check again. With `gh` unreachable, or an issue it cannot read, that filing goes unchecked: with nothing else broken the verdict is `unchecked` — never `ok`, and it exits 1 — so run the check again once `gh` answers.
|
|
21
|
-
6. Diff-check green (run-bound per `ci-state.mjs`; under staleness see **`skipped` is not `passed`** for what the label then rests on) **and** deferrals filed **and** the PR carrying exactly one release label (`patch`/`minor`/`major`, counting only those three; **required on every PR** — `release-label.yml` auto-adds `patch`
|
|
21
|
+
6. Diff-check green (run-bound per `ci-state.mjs`; under staleness see **`skipped` is not `passed`** for what the label then rests on) **and** deferrals filed **and** the PR carrying exactly one release label (`patch`/`minor`/`major`, counting only those three; **required on every PR** — `release-label.yml` auto-adds `patch` only to a dependabot or renovate PR that has none, and hard-fails any PR with none or more than one, so this never needs to check whether the repo defines the gate at all, and `gh label list` settles nothing here any more) **and** `dispositions-check.mjs --member fix-pr-<pr> --scratch <scratch>` run over your own record exiting 0 (standalone that is yours to run, from the worktree, after step 2 writes `<scratch>/dispositions-fix-pr-<pr>.json`; `--review <path>` names the review file, which you write yourself from the findings step 1 collected, in the shape **The review result file** describes — `pr`, `head`, `snapshot`, `counts` and the `survived`, `refuted` and `unverified` buckets; standalone you pass `--no-ledger` with it, so it writes no token and the exit status is the verdict — a `.fleet/ledger.md` the repository already holds is a previous run's, and without the flag it exits 2 for a ledger with no row for you; under the fleet `ledger.mjs dispatch` has already refused a finisher without it) → `gh pr comment <pr> --body "ready-to-merge-head: <prHead>"`, `prHead` being the full SHA your green `ci-state.mjs` reading reported, which the merge bot reads as the head the label was applied to, then `gh pr edit <pr> --add-label ready-to-merge`. Do not merge. A non-zero dispositions exit halts you before the label, and you report the violations it printed. Zero or more than one release label halts you before the label, naming which you found — a timed-out `gh pr create` opens the PR with its `--label` unapplied and no exit status to react to, and nothing after this step re-derives the release label. **Under the fleet a finisher does this** — a fresh small agent the controller dispatches (after your final verdict, never on your bare idle). Gate it on the diff-validating **`check`** job, **not** on `ci-state --quiet` exit 0, which a behind PR — the normal case once any sibling merges — never reaches: a `rebase-check` red or heavy jobs `skipped` off the behind-count hold it short of full green, so an exit-0 gate strands every behind PR unlabelled. Label when `check` is present and green **and no heavy job (the diff-validating suites — *not* the `rebase-check` currency gate) is in `failure`** — a `skipped` heavy job is behind-count staleness and fine — **solely** off that count, which is a condition to establish rather than infer, see the five-condition note in `$(~/.fleet/bin/fleet-run --root)/skills/run-team/SKILL.md`'s fix-applier prompt block. A `failure` is the diff's own fault and blocks the label, else a genuinely broken PR gets labelled off a green `check` and burns a merge-bot rebase+CI cycle before the failure resurfaces. Read per-job state for this: run `ci-state.mjs` **without** `--quiet`, or read its `jobs` — the `--quiet` payload drops the per-job `jobs`/`missing` and the `dropped` list, keeping `reasons` (which still distinguishes `is failure` from `is skipped`). You reach step 6 yourself only when green is already in hand at push time.
|
|
22
22
|
|
|
23
23
|
**`verdict: "no-ci"` — this repo has no `check` job to gate on at all**, and that is a different state from red or unknown, not a third way to skip the wait. Label off step 3's own green `testCmd` run instead of a `check` job that will never exist — and only once `ci-state.mjs` reads `no-ci` **without** the flag, since a repo whose CI is merely misconfigured exits 2 there rather than reading no-ci. `--declare-no-ci` is your caller-side declaration that step 3's run **is** this repo's gate: it flips the exit code and echoes itself into `reasons`, so finding it in the payload confirms only that you passed it, never that anyone verified anything. **No green `testCmd` run on record → do not label.** Say in your verdict that the repo has no CI configured and no declaration, and stop — inferring a pass from silence here is exactly the misconfigured-CI case the declaration exists to rule out. See `ci-state.mjs --pr <n> --declare-no-ci` below.
|
|
24
24
|
|
package/package.json
CHANGED
|
@@ -61,11 +61,10 @@ import { spawnSync } from "node:child_process";
|
|
|
61
61
|
import { statSync } from "node:fs";
|
|
62
62
|
import { makeDie, defineFlags, writeAll } from "./arg.mjs";
|
|
63
63
|
import { gitEnv } from "./git-env.mjs";
|
|
64
|
+
import { LABEL_LIST_ARGS, LABEL_LIST_LIMIT, releaseLabelsDefined, releaseLabelsIn } from "./release-labels.mjs";
|
|
64
65
|
|
|
65
66
|
const NAME = "finisher-label";
|
|
66
67
|
const LABEL = "ready-to-merge";
|
|
67
|
-
const RELEASE = ["patch", "minor", "major"];
|
|
68
|
-
const LIST_LIMIT = 1000;
|
|
69
68
|
|
|
70
69
|
const die = makeDie(NAME);
|
|
71
70
|
const { arg, numArg, sweep, stray } = defineFlags(die, {
|
|
@@ -156,17 +155,16 @@ const first = ghJson("read-labels", ["pr", "view", String(pr), "--json", "labels
|
|
|
156
155
|
isObject(v) && namedList(v.labels) && Array.isArray(v.comments) && v.comments.every((c) => isObject(c) && typeof c.body === "string" && typeof c.createdAt === "string"));
|
|
157
156
|
const firstLabels = labelNames(first);
|
|
158
157
|
receipt.firstRead = firstLabels;
|
|
159
|
-
const found =
|
|
158
|
+
const found = releaseLabelsIn(firstLabels);
|
|
160
159
|
|
|
161
160
|
if (found.length === 0) {
|
|
162
|
-
const defined = ghJson("list-labels",
|
|
163
|
-
|
|
161
|
+
const defined = ghJson("list-labels", LABEL_LIST_ARGS, namedList).map((l) => l.name);
|
|
162
|
+
const state = releaseLabelsDefined(defined);
|
|
163
|
+
if (state === "defined") {
|
|
164
164
|
finish(3, { releaseLabels: found }, "the PR carries no release label (patch, minor or major); nothing was written");
|
|
165
165
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
if (defined.length >= LIST_LIMIT) {
|
|
169
|
-
fail("list-labels", `the repository has ${LIST_LIMIT} or more labels, so the list may be cut off before patch, minor or major`);
|
|
166
|
+
if (state === "cut-off") {
|
|
167
|
+
fail("list-labels", `the repository has ${LABEL_LIST_LIMIT} or more labels, so the list may be cut off before patch, minor or major`);
|
|
170
168
|
}
|
|
171
169
|
receipt.releaseLabels = "undefined-in-repo";
|
|
172
170
|
} else if (found.length > 1) {
|
package/scripts/ledger.mjs
CHANGED
|
@@ -64,6 +64,7 @@ import { attemptLock, releaseLockFile, reapPathOf } from "./pid-lock.mjs";
|
|
|
64
64
|
import { readCheckRecordIn, readReportIn, reportsDir, reportFamily, defaultScratch, reportPath } from "./report-files.mjs";
|
|
65
65
|
import { mergeBotVerdicts } from "./report-schema.mjs";
|
|
66
66
|
import { ownClosingIssues } from "./closing-refs.mjs";
|
|
67
|
+
import { RELEASE_LABELS, LABEL_LIST_ARGS, LABEL_LIST_LIMIT, releaseLabelsIn, releaseLabelsDefined } from "./release-labels.mjs";
|
|
67
68
|
|
|
68
69
|
const NAME = "ledger";
|
|
69
70
|
// The plugin's own agent definitions — the directory tier-check.mjs reads
|
|
@@ -1962,20 +1963,21 @@ function checkReaders(where) {
|
|
|
1962
1963
|
return repo;
|
|
1963
1964
|
};
|
|
1964
1965
|
// A child that must answer with one of `answers`; anything else — another
|
|
1965
|
-
// status, a spawn failure, a timeout — could not look
|
|
1966
|
-
|
|
1966
|
+
// status, a spawn failure, a timeout — could not look, or whatever `lead`
|
|
1967
|
+
// names instead.
|
|
1968
|
+
const look = (what, cmd, args, answers = [0], lead = "could not look") => {
|
|
1967
1969
|
const r = spawnSync(cmd, args, { encoding: "utf8", cwd: repoDir(), env, timeout: checkTimeoutMs, stdio: ["ignore", "pipe", "pipe"] });
|
|
1968
1970
|
if (r.status === null || !answers.includes(r.status)) {
|
|
1969
1971
|
const why = cause(r.error && r.error.message, r.stderr);
|
|
1970
|
-
die(`${where}:
|
|
1972
|
+
die(`${where}: ${lead} — ${what}${r.status === null ? "" : ` exited ${r.status}`}${why ? `: ${why}` : ""}; nothing written`);
|
|
1971
1973
|
}
|
|
1972
1974
|
return r;
|
|
1973
1975
|
};
|
|
1974
1976
|
const verifyShaOf = (branch, sha) => look(`verify-sha.sh ${branch} ${sha}`, "sh", [verifySha, branch, sha], [0, 1]);
|
|
1975
1977
|
// A gh answer, parsed: `v` the JSON it printed, or null when it printed
|
|
1976
1978
|
// none, beside the answer `r` itself for the caller's refusal.
|
|
1977
|
-
const lookJson = (what, args) => {
|
|
1978
|
-
const r = look(what, "gh", args);
|
|
1979
|
+
const lookJson = (what, args, lead = "could not look") => {
|
|
1980
|
+
const r = look(what, "gh", args, [0], lead);
|
|
1979
1981
|
try {
|
|
1980
1982
|
return { r, v: JSON.parse(r.stdout) };
|
|
1981
1983
|
} catch {
|
|
@@ -2020,7 +2022,23 @@ function checkReaders(where) {
|
|
|
2020
2022
|
}
|
|
2021
2023
|
return v.title;
|
|
2022
2024
|
};
|
|
2023
|
-
|
|
2025
|
+
// The label names gh answers `args` with, `list` taking the label list off
|
|
2026
|
+
// the parsed answer. A read that fails, or answers anything but a list of
|
|
2027
|
+
// named labels, could not read `whose` labels.
|
|
2028
|
+
const labelNames = (whose, args, list) => {
|
|
2029
|
+
const what = `gh ${args.join(" ")}`;
|
|
2030
|
+
const lead = `could not read ${whose} labels`;
|
|
2031
|
+
const { r, v } = lookJson(what, args, lead);
|
|
2032
|
+
const labels = list(v);
|
|
2033
|
+
if (!Array.isArray(labels) || !labels.every((l) => l !== null && typeof l === "object" && typeof l.name === "string")) {
|
|
2034
|
+
die(`${where}: ${lead} — ${what} printed no list of named labels: ${cause(r.stdout)}; nothing written`);
|
|
2035
|
+
}
|
|
2036
|
+
return labels.map((l) => l.name);
|
|
2037
|
+
};
|
|
2038
|
+
// PR `pr`'s label names, and the names of the labels its repository defines.
|
|
2039
|
+
const prLabels = (pr) => labelNames(`PR #${pr}'s`, ["pr", "view", String(pr), "--json", "labels"], (v) => v?.labels);
|
|
2040
|
+
const repoLabels = () => labelNames("the repository's", LABEL_LIST_ARGS, (v) => v);
|
|
2041
|
+
return { repoDir, look, verifyShaOf, prView, prViewClosing, issueTitle, prLabels, repoLabels };
|
|
2024
2042
|
}
|
|
2025
2043
|
|
|
2026
2044
|
// The finisher gate's closing-reference checks for the PR `member` finishes,
|
|
@@ -2032,9 +2050,11 @@ function checkReaders(where) {
|
|
|
2032
2050
|
// body when the PR's ruling is closed_own_ticket=yes, and is not when the
|
|
2033
2051
|
// ruling is no. Check 2, for every PR: no reference may be the
|
|
2034
2052
|
// suggestion-band record of this PR's review — an issue whose title, trimmed,
|
|
2035
|
-
// is recordTitle(), as dispositions-check.mjs finds the record. Returns
|
|
2036
|
-
//
|
|
2037
|
-
//
|
|
2053
|
+
// is recordTitle(), as dispositions-check.mjs finds the record. Returns
|
|
2054
|
+
// `{refs, failures}`: the issue numbers the PR closes, its own
|
|
2055
|
+
// suggestion-band record aside — that one is a failure here, not a ticket —
|
|
2056
|
+
// and one line per failure. A read the checks need dies, naming the read,
|
|
2057
|
+
// and writes nothing.
|
|
2038
2058
|
async function closingRefusals(member, { tickets, ruling, tsv }) {
|
|
2039
2059
|
const pr = member.number;
|
|
2040
2060
|
const where = `dispatch ${member.name}`;
|
|
@@ -2057,10 +2077,42 @@ async function closingRefusals(member, { tickets, ruling, tsv }) {
|
|
|
2057
2077
|
}
|
|
2058
2078
|
}
|
|
2059
2079
|
const own = recordTitle(pr);
|
|
2080
|
+
const closed = [];
|
|
2060
2081
|
for (const n of refs) {
|
|
2061
2082
|
if (issueTitle(n).trim() === own) failures.push(`PR #${pr} closes #${n}, its own suggestion-band record — rephrase the mention as "Refs #${n}" or "issue #${n}"`);
|
|
2083
|
+
else closed.push(n);
|
|
2062
2084
|
}
|
|
2063
|
-
return failures;
|
|
2085
|
+
return { refs: closed, failures };
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
// The finisher gate's release-label check for the PR `member` finishes: the
|
|
2089
|
+
// PR carries exactly one release label, or its repository defines none, as
|
|
2090
|
+
// finisher-label.mjs counts them before it labels. `tickets` are the tickets
|
|
2091
|
+
// rulingOf() found settled implementer tokens naming the PR for, and `refs`
|
|
2092
|
+
// the issues closingRefusals() read it closing, its own suggestion-band
|
|
2093
|
+
// record aside. Returns the refusal, or null.
|
|
2094
|
+
// A PR with none is repaired with `patch` only when it closes no ticket and
|
|
2095
|
+
// no such token names it; any other PR's bump is not picked here, and
|
|
2096
|
+
// neither is which of several labels stays. A read the check needs dies,
|
|
2097
|
+
// naming the labels it could not read, and writes nothing.
|
|
2098
|
+
function releaseLabelRefusal(member, tickets, refs) {
|
|
2099
|
+
const pr = member.number;
|
|
2100
|
+
const where = `dispatch ${member.name}`;
|
|
2101
|
+
const { prLabels, repoLabels } = checkReaders(where);
|
|
2102
|
+
const found = releaseLabelsIn(prLabels(pr));
|
|
2103
|
+
if (found.length === 1) return null;
|
|
2104
|
+
if (found.length > 1) return `PR #${pr} carries more than one release label (${found.join(", ")}) — exactly one may stay, and which is not picked here`;
|
|
2105
|
+
const defined = releaseLabelsDefined(repoLabels());
|
|
2106
|
+
if (defined === "undefined") return null;
|
|
2107
|
+
if (defined === "cut-off") {
|
|
2108
|
+
die(`${where}: could not read the repository's labels — gh ${LABEL_LIST_ARGS.join(" ")} answered ${LABEL_LIST_LIMIT} or more, so the list may be cut off before ${RELEASE_LABELS.join(", ")}; nothing written`);
|
|
2109
|
+
}
|
|
2110
|
+
const missing = `PR #${pr} carries no release label (${RELEASE_LABELS.join(", ")})`;
|
|
2111
|
+
if (refs.length === 0 && tickets.length === 0) return `${missing} and closes no ticket — add one: gh pr edit ${pr} --add-label patch`;
|
|
2112
|
+
const closes = refs.length > 0
|
|
2113
|
+
? `closes ${refs.map((n) => `#${n}`).join(", ")}`
|
|
2114
|
+
: `closes no ticket, and is the implementer PR of ${tickets.map((n) => `#${n}`).join(", ")}`;
|
|
2115
|
+
return `${missing} and ${closes} — add exactly one, the bump its change calls for: gh pr edit ${pr} --add-label <${RELEASE_LABELS.join("|")}>`;
|
|
2064
2116
|
}
|
|
2065
2117
|
|
|
2066
2118
|
// The open `ready-to-merge` PRs, ascending, as gh answers from the ledger's
|
|
@@ -2220,9 +2272,10 @@ async function runDispatch() {
|
|
|
2220
2272
|
// waits on its ruling, its tier-outcomes row, written while the PR is open
|
|
2221
2273
|
// and its review evidence still in scratch; see rulingOf(). Last, the
|
|
2222
2274
|
// PR's closing references must match that ruling and exclude its own
|
|
2223
|
-
// suggestion-band record,
|
|
2224
|
-
//
|
|
2225
|
-
//
|
|
2275
|
+
// suggestion-band record, and the PR must carry exactly one release label,
|
|
2276
|
+
// both read off gh from the ledger's repository; see closingRefusals() and
|
|
2277
|
+
// releaseLabelRefusal(). Refused here, before anything is written, so no
|
|
2278
|
+
// check can be skipped by forgetting to run it.
|
|
2226
2279
|
if (member.family === "finisher-pr") {
|
|
2227
2280
|
const fold = tickFold("conflict hold and dispositions verdict");
|
|
2228
2281
|
if (fold.conflictHeld.includes(member.number)) {
|
|
@@ -2235,7 +2288,9 @@ async function runDispatch() {
|
|
|
2235
2288
|
const ruled = await rulingOf(member.number);
|
|
2236
2289
|
if (ruled.refusal !== null) die(`${member.name}: ${ruled.refusal} — not dispatching ${member.name}`);
|
|
2237
2290
|
const closing = await closingRefusals(member, ruled);
|
|
2238
|
-
|
|
2291
|
+
const labelled = releaseLabelRefusal(member, ruled.tickets, closing.refs);
|
|
2292
|
+
const failures = labelled === null ? closing.failures : [...closing.failures, labelled];
|
|
2293
|
+
if (failures.length > 0) die(`${member.name}: ${failures.join("; ")} — not dispatching ${member.name}`);
|
|
2239
2294
|
}
|
|
2240
2295
|
// Every error caught here carries its own cause and remedy — a row's `tier=`
|
|
2241
2296
|
// (fix it with `ledger.mjs row`) for an implementer, a family
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The release labels, as every reader of them counts them: a PR carries
|
|
2
|
+
// exactly one of `patch`, `minor` and `major`, and a repository whose label
|
|
3
|
+
// list defines none of the three has no release label to count. Shared by
|
|
4
|
+
// finisher-label.mjs, which counts them before it labels, and the finisher
|
|
5
|
+
// gate in ledger.mjs, which counts them before it dispatches a finisher.
|
|
6
|
+
|
|
7
|
+
export const RELEASE_LABELS = Object.freeze(["patch", "minor", "major"]);
|
|
8
|
+
|
|
9
|
+
// `gh label list` reads at most this many labels.
|
|
10
|
+
export const LABEL_LIST_LIMIT = 1000;
|
|
11
|
+
|
|
12
|
+
// The `gh` arguments that read the repository's label list.
|
|
13
|
+
export const LABEL_LIST_ARGS = Object.freeze(["label", "list", "--json", "name", "--limit", String(LABEL_LIST_LIMIT)]);
|
|
14
|
+
|
|
15
|
+
// The release labels among the label names `names`, in RELEASE_LABELS' order.
|
|
16
|
+
export const releaseLabelsIn = (names) => RELEASE_LABELS.filter((r) => names.includes(r));
|
|
17
|
+
|
|
18
|
+
// What the repository's label names `defined`, as LABEL_LIST_ARGS reads them,
|
|
19
|
+
// say of the release labels: "defined" when they name one, "undefined" when
|
|
20
|
+
// they name none, and "cut-off" when they name none but fill the limit — a
|
|
21
|
+
// list cut off before the release labels proves nothing of their absence.
|
|
22
|
+
export function releaseLabelsDefined(defined) {
|
|
23
|
+
if (releaseLabelsIn(defined).length > 0) return "defined";
|
|
24
|
+
return defined.length >= LABEL_LIST_LIMIT ? "cut-off" : "undefined";
|
|
25
|
+
}
|
package/skills/run-team/SKILL.md
CHANGED
|
@@ -558,7 +558,8 @@ Dispatch a finisher only when every test here passes at once — the review retu
|
|
|
558
558
|
bullet and the one above are a pair, and on a clean PR this one carries the
|
|
559
559
|
whole route to a finisher**: a `no-op` report satisfies the gate above by
|
|
560
560
|
itself, so nothing further is owed and no further event is coming. Edit either bullet without the other and what strands is exactly the PR that needed no fixes.
|
|
561
|
-
- **The PR's closing references match its ruling and exclude its own suggestion-band record** → `ledger.mjs dispatch`
|
|
561
|
+
- **The PR's closing references match its ruling and exclude its own suggestion-band record** → `ledger.mjs dispatch` refuses the finisher otherwise, naming the PR and the ticket: repair the body with `gh pr edit <M> --body-file <file>`, then dispatch again.
|
|
562
|
+
- **The PR carries exactly one release label, or its repo defines none** → `ledger.mjs dispatch` refuses the finisher otherwise, naming `patch` as the repair only when the PR closes no ticket and no settled `impl-<N>=PR#<M>` token names it. Fix the labels, then dispatch again. In full: references/finisher.md.
|
|
562
563
|
- **`ci-state.mjs --pr <N>` reads `verdict: "no-ci"`** → the CI edge never
|
|
563
564
|
fires: dispatch the finisher off the reviewer's final verdict the moment it
|
|
564
565
|
lands, never while you still owe it a reply. Its gate is then the
|
|
@@ -118,6 +118,7 @@ and will act on it. Relay everything — reversals too — then dispatch.
|
|
|
118
118
|
is never held once the first passed; a PR no such token names is not gated.
|
|
119
119
|
No flag skips it: a PR `append` refuses is fixed on the ledger, as at close-out.
|
|
120
120
|
- **The PR's closing references match its ruling and exclude its own suggestion-band record** → `ledger.mjs dispatch` reads the PR's `closingIssuesReferences` from `gh pr view <M>` before it dispatches any finisher, counting only those in the PR's own repository: a reference into another repository closes none of this repository's tickets, whatever its number. When the ruled row says `closed_own_ticket=yes`, each ticket a settled `impl-<N>=PR#<M>` token names must be closed by the body (`Closes #<N>`); when it says `no`, none may be, and no reference may be the PR's own suggestion-band record. A blank verdict is refused until it is set. A refusal names the PR and the ticket. The controller repairs the body itself with `gh pr edit <M> --body-file <file>`, then dispatches the finisher again. A `gh` read that fails refuses the dispatch and writes nothing.
|
|
121
|
+
- **The PR carries exactly one release label** → `ledger.mjs dispatch` reads the PR's labels from `gh pr view <M>` before it dispatches any finisher, and refuses unless exactly one of `patch`, `minor` and `major` is among them — the count `finisher-label.mjs` makes before it labels. A repository whose label list defines none of the three is not gated. A PR with none, which closes no ticket and no settled `impl-<N>=PR#<M>` token names, is refused naming the repair, `gh pr edit <M> --add-label patch`; any other PR with none is refused naming the tickets it closes, and one with several naming the labels found — the bump, or which label stays, is not picked for the controller. The controller adds or removes the label itself, then dispatches the finisher again. A `gh` read that fails or answers malformed refuses the dispatch, saying the labels could not be read, and writes nothing.
|
|
121
122
|
|
|
122
123
|
Gate on the `check` job, **not** on `ci-state --quiet` exit 0: a behind PR never
|
|
123
124
|
reaches full green, so an exit-0 gate strands it unlabelled. The finisher reads
|