@feigi/fleet-ctl 3.101.0 → 3.102.0
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.
- package/package.json +1 -1
- package/scripts/closeout.mjs +2 -2
- package/scripts/dispositions-check.mjs +6 -2
- package/scripts/ledger.mjs +2 -2
- package/scripts/tier-outcomes.mjs +305 -36
- package/skills/run-team/SKILL.md +3 -3
- package/skills/run-team/references/close-out.md +1 -2
- package/skills/run-team/references/pull.md +6 -0
package/package.json
CHANGED
package/scripts/closeout.mjs
CHANGED
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
// step. At exit 0 each `WARNING PR #<n> carries reviewed=` on
|
|
36
36
|
// its stderr is a PR the ledger marks reviewed with no row:
|
|
37
37
|
// `NEED tier-outcomes append <n>`, and the step is pending.
|
|
38
|
-
// The verdicts
|
|
39
|
-
// writes one.
|
|
38
|
+
// The verdicts come from `tier-outcomes.mjs append`, which
|
|
39
|
+
// works them out from the Charges; this script never writes one.
|
|
40
40
|
// metrics `metrics-refresh.mjs`. Any non-zero exit is a failed step.
|
|
41
41
|
// commit-and-pr `git switch -c <branch>` (`--branch`, default
|
|
42
42
|
// `chore/run-artifacts-<UTC date>`, suffixed `b`, `c`… while
|
|
@@ -192,7 +192,9 @@ const LEDGER_SCRIPT = join(SCRIPT_DIR, "ledger.mjs");
|
|
|
192
192
|
|
|
193
193
|
// Two spellings of one commit: either head a prefix of the other, the way a
|
|
194
194
|
// record's head (7-40 hex) is matched against its review's full head.
|
|
195
|
-
|
|
195
|
+
// Exported: tier-outcomes.mjs matches a review file's head to its ledger
|
|
196
|
+
// token's the same way.
|
|
197
|
+
export const sameHead = (a, b) => a.startsWith(b) || b.startsWith(a);
|
|
196
198
|
|
|
197
199
|
// ---------------------------------------------------------------------------
|
|
198
200
|
// pure core — no filesystem or process access until main()
|
|
@@ -425,7 +427,9 @@ function shapeErrors(e) {
|
|
|
425
427
|
// Whether an entry's finding stands: a survived finding not deferred
|
|
426
428
|
// false-rationale, or a refuted one — an entry in that bucket exists only for
|
|
427
429
|
// a refutation the fix-applier reversed. An unverified finding never stands.
|
|
428
|
-
|
|
430
|
+
// Exported: tier-outcomes.mjs works a Ruling out from the Charges on exactly
|
|
431
|
+
// these findings.
|
|
432
|
+
export const stands = (bucket, e) => bucket === "refuted"
|
|
429
433
|
|| (bucket === "survived" && !(e.disposition === "defer" && e.reason === "false-rationale"));
|
|
430
434
|
|
|
431
435
|
// Why an entry's Charge breaks a rule, or null when it holds. `required`: the
|
package/scripts/ledger.mjs
CHANGED
|
@@ -1917,8 +1917,8 @@ async function rulingOf(pr) {
|
|
|
1917
1917
|
if (ruling !== undefined) return { tickets, ruling, tsv, refusal: null };
|
|
1918
1918
|
}
|
|
1919
1919
|
return { tickets, ruling: null, refusal: `PR #${pr}, the implementer PR of ${tickets.length === 1 ? "ticket" : "tickets"} ${tickets.map((n) => `#${n}`).join(", ")}, has no ruling — `
|
|
1920
|
-
+ `${tsv} has no row for it; rule its review with \`~/.fleet/bin/fleet-run tier-outcomes.mjs append ${pr}
|
|
1921
|
-
+
|
|
1920
|
+
+ `${tsv} has no row for it; rule its review with \`~/.fleet/bin/fleet-run tier-outcomes.mjs append ${pr}\`, `
|
|
1921
|
+
+ "then dispatch the finisher again" };
|
|
1922
1922
|
}
|
|
1923
1923
|
|
|
1924
1924
|
// The reads a check makes of GitHub and git, shared by settle --from-report
|
|
@@ -5,15 +5,18 @@
|
|
|
5
5
|
// `opus` for implementers dispatched as a generic `task` that ran
|
|
6
6
|
// sonnet/high, which is neither tier arm.
|
|
7
7
|
//
|
|
8
|
-
// append <pr> --
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// append <pr> [--note <text>] [--sizing light|heavy] [--profile <p>] [--loc <n>]
|
|
9
|
+
// [--files <n>] [--scratch <dir>]
|
|
10
|
+
// append <pr> --closed-own-ticket yes|no --minted-false-claim yes|no --note <text>
|
|
11
|
+
// [--sizing light|heavy] [--profile <p>] [--loc <n>] [--files <n>] [--scratch <dir>]
|
|
11
12
|
// Fills `run_date` (today's UTC date, the ruling date), `pr`, `ticket` (the
|
|
12
13
|
// PR's one `gh pr view --json closingIssuesReferences,url` issue in its own
|
|
13
14
|
// repository; when the PR closes zero or several there, the N of its one
|
|
14
|
-
// settled `impl-<N>=PR#<pr>` token in the run ledger), `tier
|
|
15
|
+
// settled `impl-<N>=PR#<pr>` token in the run ledger), `tier`, the two
|
|
16
|
+
// verdict columns and the `note` (see "The verdicts" below) and, unless
|
|
15
17
|
// `--sizing` names it, `sizing` (see below). Idempotent by
|
|
16
18
|
// PR: a row already present is named and nothing is written, exit 0.
|
|
19
|
+
// Every refusal writes no row and exits 2.
|
|
17
20
|
// check [--live]
|
|
18
21
|
// Post-switch rows only: a filled `tier` must equal the short name of the
|
|
19
22
|
// ticket's single implementer row in member-outcomes.tsv, and that row
|
|
@@ -71,16 +74,50 @@
|
|
|
71
74
|
// is appended. Readers pick a ticket's ruling by file order, not by `run_date`.
|
|
72
75
|
//
|
|
73
76
|
// Exit codes: 0 ok, 1 `check` found a mismatch or checked nothing from the latest
|
|
74
|
-
// run_date (see `check`), 2 usage or
|
|
77
|
+
// run_date (see `check`), 2 usage, unreadable input or a refused `append`.
|
|
78
|
+
|
|
79
|
+
// The verdicts. With no verdict flag, `closed_own_ticket` and
|
|
80
|
+
// `minted_false_claim` are worked out from the Charges the fix-applier
|
|
81
|
+
// recorded on the standing findings of the PR's first review, and the `note`
|
|
82
|
+
// is composed from them, so the row explains itself once the evidence is
|
|
83
|
+
// reclaimed:
|
|
84
|
+
// - the review is ledger-grammar.mjs's firstReview() over the PR's ledger
|
|
85
|
+
// tokens: the first `reviewed=` that either counted no survived and no
|
|
86
|
+
// unverified finding or that a fix-applier answered. Its `review.json` is
|
|
87
|
+
// `<scratch>/pr<pr>/<run>/review.json`, and its head and its survived and
|
|
88
|
+
// unverified counts are the ledger token's, or it refuses;
|
|
89
|
+
// - the record is the last answering that review's run:
|
|
90
|
+
// `<scratch>/dispositions-fix-pr-<pr>[-x].json` with the highest retry
|
|
91
|
+
// letter whose `run` is the review's run. It is read whatever
|
|
92
|
+
// dispositions-check.mjs concluded about its remedies;
|
|
93
|
+
// - a finding stands as dispositions-check.mjs's `stands` says. `unmet` or
|
|
94
|
+
// `both` on one fails `closed_own_ticket`, `false-claim` or `both` makes
|
|
95
|
+
// `minted_false_claim` yes. A review that counted no survived and no
|
|
96
|
+
// unverified finding is a pass and needs no record;
|
|
97
|
+
// - a standing finding with no Charge, or a review, `review.json` or record
|
|
98
|
+
// that is missing, refuses. So does a record entry that names no finding
|
|
99
|
+
// of the review, or a second entry for a finding an earlier entry
|
|
100
|
+
// answers. A dimension that never ran does not: the note names it.
|
|
101
|
+
// The note reads `review <run> at <head7>, record <file>: <bucket>[<index>]
|
|
102
|
+
// <charge> "<claim>"; …; none: <bucket>[<index>] <reason>, …; cleared:
|
|
103
|
+
// survived[<index>] false-rationale, …; unrun: <dimension>, …`, and ` — <--note>`
|
|
104
|
+
// when `--note` is given. Tabs and newlines in a claim become spaces.
|
|
105
|
+
//
|
|
106
|
+
// Verdicts given by hand — `--closed-own-ticket` and `--minted-false-claim`
|
|
107
|
+
// together, with `--note` naming the evidence — are written as given for a PR
|
|
108
|
+
// GitHub reports MERGED, and refused for any other. A merged PR given no
|
|
109
|
+
// verdict flags is derived when its evidence is still in scratch, and
|
|
110
|
+
// otherwise refused with the flags to give.
|
|
75
111
|
|
|
76
|
-
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
112
|
+
import { appendFileSync, existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
77
113
|
import { execFileSync } from "node:child_process";
|
|
78
114
|
import { dirname, join } from "node:path";
|
|
79
115
|
import { fileURLToPath } from "node:url";
|
|
80
116
|
import { isCLI } from "./is-cli.mjs";
|
|
81
117
|
import { makeDie, defineFlags, isDigits } from "./arg.mjs";
|
|
82
118
|
import { parseTsv as parseMemberTsv } from "./member-outcomes.mjs";
|
|
83
|
-
import { parseMember, parseToken, memberTokens, rowPr } from "./ledger-grammar.mjs";
|
|
119
|
+
import { parseMember, parseToken, memberTokens, rowPr, prTokens, firstReview } from "./ledger-grammar.mjs";
|
|
120
|
+
import { CHARGES, CHARGE_REASONS, reviewProblem, sameHead, stands } from "./dispositions-check.mjs";
|
|
84
121
|
import { rowText } from "./tier-check.mjs";
|
|
85
122
|
import { latestImplReport, readReport, reportsDir, defaultScratch } from "./report-files.mjs";
|
|
86
123
|
import { FleetDirUnresolvable } from "./fleet-dir.mjs";
|
|
@@ -382,8 +419,9 @@ export function unrecordedReviewedPrs(ledger, rows) {
|
|
|
382
419
|
// CLI
|
|
383
420
|
// ---------------------------------------------------------------------------
|
|
384
421
|
|
|
385
|
-
const USAGE = "usage: tier-outcomes.mjs append <pr> --
|
|
422
|
+
const USAGE = "usage: tier-outcomes.mjs append <pr> [--note <text>] "
|
|
386
423
|
+ "[--sizing light|heavy] [--profile <p>] [--loc <n>] [--files <n>] [--scratch <dir>] [--file <tsv>] [--member-outcomes <tsv>] [--ledger <path>]\n"
|
|
424
|
+
+ " tier-outcomes.mjs append <pr> --closed-own-ticket yes|no --minted-false-claim yes|no --note <text> (a merged PR only) [the same options]\n"
|
|
387
425
|
+ " tier-outcomes.mjs check [--live] [--file <tsv>] [--member-outcomes <tsv>] [--ledger <path>]";
|
|
388
426
|
|
|
389
427
|
const die = makeDie(NAME);
|
|
@@ -456,29 +494,33 @@ function readLedger(ledgerFile, requireFile) {
|
|
|
456
494
|
}
|
|
457
495
|
}
|
|
458
496
|
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
// blanked so an ambient one naming another repository never decides which PR
|
|
464
|
-
// is read; the timeout keeps a hung `gh` from stalling `append` or a
|
|
465
|
-
// close-out's `check --live`.
|
|
497
|
+
// `gh pr view <pr> --json <fields>` parsed, or an Error naming why it could
|
|
498
|
+
// not be read. GH_REPO is blanked so an ambient one naming another repository
|
|
499
|
+
// never decides which PR is read; the timeout keeps a hung `gh` from stalling
|
|
500
|
+
// `append` or a close-out's `check --live`.
|
|
466
501
|
const GH_TIMEOUT_MS = 60_000;
|
|
467
|
-
function
|
|
502
|
+
function ghPrView(pr, fields) {
|
|
468
503
|
let raw;
|
|
469
504
|
try {
|
|
470
|
-
raw = execFileSync("gh", ["pr", "view", String(pr), "--json",
|
|
505
|
+
raw = execFileSync("gh", ["pr", "view", String(pr), "--json", fields], {
|
|
471
506
|
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GH_REPO: "" }, timeout: GH_TIMEOUT_MS,
|
|
472
507
|
});
|
|
473
508
|
} catch (e) {
|
|
474
509
|
throw new Error(`gh pr view ${pr} failed: ${e.stderr?.trim() || e.message}`);
|
|
475
510
|
}
|
|
476
|
-
let view;
|
|
477
511
|
try {
|
|
478
|
-
|
|
512
|
+
return JSON.parse(raw);
|
|
479
513
|
} catch (e) {
|
|
480
514
|
throw new Error(`gh pr view ${pr} returned unreadable JSON: ${e.message}`);
|
|
481
515
|
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// The unique issue numbers of a PR's closingIssuesReferences in its own
|
|
519
|
+
// repository, by ownClosingIssues()'s rule, in GitHub's order — another
|
|
520
|
+
// repository's reference is none of them — or an Error naming why they could
|
|
521
|
+
// not be read, a reference that rule cannot read among the reasons.
|
|
522
|
+
function closingIssueNumbers(pr) {
|
|
523
|
+
const view = ghPrView(pr, "closingIssuesReferences,url");
|
|
482
524
|
if (!Array.isArray(view?.closingIssuesReferences)) throw new Error(`gh pr view ${pr} returned no closingIssuesReferences array`);
|
|
483
525
|
try {
|
|
484
526
|
return ownClosingIssues(pr, view.url, view.closingIssuesReferences);
|
|
@@ -538,6 +580,202 @@ function today() {
|
|
|
538
580
|
return new Date().toISOString().slice(0, 10);
|
|
539
581
|
}
|
|
540
582
|
|
|
583
|
+
// ---------------------------------------------------------------------------
|
|
584
|
+
// the Ruling worked out from the Charges
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
|
|
587
|
+
const oneLine = (s) => String(s ?? "").replace(/[\t\r\n]/g, " ");
|
|
588
|
+
|
|
589
|
+
// `path` parsed as JSON: `{value}`, or `{problem}` naming the failure — with
|
|
590
|
+
// `code` the error code when the file could not be read at all, and no `code`
|
|
591
|
+
// when it was read and is not valid JSON.
|
|
592
|
+
function readJson(path) {
|
|
593
|
+
let text;
|
|
594
|
+
try {
|
|
595
|
+
text = readFileSync(path, "utf8");
|
|
596
|
+
} catch (e) {
|
|
597
|
+
return { code: e.code, problem: `${path} cannot be read: ${e.code ?? e.message}` };
|
|
598
|
+
}
|
|
599
|
+
try {
|
|
600
|
+
return { value: JSON.parse(text) };
|
|
601
|
+
} catch (e) {
|
|
602
|
+
return { problem: `${path} is not valid JSON: ${e.message}` };
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// The Charge an entry carries, `{charge, reason}`, or null when it carries
|
|
607
|
+
// none a Ruling can read: a value outside CHARGES, or `none` with no reason
|
|
608
|
+
// from CHARGE_REASONS.
|
|
609
|
+
function chargeOf(e) {
|
|
610
|
+
if (!CHARGES.includes(e.charge)) return null;
|
|
611
|
+
if (e.charge !== "none") return { charge: e.charge, reason: null };
|
|
612
|
+
return CHARGE_REASONS.includes(e.chargeReason) ? { charge: "none", reason: e.chargeReason } : null;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// The record file names under `scratch` that answer PR `pr`, as `{file,
|
|
616
|
+
// letter}`, highest retry letter first — the first attempt, which carries no
|
|
617
|
+
// letter, last. Empty when the directory is not there.
|
|
618
|
+
function recordFiles(scratch, pr) {
|
|
619
|
+
let names;
|
|
620
|
+
try {
|
|
621
|
+
names = readdirSync(scratch);
|
|
622
|
+
} catch (e) {
|
|
623
|
+
if (e.code === "ENOENT") return [];
|
|
624
|
+
throw new Error(`cannot list ${scratch}: ${e.code ?? e.message}`);
|
|
625
|
+
}
|
|
626
|
+
const answering = new RegExp(`^dispositions-fix-pr-${pr}(?:-([a-z]))?\\.json$`);
|
|
627
|
+
return names
|
|
628
|
+
.flatMap((file) => {
|
|
629
|
+
const m = answering.exec(file);
|
|
630
|
+
return m === null ? [] : [{ file, letter: m[1] ?? "" }];
|
|
631
|
+
})
|
|
632
|
+
.sort((a, b) => (a.letter < b.letter ? 1 : a.letter > b.letter ? -1 : 0));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// The last record answering review run `run`: `{file, record}`, `{missing}`
|
|
636
|
+
// naming why there is none, a retry's record replacing the first attempt's on
|
|
637
|
+
// the same run and a record answering any other run changing nothing. A file
|
|
638
|
+
// met before the answering one that cannot be read, or is not valid JSON,
|
|
639
|
+
// names no run, so it could be the retry's: it refuses rather than letting an
|
|
640
|
+
// older record stand in.
|
|
641
|
+
function recordFor(scratch, pr, run) {
|
|
642
|
+
let files;
|
|
643
|
+
try {
|
|
644
|
+
files = recordFiles(scratch, pr);
|
|
645
|
+
} catch (e) {
|
|
646
|
+
return { missing: e.message };
|
|
647
|
+
}
|
|
648
|
+
for (const { file } of files) {
|
|
649
|
+
const path = join(scratch, file);
|
|
650
|
+
const read = readJson(path);
|
|
651
|
+
if (read.problem !== undefined) return { missing: read.problem };
|
|
652
|
+
if (read.value?.run === run) return { file, path, record: read.value };
|
|
653
|
+
}
|
|
654
|
+
return { missing: `no ${join(scratch, `dispositions-fix-pr-${pr}[-x].json`)} names review ${run} as the run it answers` };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// The Charges `record` carries on the findings of `review` that stand, as the
|
|
658
|
+
// pieces of the note and the findings with none. A survived finding no entry
|
|
659
|
+
// answers stands, with no Charge. `broken` names each entry the Ruling cannot
|
|
660
|
+
// read — not an object, a bucket the review has not got, an index naming no
|
|
661
|
+
// finding, or a second entry for a finding an earlier one answers — since
|
|
662
|
+
// skipping one could hide the Charge that decides a column.
|
|
663
|
+
function chargesOf(review, record) {
|
|
664
|
+
const entries = new Map();
|
|
665
|
+
const broken = [];
|
|
666
|
+
for (const [n, e] of (Array.isArray(record.entries) ? record.entries : []).entries()) {
|
|
667
|
+
if (e === null || typeof e !== "object" || Array.isArray(e)) {
|
|
668
|
+
broken.push(`entries[${n}] is not an object`);
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (!["survived", "refuted", "unverified"].includes(e.bucket)) {
|
|
672
|
+
broken.push(`entries[${n}] names bucket ${JSON.stringify(e.bucket ?? null)}, not survived, refuted or unverified`);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
if (!Number.isInteger(e.index) || e.index < 0 || e.index >= review[e.bucket].length) {
|
|
676
|
+
broken.push(`entries[${n}] names ${e.bucket}[${JSON.stringify(e.index ?? null)}], and ${e.bucket} holds ${review[e.bucket].length}`);
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
const key = `${e.bucket}[${e.index}]`;
|
|
680
|
+
if (entries.has(key)) broken.push(`entries[${n}] is a second entry for ${key}, which entries[${entries.get(key).n}] already answers`);
|
|
681
|
+
else entries.set(key, { e, n });
|
|
682
|
+
}
|
|
683
|
+
const entryFor = (key) => entries.get(key)?.e;
|
|
684
|
+
const out = { charged: [], none: [], cleared: [], missing: [], broken };
|
|
685
|
+
const take = (bucket, index, e) => {
|
|
686
|
+
const key = `${bucket}[${index}]`;
|
|
687
|
+
const c = e === undefined ? null : chargeOf(e);
|
|
688
|
+
if (c === null) out.missing.push(`${key} "${oneLine(review[bucket][index].claim)}"`);
|
|
689
|
+
else if (c.charge === "none") out.none.push(`${key} ${c.reason}`);
|
|
690
|
+
else out.charged.push({ charge: c.charge, text: `${key} ${c.charge} "${oneLine(review[bucket][index].claim)}"` });
|
|
691
|
+
};
|
|
692
|
+
review.survived.forEach((_, index) => {
|
|
693
|
+
const e = entryFor(`survived[${index}]`);
|
|
694
|
+
if (e !== undefined && !stands("survived", e)) out.cleared.push(`survived[${index}] ${e.reason}`);
|
|
695
|
+
else take("survived", index, e);
|
|
696
|
+
});
|
|
697
|
+
review.refuted.forEach((_, index) => {
|
|
698
|
+
const e = entryFor(`refuted[${index}]`);
|
|
699
|
+
if (e !== undefined) take("refuted", index, e);
|
|
700
|
+
});
|
|
701
|
+
return out;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const FAILS_CLOSED = ["unmet", "both"];
|
|
705
|
+
const MINTS = ["false-claim", "both"];
|
|
706
|
+
|
|
707
|
+
// PR `pr`'s verdict columns and note off its first review's Charges:
|
|
708
|
+
// `{closed_own_ticket, minted_false_claim, note}`, or `{missing}` saying what
|
|
709
|
+
// is not there to read. `scratch` is `{root}` or `{unresolvable}`.
|
|
710
|
+
function derivedRuling(pr, ledger, scratch) {
|
|
711
|
+
const review = firstReview(prTokens(ledger?.rows ?? [], Number(pr)));
|
|
712
|
+
if (review === null) {
|
|
713
|
+
return { missing: `PR #${pr} has no first review on the ledger: no \`reviewed=\` token of its rows counted no survived and no unverified finding, or was answered by a fix-applier` };
|
|
714
|
+
}
|
|
715
|
+
if (scratch.unresolvable !== undefined) return { missing: `no scratch root to look in: ${scratch.unresolvable}` };
|
|
716
|
+
const reviewPath = join(scratch.root, `pr${pr}`, review.run, "review.json");
|
|
717
|
+
const read = readJson(reviewPath);
|
|
718
|
+
if (read.code === "ENOENT") return { missing: `review ${review.run}'s result file ${reviewPath} is missing` };
|
|
719
|
+
if (read.problem !== undefined) return { missing: read.problem };
|
|
720
|
+
const file = read.value;
|
|
721
|
+
const problem = reviewProblem(file);
|
|
722
|
+
if (problem !== null) return { missing: `${reviewPath} ${problem}` };
|
|
723
|
+
if (!sameHead(String(file.head).toLowerCase(), review.head)) {
|
|
724
|
+
return { missing: `${reviewPath} is the review of head ${file.head}, not the ledger's review ${review.run} at ${review.head}` };
|
|
725
|
+
}
|
|
726
|
+
if (file.survived.length !== review.survived || file.unverified.length !== review.unverified) {
|
|
727
|
+
return { missing: `${reviewPath} holds ${file.survived.length} survived and ${file.unverified.length} unverified, not the ${review.survived} and ${review.unverified} the ledger's review ${review.run} counted` };
|
|
728
|
+
}
|
|
729
|
+
// An entry that is neither a dimension's name nor {dimension} is named as
|
|
730
|
+
// the JSON it is, so the note never reads as a blank dimension.
|
|
731
|
+
const unrun = (Array.isArray(file.dimensionsUnrun) ? file.dimensionsUnrun : []).map((d) => oneLine(
|
|
732
|
+
typeof d === "string" ? d : typeof d?.dimension === "string" ? d.dimension : JSON.stringify(d ?? null),
|
|
733
|
+
));
|
|
734
|
+
const opening = `review ${review.run} at ${review.head.slice(0, 7)}`;
|
|
735
|
+
const noted = (parts) => (unrun.length === 0 ? parts : [...parts, `unrun: ${unrun.join(", ")}`]).join("; ");
|
|
736
|
+
|
|
737
|
+
if (review.survived === 0 && review.unverified === 0) {
|
|
738
|
+
return { review: review.run, closed_own_ticket: "yes", minted_false_claim: "no", note: `${opening}, no record: ${noted(["counted no survived and no unverified finding"])}` };
|
|
739
|
+
}
|
|
740
|
+
const found = recordFor(scratch.root, pr, review.run);
|
|
741
|
+
if (found.missing !== undefined) return { missing: found.missing };
|
|
742
|
+
const charges = chargesOf(file, found.record);
|
|
743
|
+
if (charges.broken.length > 0) {
|
|
744
|
+
return { missing: `${found.path} has ${charges.broken.length === 1 ? "an entry" : "entries"} a Ruling cannot read: ${charges.broken.join("; ")}` };
|
|
745
|
+
}
|
|
746
|
+
if (charges.missing.length > 0) {
|
|
747
|
+
return { missing: `${found.path} carries no Charge on ${charges.missing.length === 1 ? "this standing finding" : "these standing findings"}: ${charges.missing.join("; ")}` };
|
|
748
|
+
}
|
|
749
|
+
const parts = charges.charged.map((c) => c.text);
|
|
750
|
+
if (charges.none.length > 0) parts.push(`none: ${charges.none.join(", ")}`);
|
|
751
|
+
if (charges.cleared.length > 0) parts.push(`cleared: ${charges.cleared.join(", ")}`);
|
|
752
|
+
return {
|
|
753
|
+
closed_own_ticket: charges.charged.some((c) => FAILS_CLOSED.includes(c.charge)) ? "no" : "yes",
|
|
754
|
+
minted_false_claim: charges.charged.some((c) => MINTS.includes(c.charge)) ? "yes" : "no",
|
|
755
|
+
note: `${opening}, record ${found.file}: ${noted(parts.length === 0 ? ["no finding stands"] : parts)}`,
|
|
756
|
+
review: review.run,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// The step a refusal hands the controller. A merged PR's evidence is
|
|
761
|
+
// reclaimed with its scratch, so the verdicts are given by hand; any other
|
|
762
|
+
// PR's is still wanted, and a ruling is never typed for it.
|
|
763
|
+
function nextStep(pr, state) {
|
|
764
|
+
if (state === "MERGED") {
|
|
765
|
+
return `the PR is merged and its evidence is not there to read — give the verdicts by hand: append ${pr} --closed-own-ticket yes|no --minted-false-claim yes|no --note "<the evidence>"`;
|
|
766
|
+
}
|
|
767
|
+
return `check the record again (\`dispositions-check.mjs --member fix-pr-${pr} --scratch <scratch>\`), or dispatch the fix-applier again as a dispositions retry (\`--kind dispositions-retry\`), then run append ${pr} again`;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// The PR's state as GitHub gives it: OPEN, CLOSED (never merged) or MERGED.
|
|
771
|
+
function prState(pr) {
|
|
772
|
+
const view = ghPrView(pr, "state");
|
|
773
|
+
if (!["OPEN", "CLOSED", "MERGED"].includes(view?.state)) {
|
|
774
|
+
throw new Error(`gh pr view ${pr} returned state ${JSON.stringify(view?.state ?? null)}, not OPEN, CLOSED or MERGED`);
|
|
775
|
+
}
|
|
776
|
+
return view.state;
|
|
777
|
+
}
|
|
778
|
+
|
|
541
779
|
function append(pr, paths) {
|
|
542
780
|
if (!isDigits(pr) || Number(pr) === 0) die(`append needs a PR number, got '${pr}'`);
|
|
543
781
|
pr = String(Number(pr));
|
|
@@ -547,14 +785,17 @@ function append(pr, paths) {
|
|
|
547
785
|
closed_own_ticket: arg("closed-own-ticket"), minted_false_claim: arg("minted-false-claim"),
|
|
548
786
|
note: arg("note"), sizing: sizingFlag ?? "", profile: arg("profile") ?? "", loc: arg("loc") ?? "", files: arg("files") ?? "",
|
|
549
787
|
};
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
788
|
+
const given = VERDICT_COLUMNS.filter((col) => fields[col] !== null);
|
|
789
|
+
if (given.length === 1) die(`--${given[0].replaceAll("_", "-")} comes with ${VERDICT_COLUMNS.filter((c) => c !== given[0]).map((c) => `--${c.replaceAll("_", "-")}`)} or not at all\n${USAGE}`);
|
|
790
|
+
const byHand = given.length > 0;
|
|
791
|
+
if (byHand && fields.note === null) die(`verdicts given by hand need --note, naming the evidence for them\n${USAGE}`);
|
|
553
792
|
for (const [col, value] of Object.entries(fields)) {
|
|
554
|
-
if (/[\t\r\n]/.test(value)) die(`--${col.replaceAll("_", "-")} holds a tab or newline — it would shift every field after it`);
|
|
793
|
+
if (value !== null && /[\t\r\n]/.test(value)) die(`--${col.replaceAll("_", "-")} holds a tab or newline — it would shift every field after it`);
|
|
555
794
|
}
|
|
556
|
-
|
|
557
|
-
|
|
795
|
+
if (byHand) {
|
|
796
|
+
for (const col of VERDICT_COLUMNS) {
|
|
797
|
+
if (!VERDICT.includes(fields[col])) die(`--${col.replaceAll("_", "-")} must be yes or no, got '${fields[col]}'`);
|
|
798
|
+
}
|
|
558
799
|
}
|
|
559
800
|
if (fields.sizing && !["light", "heavy"].includes(fields.sizing)) die(`--sizing must be light or heavy, got '${fields.sizing}'`);
|
|
560
801
|
for (const col of ["loc", "files"]) {
|
|
@@ -568,7 +809,45 @@ function append(pr, paths) {
|
|
|
568
809
|
return;
|
|
569
810
|
}
|
|
570
811
|
|
|
812
|
+
let scratch = { root: paths.scratch };
|
|
813
|
+
if (scratch.root === null) {
|
|
814
|
+
try {
|
|
815
|
+
scratch = { root: defaultScratch() };
|
|
816
|
+
} catch (e) {
|
|
817
|
+
if (!(e instanceof FleetDirUnresolvable)) throw e;
|
|
818
|
+
scratch = { unresolvable: e.message };
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
571
822
|
const ledger = readLedger(paths.ledger, false);
|
|
823
|
+
if (byHand) {
|
|
824
|
+
let state;
|
|
825
|
+
try {
|
|
826
|
+
state = prState(pr);
|
|
827
|
+
} catch (e) {
|
|
828
|
+
die(`could not read PR #${pr}'s state, which decides whether verdicts may be given by hand: ${e.message}`);
|
|
829
|
+
}
|
|
830
|
+
if (state !== "MERGED") {
|
|
831
|
+
die(`PR #${pr} is ${state === "OPEN" ? "open" : "closed without merging"}, and verdicts are given by hand only for a merged PR — leave out --closed-own-ticket and --minted-false-claim, and \`append\` works them out from the record: ${nextStep(pr, state)}`);
|
|
832
|
+
}
|
|
833
|
+
} else {
|
|
834
|
+
const ruled = derivedRuling(pr, ledger, scratch);
|
|
835
|
+
if (ruled.missing !== undefined) {
|
|
836
|
+
let state = null;
|
|
837
|
+
let unread = "";
|
|
838
|
+
try {
|
|
839
|
+
state = prState(pr);
|
|
840
|
+
} catch (e) {
|
|
841
|
+
unread = ` (the PR's state could not be read: ${e.message})`;
|
|
842
|
+
}
|
|
843
|
+
die(`cannot rule PR #${pr} from its record — ${ruled.missing}${unread}; ${nextStep(pr, state)}`);
|
|
844
|
+
}
|
|
845
|
+
fields.closed_own_ticket = ruled.closed_own_ticket;
|
|
846
|
+
fields.minted_false_claim = ruled.minted_false_claim;
|
|
847
|
+
fields.note = fields.note === null ? ruled.note : `${ruled.note} — ${fields.note}`;
|
|
848
|
+
console.error(`${NAME}: closed_own_ticket=${fields.closed_own_ticket} minted_false_claim=${fields.minted_false_claim} from the Charges on review ${ruled.review}`);
|
|
849
|
+
}
|
|
850
|
+
|
|
572
851
|
const { ticket, refusal } = ticketFor(pr, closingNumbers(pr), ledger);
|
|
573
852
|
if (refusal) die(refusal);
|
|
574
853
|
const memberRows = readMemberRows(paths.members);
|
|
@@ -579,17 +858,7 @@ function append(pr, paths) {
|
|
|
579
858
|
if (sizingFlag !== null) {
|
|
580
859
|
console.error(`${NAME}: sizing=${fields.sizing} from --sizing`);
|
|
581
860
|
} else {
|
|
582
|
-
|
|
583
|
-
let unresolvable = null;
|
|
584
|
-
if (scratch === null) {
|
|
585
|
-
try {
|
|
586
|
-
scratch = defaultScratch();
|
|
587
|
-
} catch (e) {
|
|
588
|
-
if (!(e instanceof FleetDirUnresolvable)) throw e;
|
|
589
|
-
unresolvable = e.message;
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
const sized = unresolvable === null ? sizingFromReport(scratch, ticket) : { sizing: "", source: null, warning: `no scratch root to look in: ${unresolvable}` };
|
|
861
|
+
const sized = scratch.unresolvable === undefined ? sizingFromReport(scratch.root, ticket) : { sizing: "", source: null, warning: `no scratch root to look in: ${scratch.unresolvable}` };
|
|
593
862
|
fields.sizing = sized.sizing;
|
|
594
863
|
if (sized.warning) console.error(`${NAME}: WARNING sizing left blank for PR #${pr} (ticket #${ticket}): ${sized.warning}`);
|
|
595
864
|
else console.error(`${NAME}: sizing=${sized.sizing} from ${sized.source}`);
|
package/skills/run-team/SKILL.md
CHANGED
|
@@ -197,7 +197,7 @@ owes beyond its call:
|
|
|
197
197
|
then records `fleet-heartbeat.mjs --stop` last, because every reader takes
|
|
198
198
|
a recorded stop as the end of the run. A `NEED tier-outcomes append <pr>`
|
|
199
199
|
is a reviewed PR with no verdict row — for an implementer's, one that never
|
|
200
|
-
reached its finisher: append
|
|
200
|
+
reached its finisher: run `append <pr>` (**Guard: accumulate per PR**) and run `finish` again.
|
|
201
201
|
A PR closing none or several: a NEED if a settled `impl-<N>=PR#<pr>` token
|
|
202
202
|
names it, else a note. A step it fails is explained in
|
|
203
203
|
references/close-out.md. Exit 0 is the run closed.
|
|
@@ -281,8 +281,8 @@ digest asks of you:
|
|
|
281
281
|
|
|
282
282
|
**Guard: accumulate per PR, never conclude inside one run.** **Append one row to
|
|
283
283
|
`docs/metrics/tier-outcomes.tsv` when you rule each PR's review, before its finisher, with
|
|
284
|
-
`~/.fleet/bin/fleet-run tier-outcomes.mjs append <pr
|
|
285
|
-
|
|
284
|
+
`~/.fleet/bin/fleet-run tier-outcomes.mjs append <pr>`, never by hand** (that file's header carries the column meanings).
|
|
285
|
+
The script works both verdicts out from the Charges on the PR's first review. The script
|
|
286
286
|
fills `tier` itself; never type one in. `ledger.mjs dispatch` refuses the finisher until the row exists. That append is the whole duty. See references/pull.md.
|
|
287
287
|
|
|
288
288
|
**A verdict is a measurement only if it post-dates the run that produced it.** See references/reviewers.md.
|
|
@@ -25,8 +25,7 @@
|
|
|
25
25
|
stop as the end of the run. A `NEED tier-outcomes append <pr>` is a
|
|
26
26
|
reviewed PR with no verdict row — for an implementer's PR, one that never
|
|
27
27
|
reached its finisher, since `ledger.mjs dispatch` refuses that finisher
|
|
28
|
-
until the row exists: append
|
|
29
|
-
you rule (**Guard: accumulate per PR**) and run `finish` again. A reviewed
|
|
28
|
+
until the row exists: run `append <pr>` (**Guard: accumulate per PR**) and run `finish` again. A reviewed
|
|
30
29
|
PR that closes no issue, or several, with no settled `impl-<N>=PR#<pr>`
|
|
31
30
|
token naming it takes no row: the check names it in a `note` line and
|
|
32
31
|
never asks for one. With such a token it is a NEED: `append` takes the
|
|
@@ -477,6 +477,12 @@ member-outcomes row (a `tier-unverifiable=` member has no ledger verdict, so
|
|
|
477
477
|
its one member-outcomes row decides), and BLANK with a WARNING naming why
|
|
478
478
|
when neither settles
|
|
479
479
|
it; never type a tier in over that.
|
|
480
|
+
It also works `closed_own_ticket` and `minted_false_claim` out from the Charges on
|
|
481
|
+
the PR's first review and composes `note` from them, so you type no verdict. A PR whose
|
|
482
|
+
record has no Charge on a standing finding, or whose review file or record is missing,
|
|
483
|
+
is refused, naming what is missing: check the record again, or dispatch the fix-applier
|
|
484
|
+
again as a dispositions retry. Verdicts given by hand, with `--note`, are for a merged
|
|
485
|
+
PR whose evidence is reclaimed, and `append` refuses them on any other.
|
|
480
486
|
|
|
481
487
|
**Why not decide inside one run.** A run holds 2-3 implementer PRs, and ticket
|
|
482
488
|
difficulty swamps the tier effect — a gojq parity harness and a two-statement
|