@feigi/fleet-ctl 3.100.3 → 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/commands/review-and-fix.md +1 -0
- package/package.json +1 -1
- package/scripts/closeout.mjs +2 -2
- package/scripts/dispatch-prompt.mjs +97 -9
- package/scripts/dispositions-check.mjs +74 -10
- package/scripts/fleet-event.mjs +2 -2
- package/scripts/fleet-tick.mjs +1 -21
- package/scripts/ledger-grammar.mjs +65 -0
- package/scripts/ledger.mjs +7 -21
- package/scripts/pull.mjs +2 -16
- package/scripts/ticket-router.mjs +1 -70
- package/scripts/ticket-text.mjs +94 -0
- package/scripts/tier-outcomes.mjs +305 -36
- package/skills/run-team/SKILL.md +3 -3
- package/skills/run-team/briefs/fix-applier.md +11 -0
- package/skills/run-team/references/close-out.md +1 -2
- package/skills/run-team/references/pull.md +6 -0
package/scripts/pull.mjs
CHANGED
|
@@ -137,7 +137,7 @@ import { gitEnv } from "./git-env.mjs";
|
|
|
137
137
|
import { isCLI } from "./is-cli.mjs";
|
|
138
138
|
import { EXCLUDED_REASON, memberTokens, premisesOf, pullEnded, tierValues } from "./ledger-grammar.mjs";
|
|
139
139
|
import { sessionFromReceipt } from "./phase0-receipt.mjs";
|
|
140
|
-
import { briefPaths, briefText, outOfScope, strandedRuling } from "./ticket-
|
|
140
|
+
import { briefPaths, briefText, defang, outOfScope, strandedRuling, ticketText } from "./ticket-text.mjs";
|
|
141
141
|
|
|
142
142
|
const NAME = "pull";
|
|
143
143
|
const SCRIPTS = dirname(fileURLToPath(import.meta.url));
|
|
@@ -399,11 +399,6 @@ function declaredPlaceholders() {
|
|
|
399
399
|
return names;
|
|
400
400
|
}
|
|
401
401
|
|
|
402
|
-
/** `text` with each declared placeholder's spelling `<name>` written `‹name›`, which the renderer does not read. */
|
|
403
|
-
export function defang(text, names) {
|
|
404
|
-
return names.reduce((t, n) => t.split(`<${n}>`).join(`‹${n}›`), text);
|
|
405
|
-
}
|
|
406
|
-
|
|
407
402
|
// ── children ─────────────────────────────────────────────────────────────────
|
|
408
403
|
|
|
409
404
|
const trimmed = (s) => String(s ?? "").trim();
|
|
@@ -686,15 +681,6 @@ class Pull {
|
|
|
686
681
|
];
|
|
687
682
|
}
|
|
688
683
|
|
|
689
|
-
// What the dispatched member is told the ticket is: its title, its brief,
|
|
690
|
-
// and the Out of scope section when that sits outside the brief.
|
|
691
|
-
dispatchBrief() {
|
|
692
|
-
const issue = this.issue();
|
|
693
|
-
const text = briefText(issue).trim();
|
|
694
|
-
const outside = outOfScope(text) === null ? outOfScope(String(issue.body ?? "")) : null;
|
|
695
|
-
return [`Title: ${issue.title}`, text, ...(outside === null ? [] : [outside])].join("\n\n");
|
|
696
|
-
}
|
|
697
|
-
|
|
698
684
|
// ── the question standing, printed in full: what a Pull picked up again re-reads ──
|
|
699
685
|
describe() {
|
|
700
686
|
const { qid } = this.st.pending;
|
|
@@ -998,7 +984,7 @@ class Pull {
|
|
|
998
984
|
const r = this.script("render", "dispatch-prompt.mjs", [
|
|
999
985
|
"render", "implementer", "--member", st.member, "--ticket", String(st.ticket), "--worktree", worktree,
|
|
1000
986
|
"--branch", `implementer/${st.ticket}-${st.member}`, "--scratch", this.p.scratch,
|
|
1001
|
-
"--brief", defang(this.
|
|
987
|
+
"--brief", defang(ticketText(this.issue()), names), "--staleness", defang(staleness, names),
|
|
1002
988
|
]);
|
|
1003
989
|
if (r.status !== 0) return this.stop(withText(`dispatch-prompt.mjs render refused (${r.how}) — the claim stands`, r));
|
|
1004
990
|
st.stage = "dispatch";
|
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
AttributionError, FEATURE_COLUMNS, TRIP_POINTS, attribute, bootUnit, bootstrapDiffCI, generations, parseFeatures, tally, underFloor,
|
|
56
56
|
} from "./pull-attribution.mjs";
|
|
57
57
|
import { RulingError, parseTierOutcomes } from "./tier-outcomes.mjs";
|
|
58
|
+
import { briefPaths, briefText } from "./ticket-text.mjs";
|
|
58
59
|
|
|
59
60
|
const NAME = "ticket-router";
|
|
60
61
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
@@ -80,76 +81,6 @@ const DAY_MS = 86_400_000;
|
|
|
80
81
|
// metrics — every one free, deterministic and known before dispatch
|
|
81
82
|
// ---------------------------------------------------------------------------
|
|
82
83
|
|
|
83
|
-
/** An `## Agent Brief` heading line: what marks the comment the brief is taken from. */
|
|
84
|
-
const AGENT_BRIEF = /^## Agent Brief/m;
|
|
85
|
-
|
|
86
|
-
/** The brief: the last comment carrying an `## Agent Brief` heading line, else the body. */
|
|
87
|
-
export function briefText(issue) {
|
|
88
|
-
const comments = Array.isArray(issue.comments) ? issue.comments : [];
|
|
89
|
-
for (let i = comments.length - 1; i >= 0; i--) {
|
|
90
|
-
const body = String(comments[i]?.body ?? "");
|
|
91
|
-
if (AGENT_BRIEF.test(body)) return body;
|
|
92
|
-
}
|
|
93
|
-
return String(issue.body ?? "");
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** A ruling or triage heading line: what a decision posted outside an `## Agent Brief` comment is headed with. */
|
|
97
|
-
const RULING_HEADING = /^## (?:Ruling|Triage).*$/m;
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* The newest comment with a ruling or triage heading line and no `## Agent
|
|
101
|
-
* Brief` heading line that comes after the last comment `briefText` takes the
|
|
102
|
-
* brief from, or after none when there is no such comment, as its first ruling
|
|
103
|
-
* or triage heading line and its `url`; null when none is.
|
|
104
|
-
*/
|
|
105
|
-
export function strandedRuling(issue) {
|
|
106
|
-
const comments = Array.isArray(issue.comments) ? issue.comments : [];
|
|
107
|
-
for (let i = comments.length - 1; i >= 0; i--) {
|
|
108
|
-
const body = String(comments[i]?.body ?? "");
|
|
109
|
-
if (AGENT_BRIEF.test(body)) return null;
|
|
110
|
-
const heading = RULING_HEADING.exec(body);
|
|
111
|
-
if (heading !== null) return { heading: heading[0], url: comments[i].url };
|
|
112
|
-
}
|
|
113
|
-
return null;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** The line span `[at, end)` of `lines`' `Out of scope` section, heading to the next heading of its level or above; null when there is none. */
|
|
117
|
-
function outOfScopeSpan(lines) {
|
|
118
|
-
const at = lines.findIndex((l) => /^#{1,6}\s*out of scope\b/i.test(l));
|
|
119
|
-
if (at === -1) return null;
|
|
120
|
-
const level = /^#+/.exec(lines[at])[0].length;
|
|
121
|
-
for (let i = at + 1; i < lines.length; i++) {
|
|
122
|
-
const h = /^(#{1,6})\s/.exec(lines[i]);
|
|
123
|
-
if (h !== null && h[1].length <= level) return { at, end: i };
|
|
124
|
-
}
|
|
125
|
-
return { at, end: lines.length };
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** The `Out of scope` section of `text`, heading to the next heading of its level or above; null when there is none. */
|
|
129
|
-
export function outOfScope(text) {
|
|
130
|
-
const all = text.split("\n");
|
|
131
|
-
const span = outOfScopeSpan(all);
|
|
132
|
-
return span === null ? null : all.slice(span.at, span.end).join("\n").trim();
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* The file paths a brief names: every backticked token without whitespace that
|
|
137
|
-
* holds a `/` or ends in an extension, in first-seen order, each once. The
|
|
138
|
-
* brief's `Out of scope` section names no target, so its tokens are skipped.
|
|
139
|
-
*/
|
|
140
|
-
export function briefPaths(brief) {
|
|
141
|
-
const all = brief.split("\n");
|
|
142
|
-
const span = outOfScopeSpan(all);
|
|
143
|
-
const kept = span === null ? brief : [...all.slice(0, span.at), ...all.slice(span.end)].join("\n");
|
|
144
|
-
const paths = new Set();
|
|
145
|
-
for (const m of kept.matchAll(/`([^`\n]+)`/g)) {
|
|
146
|
-
const tok = m[1];
|
|
147
|
-
if (/\s/.test(tok)) continue;
|
|
148
|
-
if (tok.includes("/") || /\.[A-Za-z][A-Za-z0-9]*$/.test(tok)) paths.add(tok);
|
|
149
|
-
}
|
|
150
|
-
return [...paths];
|
|
151
|
-
}
|
|
152
|
-
|
|
153
84
|
/** The metric columns for a parsed `gh issue view --json title,body,comments,labels,createdAt`, or null when it is not one. */
|
|
154
85
|
export function issueMetrics(issue, now = Date.now()) {
|
|
155
86
|
if (issue === null || typeof issue !== "object" || Array.isArray(issue) || typeof issue.body !== "string") return null;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// A ticket's text as the fleet reads it off a saved `gh issue view --json
|
|
2
|
+
// title,body,comments,…`: its brief, its `Out of scope` section, the paths it
|
|
3
|
+
// names, a ruling posted outside its brief, and the text a member is
|
|
4
|
+
// dispatched with. A module of its own, importing nothing, because
|
|
5
|
+
// ticket-router.mjs, pull.mjs and dispatch-prompt.mjs each read a ticket this
|
|
6
|
+
// way, and the renderer has no use for the router's fit or anything it
|
|
7
|
+
// imports.
|
|
8
|
+
|
|
9
|
+
/** An `## Agent Brief` heading line: what marks the comment the brief is taken from. */
|
|
10
|
+
const AGENT_BRIEF = /^## Agent Brief/m;
|
|
11
|
+
|
|
12
|
+
/** The brief: the last comment carrying an `## Agent Brief` heading line, else the body. */
|
|
13
|
+
export function briefText(issue) {
|
|
14
|
+
const comments = Array.isArray(issue.comments) ? issue.comments : [];
|
|
15
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
16
|
+
const body = String(comments[i]?.body ?? "");
|
|
17
|
+
if (AGENT_BRIEF.test(body)) return body;
|
|
18
|
+
}
|
|
19
|
+
return String(issue.body ?? "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A ruling or triage heading line: what a decision posted outside an `## Agent Brief` comment is headed with. */
|
|
23
|
+
const RULING_HEADING = /^## (?:Ruling|Triage).*$/m;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The newest comment with a ruling or triage heading line and no `## Agent
|
|
27
|
+
* Brief` heading line that comes after the last comment `briefText` takes the
|
|
28
|
+
* brief from, or after none when there is no such comment, as its first ruling
|
|
29
|
+
* or triage heading line and its `url`; null when none is.
|
|
30
|
+
*/
|
|
31
|
+
export function strandedRuling(issue) {
|
|
32
|
+
const comments = Array.isArray(issue.comments) ? issue.comments : [];
|
|
33
|
+
for (let i = comments.length - 1; i >= 0; i--) {
|
|
34
|
+
const body = String(comments[i]?.body ?? "");
|
|
35
|
+
if (AGENT_BRIEF.test(body)) return null;
|
|
36
|
+
const heading = RULING_HEADING.exec(body);
|
|
37
|
+
if (heading !== null) return { heading: heading[0], url: comments[i].url };
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The line span `[at, end)` of `lines`' `Out of scope` section, heading to the next heading of its level or above; null when there is none. */
|
|
43
|
+
function outOfScopeSpan(lines) {
|
|
44
|
+
const at = lines.findIndex((l) => /^#{1,6}\s*out of scope\b/i.test(l));
|
|
45
|
+
if (at === -1) return null;
|
|
46
|
+
const level = /^#+/.exec(lines[at])[0].length;
|
|
47
|
+
for (let i = at + 1; i < lines.length; i++) {
|
|
48
|
+
const h = /^(#{1,6})\s/.exec(lines[i]);
|
|
49
|
+
if (h !== null && h[1].length <= level) return { at, end: i };
|
|
50
|
+
}
|
|
51
|
+
return { at, end: lines.length };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The `Out of scope` section of `text`, heading to the next heading of its level or above; null when there is none. */
|
|
55
|
+
export function outOfScope(text) {
|
|
56
|
+
const all = text.split("\n");
|
|
57
|
+
const span = outOfScopeSpan(all);
|
|
58
|
+
return span === null ? null : all.slice(span.at, span.end).join("\n").trim();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The ticket as a member is told it: its title, its brief (briefText), and the
|
|
63
|
+
* body's `Out of scope` section when the brief carries none. An implementer's
|
|
64
|
+
* dispatch carries it, and a review fix-applier's brief carries the same text
|
|
65
|
+
* for the PR that implementer opened.
|
|
66
|
+
*/
|
|
67
|
+
export function ticketText(issue) {
|
|
68
|
+
const text = briefText(issue).trim();
|
|
69
|
+
const outside = outOfScope(text) === null ? outOfScope(String(issue.body ?? "")) : null;
|
|
70
|
+
return [`Title: ${issue.title}`, text, ...(outside === null ? [] : [outside])].join("\n\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `text` with each declared placeholder's spelling `<name>` written `‹name›`, which dispatch-prompt.mjs does not read. */
|
|
74
|
+
export function defang(text, names) {
|
|
75
|
+
return names.reduce((t, n) => t.split(`<${n}>`).join(`‹${n}›`), text);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The file paths a brief names: every backticked token without whitespace that
|
|
80
|
+
* holds a `/` or ends in an extension, in first-seen order, each once. The
|
|
81
|
+
* brief's `Out of scope` section names no target, so its tokens are skipped.
|
|
82
|
+
*/
|
|
83
|
+
export function briefPaths(brief) {
|
|
84
|
+
const all = brief.split("\n");
|
|
85
|
+
const span = outOfScopeSpan(all);
|
|
86
|
+
const kept = span === null ? brief : [...all.slice(0, span.at), ...all.slice(span.end)].join("\n");
|
|
87
|
+
const paths = new Set();
|
|
88
|
+
for (const m of kept.matchAll(/`([^`\n]+)`/g)) {
|
|
89
|
+
const tok = m[1];
|
|
90
|
+
if (/\s/.test(tok)) continue;
|
|
91
|
+
if (tok.includes("/") || /\.[A-Za-z][A-Za-z0-9]*$/.test(tok)) paths.add(tok);
|
|
92
|
+
}
|
|
93
|
+
return [...paths];
|
|
94
|
+
}
|
|
@@ -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}`);
|