@feigi/fleet-ctl 3.93.0 → 3.93.2
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/closing-refs.mjs +37 -0
- package/scripts/drop-merged-label.sh +23 -3
- package/scripts/fleet-tick.mjs +28 -14
- package/scripts/ledger.mjs +7 -22
- package/scripts/phase0.mjs +15 -2
- package/scripts/tier-outcomes.mjs +19 -17
package/package.json
CHANGED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Which of a PR's closingIssuesReferences are in the PR's own repository.
|
|
2
|
+
// GitHub lists a reference into another repository there too, under that
|
|
3
|
+
// repository's issue number; read by number alone it stands in for this
|
|
4
|
+
// repository's issue of the same number. Every reader of the field counts a
|
|
5
|
+
// reference through this one rule.
|
|
6
|
+
//
|
|
7
|
+
// The PR's own repository is its url less `/pull/<pr>`; a url not ending so
|
|
8
|
+
// cannot be read. A reference is in it when its url is that repository's
|
|
9
|
+
// `/issues/<number>`, or, carrying no url, when its repository's
|
|
10
|
+
// `<owner>/<name>` ends that repository's url. A reference with no integer
|
|
11
|
+
// number, or with neither a url nor a repository to place it by, cannot be
|
|
12
|
+
// read: it is never taken for the PR's own repository's.
|
|
13
|
+
|
|
14
|
+
// The unique issue numbers, in GitHub's order, of the references in `refs`
|
|
15
|
+
// that are in PR `pr`'s own repository, its url being `prUrl`. Throws an Error
|
|
16
|
+
// whose message names what could not be read, phrased to follow the read's
|
|
17
|
+
// own "answered": `url <prUrl>, not PR #<pr>'s`, `a reference with no issue
|
|
18
|
+
// number`, `a reference to #<n> with neither a url nor a repository`.
|
|
19
|
+
export function ownClosingIssues(pr, prUrl, refs) {
|
|
20
|
+
if (!Array.isArray(refs)) throw new Error("no closingIssuesReferences array");
|
|
21
|
+
const suffix = `/pull/${pr}`;
|
|
22
|
+
if (typeof prUrl !== "string" || !prUrl.endsWith(suffix)) throw new Error(`url ${prUrl}, not PR #${pr}'s`);
|
|
23
|
+
const own = prUrl.slice(0, -suffix.length);
|
|
24
|
+
const str = (v) => typeof v === "string" && v !== "";
|
|
25
|
+
const numbers = new Set();
|
|
26
|
+
for (const ref of refs) {
|
|
27
|
+
const n = ref?.number;
|
|
28
|
+
if (!Number.isInteger(n)) throw new Error("a reference with no issue number");
|
|
29
|
+
const repo = ref.repository;
|
|
30
|
+
let isOwn;
|
|
31
|
+
if (str(ref.url)) isOwn = ref.url === `${own}/issues/${n}`;
|
|
32
|
+
else if (str(repo?.name) && str(repo?.owner?.login)) isOwn = own.endsWith(`/${repo.owner.login}/${repo.name}`);
|
|
33
|
+
else throw new Error(`a reference to #${n} with neither a url nor a repository`);
|
|
34
|
+
if (isOwn) numbers.add(n);
|
|
35
|
+
}
|
|
36
|
+
return [...numbers];
|
|
37
|
+
}
|
|
@@ -19,6 +19,11 @@
|
|
|
19
19
|
# both signals (see the commit-message scan below) so a real close is never
|
|
20
20
|
# missed just because the keyword only ever lived in a commit message.
|
|
21
21
|
#
|
|
22
|
+
# Only a closing reference in the PR's own repository counts: the field also
|
|
23
|
+
# lists another repository's issue, under that repository's number, and read
|
|
24
|
+
# by number alone it would drop the label from this repository's issue of the
|
|
25
|
+
# same number.
|
|
26
|
+
#
|
|
22
27
|
# Dry-run by default; --apply mutates the tracker. Exit 0 done (or dry run),
|
|
23
28
|
# 1 one or more removals failed — REPORT THIS, never swallow it: a merged
|
|
24
29
|
# ticket that keeps the label is a future invisible ticket, 2 could not even
|
|
@@ -47,9 +52,24 @@ if ! state=$(gh pr view "$pr" --json state --jq .state); then
|
|
|
47
52
|
fi
|
|
48
53
|
[ "$state" = "MERGED" ] || die "PR #$pr is not merged (state=$state) — refusing to touch its issues"
|
|
49
54
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
# The PR's own repository is its url less `/pull/<pr>`. A reference is in it
|
|
56
|
+
# when its url is that repository's `/issues/<number>`, or, carrying no url,
|
|
57
|
+
# when its repository's `<owner>/<name>` ends that repository's url; any other
|
|
58
|
+
# is dropped here and never reaches the loop below. A url not ending
|
|
59
|
+
# `/pull/<pr>`, a reference with no integer number, or one with neither a url
|
|
60
|
+
# nor a repository to place it by cannot be read: the filter errors, gh exits
|
|
61
|
+
# non-zero, and so does this, 2.
|
|
62
|
+
own_closes='(.url | if type == "string" and endswith("/pull/'"$pr"'") then rtrimstr("/pull/'"$pr"'") else error("url \(tostring) does not end /pull/'"$pr"'") end) as $own
|
|
63
|
+
| .closingIssuesReferences | if type == "array" then .[] else error("no closingIssuesReferences array") end
|
|
64
|
+
| .number as $n | .repository as $r
|
|
65
|
+
| if ($n | type) == "number" and ($n | floor) == $n then . else error("a reference with no issue number") end
|
|
66
|
+
| if (.url | type) == "string" and .url != "" then select(.url == "\($own)/issues/\($n)")
|
|
67
|
+
elif ($r.name | type) == "string" and $r.name != "" and ($r.owner.login | type) == "string" and $r.owner.login != "" then select($own | endswith("/\($r.owner.login)/\($r.name)"))
|
|
68
|
+
else error("a reference to #\($n) with neither a url nor a repository") end
|
|
69
|
+
| $n'
|
|
70
|
+
echo "\$ gh pr view $pr --json closingIssuesReferences,url --jq '$own_closes'" >&2
|
|
71
|
+
if ! issues=$(gh pr view "$pr" --json closingIssuesReferences,url --jq "$own_closes"); then
|
|
72
|
+
die "gh pr view $pr failed — cannot read which issues it closes in its own repository"
|
|
53
73
|
fi
|
|
54
74
|
|
|
55
75
|
# The query above misses a commit-message-only close. Scan every
|
package/scripts/fleet-tick.mjs
CHANGED
|
@@ -1566,6 +1566,7 @@ import { checkMainCheckout, describe } from "./main-checkout.mjs";
|
|
|
1566
1566
|
import { readRunReceipt, phase0Gate, IN_FLIGHT_DEFAULT } from "./phase0-receipt.mjs";
|
|
1567
1567
|
import { judgeRecord, ProcUnreadable } from "./proc.mjs";
|
|
1568
1568
|
import { reportFamily, readReport, reportPath, reportsDir } from "./report-files.mjs";
|
|
1569
|
+
import { ownClosingIssues } from "./closing-refs.mjs";
|
|
1569
1570
|
|
|
1570
1571
|
const NAME = "fleet-tick";
|
|
1571
1572
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
@@ -1681,14 +1682,18 @@ function options() {
|
|
|
1681
1682
|
};
|
|
1682
1683
|
}
|
|
1683
1684
|
|
|
1684
|
-
// The open PRs, validated
|
|
1685
|
-
//
|
|
1686
|
-
//
|
|
1685
|
+
// The open PRs, validated, each with its closingIssuesReferences reduced to
|
|
1686
|
+
// `{number}` for every issue it closes in its own repository, by
|
|
1687
|
+
// ownClosingIssues()'s rule: another repository's reference, whatever its
|
|
1688
|
+
// number, closes none of this repository's tickets. A failed read is not an
|
|
1689
|
+
// empty pipeline: backlog 0 + merge-queue 0 is a plausible tick, so printing
|
|
1690
|
+
// it off a failed query is the silent stall this script exists to end.
|
|
1691
|
+
const PR_ROW_SHAPE = "{number,labels,closingIssuesReferences[{number,url|repository{name,owner{login}}}],headRefOid,mergeable,body,url} rows, url ending /pull/<number>, mergeable one of MERGEABLE, CONFLICTING, UNKNOWN";
|
|
1687
1692
|
function openPrs() {
|
|
1688
1693
|
let out;
|
|
1689
1694
|
try {
|
|
1690
1695
|
out = execFileSync("gh", ["pr", "list", "--state", "open", "--limit", String(PR_LIMIT),
|
|
1691
|
-
"--json", "number,labels,closingIssuesReferences,headRefOid,mergeable,body"], { encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: GH_TIMEOUT_MS });
|
|
1696
|
+
"--json", "number,labels,closingIssuesReferences,headRefOid,mergeable,body,url"], { encoding: "utf8", maxBuffer: MAX_BUFFER, timeout: GH_TIMEOUT_MS });
|
|
1692
1697
|
} catch (e) {
|
|
1693
1698
|
// Never interpolates e.stderr or e.message: execFileSync already forwarded
|
|
1694
1699
|
// the child's stderr to ours, and Node builds e.message out of it, so
|
|
@@ -1703,22 +1708,31 @@ function openPrs() {
|
|
|
1703
1708
|
}
|
|
1704
1709
|
// closingIssuesReferences is checked, not defaulted: absent, it would read as
|
|
1705
1710
|
// an empty list and drop every open PR from review work — and from the
|
|
1706
|
-
// In-flight count. Each reference must
|
|
1707
|
-
// matches a PR to the ticket it closes by
|
|
1708
|
-
// would leave that ticket's implementer
|
|
1709
|
-
//
|
|
1710
|
-
//
|
|
1711
|
-
//
|
|
1711
|
+
// In-flight count. Each reference must be readable by ownClosingIssues():
|
|
1712
|
+
// the count matches a PR to the ticket it closes by its number, and a
|
|
1713
|
+
// reference that cannot be placed would leave that ticket's implementer
|
|
1714
|
+
// counted twice, or count another repository's issue as this one's. url is
|
|
1715
|
+
// required too: the PR's own repository, which that rule places each
|
|
1716
|
+
// reference against, is its url less /pull/<number>.
|
|
1717
|
+
// headRefOid likewise — absent, a `halted:past-pin` PR would read as moved
|
|
1718
|
+
// forever — and mergeable: absent or outside GitHub's MergeableState enum,
|
|
1719
|
+
// no PR would ever read CONFLICTING. body too: absent, no park could ever be
|
|
1720
|
+
// matched or lifted.
|
|
1712
1721
|
if (!Array.isArray(prs) || prs.some((p) => !p || typeof p.number !== "number"
|
|
1713
|
-
|| !Array.isArray(p.labels) || !Array.isArray(p.closingIssuesReferences)
|
|
1714
|
-
|| !p.closingIssuesReferences.every((r) => r && Number.isInteger(r.number))
|
|
1722
|
+
|| !Array.isArray(p.labels) || !Array.isArray(p.closingIssuesReferences) || typeof p.url !== "string"
|
|
1715
1723
|
|| typeof p.headRefOid !== "string" || !MERGEABLE_STATES.includes(p.mergeable) || typeof p.body !== "string")) {
|
|
1716
|
-
die(
|
|
1724
|
+
die(`gh pr list did not return ${PR_ROW_SHAPE}`);
|
|
1717
1725
|
}
|
|
1718
1726
|
if (prs.length === PR_LIMIT) {
|
|
1719
1727
|
die(`exactly ${PR_LIMIT} open PRs — the list is capped and may be truncated. Raise PR_LIMIT; a backlog that silently drops PRs is not a reconcile.`);
|
|
1720
1728
|
}
|
|
1721
|
-
return prs
|
|
1729
|
+
return prs.map((p) => {
|
|
1730
|
+
try {
|
|
1731
|
+
return { ...p, closingIssuesReferences: ownClosingIssues(p.number, p.url, p.closingIssuesReferences).map((number) => ({ number })) };
|
|
1732
|
+
} catch (e) {
|
|
1733
|
+
die(`gh pr list did not return ${PR_ROW_SHAPE}: PR #${p.number} answered ${e.message}`);
|
|
1734
|
+
}
|
|
1735
|
+
});
|
|
1722
1736
|
}
|
|
1723
1737
|
|
|
1724
1738
|
// A child that did not exit 0, named with the last line it printed. A gh
|
package/scripts/ledger.mjs
CHANGED
|
@@ -63,6 +63,7 @@ import { processSource, ancestry, controllerOf, judgeController, ProcUnreadable
|
|
|
63
63
|
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
|
+
import { ownClosingIssues } from "./closing-refs.mjs";
|
|
66
67
|
|
|
67
68
|
const NAME = "ledger";
|
|
68
69
|
// The plugin's own agent definitions — the directory tier-check.mjs reads
|
|
@@ -2000,31 +2001,15 @@ function checkReaders(where) {
|
|
|
2000
2001
|
};
|
|
2001
2002
|
// PR `pr`'s prView() answer to `fields`, which name closingIssuesReferences
|
|
2002
2003
|
// and url, as `view`, with the issue numbers, unique, of its closing
|
|
2003
|
-
// references that are in the PR's own repository
|
|
2004
|
-
//
|
|
2005
|
-
// `/issues/<number>`, or, carrying no url, when its repository's
|
|
2006
|
-
// `<owner>/<name>` ends that repository's url. A reference with no integer
|
|
2007
|
-
// number, or with neither a url nor a repository to place it by, could not
|
|
2008
|
-
// look: it is never taken for the PR's own repository's.
|
|
2004
|
+
// references that are in the PR's own repository, by ownClosingIssues()'s
|
|
2005
|
+
// rule. A reference that rule cannot read could not look.
|
|
2009
2006
|
const prViewClosing = (pr, fields) => {
|
|
2010
2007
|
const view = prView(pr, fields);
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
const str = (v) => typeof v === "string" && v !== "";
|
|
2016
|
-
const numbers = new Set();
|
|
2017
|
-
for (const ref of view.closingIssuesReferences) {
|
|
2018
|
-
const n = ref?.number;
|
|
2019
|
-
if (!Number.isInteger(n)) die(`${where}: could not look — ${what} answered a reference with no issue number; nothing written`);
|
|
2020
|
-
const repo = ref.repository;
|
|
2021
|
-
let isOwn;
|
|
2022
|
-
if (str(ref.url)) isOwn = ref.url === `${own}/issues/${n}`;
|
|
2023
|
-
else if (str(repo?.name) && str(repo?.owner?.login)) isOwn = own.endsWith(`/${repo.owner.login}/${repo.name}`);
|
|
2024
|
-
else die(`${where}: could not look — ${what} answered a reference to #${n} with neither a url nor a repository; nothing written`);
|
|
2025
|
-
if (isOwn) numbers.add(n);
|
|
2008
|
+
try {
|
|
2009
|
+
return { view, numbers: ownClosingIssues(pr, view.url, view.closingIssuesReferences) };
|
|
2010
|
+
} catch (e) {
|
|
2011
|
+
die(`${where}: could not look — gh pr view ${pr} --json ${fields} answered ${e.message}; nothing written`);
|
|
2026
2012
|
}
|
|
2027
|
-
return { view, numbers: [...numbers] };
|
|
2028
2013
|
};
|
|
2029
2014
|
// The title of issue `n`, as `gh issue view <n> --json title` answers it.
|
|
2030
2015
|
const issueTitle = (n) => {
|
package/scripts/phase0.mjs
CHANGED
|
@@ -141,6 +141,7 @@ import { dirname, join, resolve } from "node:path";
|
|
|
141
141
|
import { fileURLToPath } from "node:url";
|
|
142
142
|
import { makeDie, defineFlags, isDigits } from "./arg.mjs";
|
|
143
143
|
import { alarmFile } from "./controller-alarm.mjs";
|
|
144
|
+
import { ownClosingIssues } from "./closing-refs.mjs";
|
|
144
145
|
import { FleetDirUnresolvable, fleetFile } from "./fleet-dir.mjs";
|
|
145
146
|
import { readState, stateFileIn } from "./fleet-state.mjs";
|
|
146
147
|
import { gitEnv } from "./git-env.mjs";
|
|
@@ -352,7 +353,7 @@ function ledgerRows(cwd) {
|
|
|
352
353
|
const rowKey = (row) => row.split(/\s/)[0];
|
|
353
354
|
|
|
354
355
|
function foldIn({ cwd }) {
|
|
355
|
-
const list = run("gh", ["pr", "list", "--state", "open", "--json", "number,closingIssuesReferences", "--limit", String(PR_LIMIT)], {
|
|
356
|
+
const list = run("gh", ["pr", "list", "--state", "open", "--json", "number,closingIssuesReferences,url", "--limit", String(PR_LIMIT)], {
|
|
356
357
|
cwd, env: { GH_REPO: "" }, timeoutMs: NETWORK_TIMEOUT_MS,
|
|
357
358
|
});
|
|
358
359
|
if (list.status !== 0) return refusal("gh pr list failed:", list);
|
|
@@ -364,6 +365,18 @@ function foldIn({ cwd }) {
|
|
|
364
365
|
}
|
|
365
366
|
if (!Array.isArray(prs)) return failed("gh pr list did not print a JSON array");
|
|
366
367
|
if (prs.length >= PR_LIMIT) return failed(`gh pr list came back at its cap of ${PR_LIMIT} — it may hold more PRs than it showed, so none is folded in`);
|
|
368
|
+
// The issues each PR closes in its own repository, by ownClosingIssues()'s
|
|
369
|
+
// rule — another repository's reference closes none of this one's — read
|
|
370
|
+
// for every PR before any row is written, so one that cannot be read folds
|
|
371
|
+
// nothing in.
|
|
372
|
+
const closing = new Map();
|
|
373
|
+
for (const pr of prs) {
|
|
374
|
+
try {
|
|
375
|
+
closing.set(pr, ownClosingIssues(pr?.number, pr?.url, pr?.closingIssuesReferences ?? []));
|
|
376
|
+
} catch (e) {
|
|
377
|
+
return failed(`gh pr list answered PR #${pr?.number} with ${e.message} — which issues it closes is unknown, so none is folded in`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
367
380
|
const ledger = ledgerRows(cwd);
|
|
368
381
|
if (ledger.error) return failed(`${ledger.error} — which PRs a carried row names is unknown`);
|
|
369
382
|
|
|
@@ -383,7 +396,7 @@ function foldIn({ cwd }) {
|
|
|
383
396
|
carried += 1;
|
|
384
397
|
continue;
|
|
385
398
|
}
|
|
386
|
-
const issues = (pr
|
|
399
|
+
const issues = closing.get(pr);
|
|
387
400
|
if (issues.length === 0) {
|
|
388
401
|
advice.push(`NEED review PR#${pr.number} (closes no issue)`);
|
|
389
402
|
continue;
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// --note <text> [--sizing light|heavy] [--profile <p>] [--loc <n>] [--files <n>]
|
|
10
10
|
// [--scratch <dir>]
|
|
11
11
|
// Fills `run_date` (today's UTC date, the ruling date), `pr`, `ticket` (the
|
|
12
|
-
// PR's one `gh pr view --json closingIssuesReferences` issue
|
|
13
|
-
// closes zero or several, the N of its one
|
|
14
|
-
// in the run ledger), `tier` and, unless
|
|
12
|
+
// PR's one `gh pr view --json closingIssuesReferences,url` issue in its own
|
|
13
|
+
// 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` and, unless
|
|
15
15
|
// `--sizing` names it, `sizing` (see below). Idempotent by
|
|
16
16
|
// PR: a row already present is named and nothing is written, exit 0.
|
|
17
17
|
// check [--live]
|
|
@@ -84,6 +84,7 @@ import { parseMember, parseToken, memberTokens, rowPr } from "./ledger-grammar.m
|
|
|
84
84
|
import { rowText } from "./tier-check.mjs";
|
|
85
85
|
import { latestImplReport, readReport, reportsDir, defaultScratch } from "./report-files.mjs";
|
|
86
86
|
import { FleetDirUnresolvable } from "./fleet-dir.mjs";
|
|
87
|
+
import { ownClosingIssues } from "./closing-refs.mjs";
|
|
87
88
|
|
|
88
89
|
const NAME = "tier-outcomes";
|
|
89
90
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
@@ -455,34 +456,35 @@ function readLedger(ledgerFile, requireFile) {
|
|
|
455
456
|
}
|
|
456
457
|
}
|
|
457
458
|
|
|
458
|
-
// The unique issue numbers of a PR's closingIssuesReferences
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
462
|
-
//
|
|
459
|
+
// The unique issue numbers of a PR's closingIssuesReferences in its own
|
|
460
|
+
// repository, by ownClosingIssues()'s rule, in GitHub's order — another
|
|
461
|
+
// repository's reference is none of them — or an Error naming why they could
|
|
462
|
+
// not be read, a reference that rule cannot read among the reasons. GH_REPO is
|
|
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`.
|
|
463
466
|
const GH_TIMEOUT_MS = 60_000;
|
|
464
467
|
function closingIssueNumbers(pr) {
|
|
465
468
|
let raw;
|
|
466
469
|
try {
|
|
467
|
-
raw = execFileSync("gh", ["pr", "view", String(pr), "--json", "closingIssuesReferences"], {
|
|
470
|
+
raw = execFileSync("gh", ["pr", "view", String(pr), "--json", "closingIssuesReferences,url"], {
|
|
468
471
|
encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GH_REPO: "" }, timeout: GH_TIMEOUT_MS,
|
|
469
472
|
});
|
|
470
473
|
} catch (e) {
|
|
471
474
|
throw new Error(`gh pr view ${pr} failed: ${e.stderr?.trim() || e.message}`);
|
|
472
475
|
}
|
|
473
|
-
let
|
|
476
|
+
let view;
|
|
474
477
|
try {
|
|
475
|
-
|
|
478
|
+
view = JSON.parse(raw);
|
|
476
479
|
} catch (e) {
|
|
477
480
|
throw new Error(`gh pr view ${pr} returned unreadable JSON: ${e.message}`);
|
|
478
481
|
}
|
|
479
|
-
if (!Array.isArray(
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
throw new Error(`gh pr view ${pr}
|
|
482
|
+
if (!Array.isArray(view?.closingIssuesReferences)) throw new Error(`gh pr view ${pr} returned no closingIssuesReferences array`);
|
|
483
|
+
try {
|
|
484
|
+
return ownClosingIssues(pr, view.url, view.closingIssuesReferences);
|
|
485
|
+
} catch (e) {
|
|
486
|
+
throw new Error(`gh pr view ${pr} answered ${e.message}`);
|
|
484
487
|
}
|
|
485
|
-
return numbers;
|
|
486
488
|
}
|
|
487
489
|
|
|
488
490
|
function closingNumbers(pr) {
|