@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feigi/fleet-ctl",
3
- "version": "3.93.0",
3
+ "version": "3.93.2",
4
4
  "description": "Agent fleet: run-team controller, merge bot, PR reviewer, and the ticket pipeline they share",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -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
- echo "\$ gh pr view $pr --json closingIssuesReferences --jq '.closingIssuesReferences[].number'" >&2
51
- if ! issues=$(gh pr view "$pr" --json closingIssuesReferences --jq '.closingIssuesReferences[].number'); then
52
- die "gh pr view $pr failed — cannot read which issues it closes"
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
@@ -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. A failed read is not an empty pipeline: backlog 0 +
1685
- // merge-queue 0 is a plausible tick, so printing it off a failed query is the
1686
- // silent stall this script exists to end.
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 carry an integer `number`: the count
1707
- // matches a PR to the ticket it closes by it, and a reference without one
1708
- // would leave that ticket's implementer counted twice. headRefOid likewise
1709
- // — absent, a `halted:past-pin` PR would read as moved forever — and
1710
- // mergeable: absent or outside GitHub's MergeableState enum, no PR would ever
1711
- // read CONFLICTING. body too: absent, no park could ever be matched or lifted.
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("gh pr list did not return {number,labels,closingIssuesReferences[{number}],headRefOid,mergeable,body} rows, mergeable one of MERGEABLE, CONFLICTING, UNKNOWN");
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
@@ -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: its url less
2004
- // `/pull/<pr>`. A reference is in it when its url is that repository's
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
- const what = `gh pr view ${pr} --json ${fields}`;
2012
- const suffix = `/pull/${pr}`;
2013
- if (!view.url.endsWith(suffix)) die(`${where}: could not look — ${what} answered url ${view.url}, not PR #${pr}'s; nothing written`);
2014
- const own = view.url.slice(0, -suffix.length);
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) => {
@@ -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.closingIssuesReferences ?? []).map((i) => i.number).filter(Number.isInteger);
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; when the PR
13
- // closes zero or several, the N of its one settled `impl-<N>=PR#<pr>` token
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, in GitHub's
459
- // order, or an Error naming why they could not be read — a reference with no
460
- // integer issue number among the reasons. GH_REPO is blanked so an ambient one
461
- // naming another repository never decides which PR is read; the timeout keeps
462
- // a hung `gh` from stalling `append` or a close-out's `check --live`.
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 refs;
476
+ let view;
474
477
  try {
475
- refs = JSON.parse(raw).closingIssuesReferences;
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(refs)) throw new Error(`gh pr view ${pr} returned no closingIssuesReferences array`);
480
- const numbers = [...new Set(refs.map((r) => r?.number))];
481
- const unreadable = numbers.filter((n) => !Number.isInteger(n));
482
- if (unreadable.length > 0) {
483
- throw new Error(`gh pr view ${pr} returned a closing reference with no issue number (${unreadable.map((n) => JSON.stringify(n) ?? "undefined").join(", ")})`);
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) {