omp-conductor 0.7.1 → 0.9.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/README.md +60 -30
- package/package.json +1 -1
- package/src/backups.ts +46 -0
- package/src/board.ts +254 -38
- package/src/brief-upgrade.ts +1 -30
- package/src/briefs/orchestrator.md +13 -2
- package/src/cli.ts +90 -29
- package/src/config.ts +24 -0
- package/src/daemon.ts +311 -131
- package/src/decisions.ts +67 -7
- package/src/diff-flags.ts +35 -6
- package/src/fleet.ts +65 -6
- package/src/label-projection.ts +93 -0
- package/src/lifecycle.ts +8 -1
- package/src/orchestrator-tick.ts +150 -0
- package/src/plugin.ts +1 -1
- package/src/routing.ts +20 -0
- package/src/setup.ts +1 -1
- package/src/store.ts +254 -2
- package/src/tracker/github.ts +489 -80
- package/src/types.ts +85 -3
- package/src/unblock.ts +156 -20
- package/src/upgrade.ts +57 -7
- package/src/worktree.ts +23 -4
package/src/tracker/github.ts
CHANGED
|
@@ -34,17 +34,6 @@ import type {
|
|
|
34
34
|
Tracker,
|
|
35
35
|
} from "../types.ts";
|
|
36
36
|
|
|
37
|
-
/** The subset of `gh issue list --json` output this adapter reads. Fields the
|
|
38
|
-
* API can return as null are typed as such so the mapping has to handle it. */
|
|
39
|
-
interface GhIssue {
|
|
40
|
-
number: number;
|
|
41
|
-
title: string | null;
|
|
42
|
-
body: string | null;
|
|
43
|
-
labels: { name: string }[] | null;
|
|
44
|
-
url: string;
|
|
45
|
-
updatedAt: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
37
|
/**
|
|
49
38
|
* Pull requests that would close an issue, with the one field that decides it.
|
|
50
39
|
*
|
|
@@ -172,24 +161,27 @@ async function gh(argv: string[], stdin?: string): Promise<string> {
|
|
|
172
161
|
|
|
173
162
|
/**
|
|
174
163
|
* True when a label edit failed only because the requested end state already
|
|
175
|
-
* holds. Adding a label is idempotent server-side
|
|
176
|
-
*
|
|
164
|
+
* holds. Adding a label is idempotent server-side — `gh api -X POST
|
|
165
|
+
* .../labels` answers 200 for an already-present label — but removing one that
|
|
166
|
+
* is not present is a 404, and a concurrent daemon restart can easily race into
|
|
167
|
+
* both.
|
|
177
168
|
*
|
|
178
|
-
* The quoted
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
169
|
+
* The quoted forms are gh's own: `gh issue edit --remove-label x` on a label
|
|
170
|
+
* the *repository* does not define exits 1 with `'x' not found` (2.97.0), and
|
|
171
|
+
* `gh api -X DELETE .../labels/x` on an absent label fails with the API's
|
|
172
|
+
* `Label does not exist` / `HTTP 404`. For a removal both are the end state
|
|
173
|
+
* already holding — a label nobody defined cannot be on an issue — while for
|
|
174
|
+
* an add they are a genuine failure. Removing a label the repo defines but the
|
|
175
|
+
* issue does not carry is already a silent success, so this path is reached
|
|
176
|
+
* only by the undefined-label case, most often a state label an operator
|
|
177
|
+
* declined to create at setup.
|
|
186
178
|
*/
|
|
187
179
|
function isLabelNoop(err: unknown, op: "add" | "remove"): boolean {
|
|
188
180
|
if (!(err instanceof GhError)) return false;
|
|
189
181
|
const stderr = err.stderr;
|
|
190
182
|
return op === "add"
|
|
191
183
|
? /already (?:has|had|exists|applied|added)|label .* already/i.test(stderr)
|
|
192
|
-
: /label does not exist|not labeled|does not have (?:that|the|this) label|label .* not found|not found on (?:this )?issue|'[^']+' not found/i.test(
|
|
184
|
+
: /label does not exist|not labeled|does not have (?:that|the|this) label|label .* not found|not found on (?:this )?issue|'[^']+' not found|HTTP 404|Not Found/i.test(
|
|
193
185
|
stderr,
|
|
194
186
|
);
|
|
195
187
|
}
|
|
@@ -234,6 +226,22 @@ export function firstOpenCloser(raw: string): OpenCloser | undefined {
|
|
|
234
226
|
*/
|
|
235
227
|
const PR_URL = /^https?:\/\/[^\s/]+\/[^\s/]+\/[^\s/]+\/pull\/\d+\/?$/;
|
|
236
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Split a pull request URL into the owner, repo and number the REST API path
|
|
231
|
+
* spelling needs. The same shape {@link PR_URL} guards, with the parts
|
|
232
|
+
* captured; undefined for anything that guard would reject, so callers can use
|
|
233
|
+
* the two interchangeably.
|
|
234
|
+
*/
|
|
235
|
+
export function prUrlParts(
|
|
236
|
+
url: string,
|
|
237
|
+
): { owner: string; repo: string; number: number } | undefined {
|
|
238
|
+
const match = /^https?:\/\/[^\s/]+\/([^\s/]+)\/([^\s/]+)\/pull\/(\d+)\/?$/.exec(url);
|
|
239
|
+
if (match === null || match[1] === undefined || match[2] === undefined || match[3] === undefined) {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
return { owner: match[1], repo: match[2], number: Number.parseInt(match[3], 10) };
|
|
243
|
+
}
|
|
244
|
+
|
|
237
245
|
/**
|
|
238
246
|
* How much of a pull request's diff the settlement audit will hold in memory.
|
|
239
247
|
*
|
|
@@ -372,6 +380,29 @@ export function issueStateFrom(raw: string): IssueState | undefined {
|
|
|
372
380
|
}
|
|
373
381
|
}
|
|
374
382
|
|
|
383
|
+
/**
|
|
384
|
+
* GitHub's REST pull-request shape (`{state, merged_at}`) mapped onto
|
|
385
|
+
* {@link PrState}. `merged_at` is what distinguishes a merged PR from a
|
|
386
|
+
* closed-unmerged one under REST — `state` alone answers "closed" for both, and
|
|
387
|
+
* a settle sweep that read only state would rewrite a merged row as
|
|
388
|
+
* closed-unmerged. Anything unrecognised is "could not tell", the same
|
|
389
|
+
* fail-closed answer {@link prStateFrom} gives.
|
|
390
|
+
*/
|
|
391
|
+
export function prStateFromRest(raw: string): PrState | undefined {
|
|
392
|
+
let parsed: unknown;
|
|
393
|
+
try {
|
|
394
|
+
parsed = JSON.parse(raw) as unknown;
|
|
395
|
+
} catch {
|
|
396
|
+
return undefined;
|
|
397
|
+
}
|
|
398
|
+
if (parsed === null || typeof parsed !== "object") return undefined;
|
|
399
|
+
const row = parsed as { readonly [key: string]: unknown };
|
|
400
|
+
if (row["merged_at"] != null) return "merged";
|
|
401
|
+
if (row["state"] === "closed") return "closed";
|
|
402
|
+
if (row["state"] === "open") return "open";
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
|
|
375
406
|
/**
|
|
376
407
|
* Parent number from a raw GraphQL response, or undefined when the issue has
|
|
377
408
|
* no parent. Throws when the issue or claimed parent is malformed — admission
|
|
@@ -438,6 +469,26 @@ export function mergeableFrom(raw: string): "conflicting" | "clean" | "unknown"
|
|
|
438
469
|
}
|
|
439
470
|
}
|
|
440
471
|
|
|
472
|
+
/**
|
|
473
|
+
* The REST mergeability spelling onto the same fail-open triple. `--jq
|
|
474
|
+
* .mergeable` prints the bare JSON token, so `"true"` and `"false"` are the
|
|
475
|
+
* only clean answers; `null` (printed verbatim by `--jq`) is GitHub still
|
|
476
|
+
* computing the merge, and everything else means the API changed under us.
|
|
477
|
+
*/
|
|
478
|
+
export function mergeableFromRest(raw: string): "conflicting" | "clean" | "unknown" {
|
|
479
|
+
switch (raw.trim()) {
|
|
480
|
+
case "true":
|
|
481
|
+
return "clean";
|
|
482
|
+
case "false":
|
|
483
|
+
return "conflicting";
|
|
484
|
+
default:
|
|
485
|
+
// `null` is GitHub still computing the merge on a PR pushed seconds ago.
|
|
486
|
+
// Reading it as clean would let a conflict recovery fire on a PR nobody
|
|
487
|
+
// has assessed yet — the same reasoning as `UNKNOWN` above.
|
|
488
|
+
return "unknown";
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
441
492
|
/**
|
|
442
493
|
* Distinct workflow-run ids behind the non-success checks of a PR.
|
|
443
494
|
*
|
|
@@ -459,7 +510,17 @@ export function failedRunIds(
|
|
|
459
510
|
return ids;
|
|
460
511
|
}
|
|
461
512
|
|
|
462
|
-
/**
|
|
513
|
+
/**
|
|
514
|
+
* Parse `[{number, state}]` from `gh issue list` or the sub-issues API.
|
|
515
|
+
*
|
|
516
|
+
* The REST issues endpoint mixes pull requests into the list, and the reconcile
|
|
517
|
+
* must never remove labels off a PR — so rows carrying a `pull_request` key are
|
|
518
|
+
* dropped before the caller sees them. This replaces the `--jq
|
|
519
|
+
* select(.pull_request == null)` projection listLabeled used to send: `-i`
|
|
520
|
+
* output (needed for conditional revalidation) cannot carry a jq projection,
|
|
521
|
+
* so the filter moved in-process where it also covers every future caller.
|
|
522
|
+
* `childrenOf` rows never carry the key, so the guard is a no-op there.
|
|
523
|
+
*/
|
|
463
524
|
export function labeledIssuesFrom(raw: string): { number: number; state: IssueState }[] {
|
|
464
525
|
let parsed: unknown;
|
|
465
526
|
try {
|
|
@@ -472,6 +533,7 @@ export function labeledIssuesFrom(raw: string): { number: number; state: IssueSt
|
|
|
472
533
|
for (const entry of parsed) {
|
|
473
534
|
if (entry === null || typeof entry !== "object") continue;
|
|
474
535
|
const row = entry as { readonly [key: string]: unknown };
|
|
536
|
+
if (row["pull_request"] != null) continue;
|
|
475
537
|
const number = row["number"];
|
|
476
538
|
const rawState = row["state"];
|
|
477
539
|
if (typeof number !== "number" || !Number.isInteger(number) || typeof rawState !== "string") continue;
|
|
@@ -483,50 +545,377 @@ export function labeledIssuesFrom(raw: string): { number: number; state: IssueSt
|
|
|
483
545
|
return issues;
|
|
484
546
|
}
|
|
485
547
|
|
|
486
|
-
|
|
548
|
+
/**
|
|
549
|
+
* Parse the REST `/repos/{owner}/{repo}/issues` array onto {@link ReadyIssue}.
|
|
550
|
+
*
|
|
551
|
+
* The endpoint returns pull requests as well as issues — every PR is an issue
|
|
552
|
+
* with a `pull_request` key — so that key is the filter that keeps a routed
|
|
553
|
+
* repo's PR backlog out of the ready queue (#188). The REST field spelling is
|
|
554
|
+
* snake_case (`html_url`, `updated_at`); nulls are tolerated the same way the
|
|
555
|
+
* mapping this replaces tolerated them, because the API answers null for any
|
|
556
|
+
* of these fields.
|
|
557
|
+
*/
|
|
558
|
+
export function readyIssuesFromRest(raw: string): ReadyIssue[] {
|
|
559
|
+
let parsed: unknown;
|
|
560
|
+
try {
|
|
561
|
+
parsed = JSON.parse(raw) as unknown;
|
|
562
|
+
} catch {
|
|
563
|
+
return [];
|
|
564
|
+
}
|
|
565
|
+
if (!Array.isArray(parsed)) return [];
|
|
566
|
+
const issues: ReadyIssue[] = [];
|
|
567
|
+
for (const entry of parsed) {
|
|
568
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
569
|
+
const row = entry as { readonly [key: string]: unknown };
|
|
570
|
+
// The endpoint mixes in pull requests; the queue must never claim one.
|
|
571
|
+
if (row["pull_request"] != null) continue;
|
|
572
|
+
const number = row["number"];
|
|
573
|
+
const title = row["title"];
|
|
574
|
+
const body = row["body"];
|
|
575
|
+
const labels = row["labels"];
|
|
576
|
+
const url = row["html_url"];
|
|
577
|
+
const updatedAt = row["updated_at"];
|
|
578
|
+
if (typeof number !== "number" || !Number.isInteger(number)) continue;
|
|
579
|
+
if (typeof url !== "string" || typeof updatedAt !== "string") continue;
|
|
580
|
+
const names: string[] = [];
|
|
581
|
+
if (Array.isArray(labels)) {
|
|
582
|
+
for (const label of labels) {
|
|
583
|
+
if (label === null || typeof label !== "object") continue;
|
|
584
|
+
const name = (label as { readonly name?: unknown }).name;
|
|
585
|
+
if (typeof name === "string") names.push(name);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
issues.push({
|
|
589
|
+
number,
|
|
590
|
+
title: typeof title === "string" ? title : "",
|
|
591
|
+
body: typeof body === "string" ? body : "",
|
|
592
|
+
labels: names,
|
|
593
|
+
url,
|
|
594
|
+
updatedAt,
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
return issues;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** One cached page per request URL: the ETag GitHub answered, the raw JSON
|
|
601
|
+
* body it covered, and whether that page advertised a rel="next" successor.
|
|
602
|
+
* Module-level default so every makeTracker() in the process (board probe,
|
|
603
|
+
* tick, preview) shares it; keyed by exact URL, so repos, labels and page
|
|
604
|
+
* numbers never collide. */
|
|
605
|
+
export type RestListCache = Map<string, { etag: string; body: string; hasNext: boolean }>;
|
|
606
|
+
const sharedRestListCache: RestListCache = new Map();
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Split a `gh api -i` answer into status, etag, rel="next" presence, body.
|
|
610
|
+
*
|
|
611
|
+
* `gh api -i` prints the status line and response headers, a blank line, then
|
|
612
|
+
* the body. A page's ETag is its identity for conditional revalidation, and
|
|
613
|
+
* the `Link` header is the only place the adapter can learn a successor page
|
|
614
|
+
* exists without parsing the body. Throws on a payload that is not an HTTP
|
|
615
|
+
* response at all — the caller must not guess what an unrecognisable answer
|
|
616
|
+
* means.
|
|
617
|
+
*/
|
|
618
|
+
export function splitApiInclude(raw: string): {
|
|
619
|
+
status: number;
|
|
620
|
+
etag?: string;
|
|
621
|
+
hasNext: boolean;
|
|
622
|
+
body: string;
|
|
623
|
+
} {
|
|
624
|
+
const blank = /\r?\n\r?\n/.exec(raw);
|
|
625
|
+
const headerBlock = blank === null ? raw : raw.slice(0, blank.index);
|
|
626
|
+
const body = blank === null ? "" : raw.slice(blank.index + blank[0].length);
|
|
627
|
+
const [statusLine = ""] = headerBlock.split(/\r?\n/);
|
|
628
|
+
const statusMatch = /^HTTP\/[\d.]+ (\d{3})/.exec(statusLine);
|
|
629
|
+
if (statusMatch === null) {
|
|
630
|
+
throw new Error("unrecognised gh api -i response");
|
|
631
|
+
}
|
|
632
|
+
let etag: string | undefined;
|
|
633
|
+
let hasNext = false;
|
|
634
|
+
for (const line of headerBlock.split(/\r?\n/)) {
|
|
635
|
+
const colon = line.indexOf(":");
|
|
636
|
+
if (colon <= 0) continue;
|
|
637
|
+
const name = line.slice(0, colon).trim().toLowerCase();
|
|
638
|
+
const value = line.slice(colon + 1).trim();
|
|
639
|
+
if (name === "etag") etag = value;
|
|
640
|
+
else if (name === "link" && /rel="next"/.test(value)) hasNext = true;
|
|
641
|
+
}
|
|
642
|
+
return { status: Number(statusMatch[1]), etag, hasNext, body };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* GET a REST list path page by page, revalidating each known page with
|
|
647
|
+
* If-None-Match. A 304 reuses that page's cached body and costs no primary
|
|
648
|
+
* rate-limit budget; a changed page answers 200 and replaces its cache entry.
|
|
649
|
+
* Every known page is requested on every pass, so a change on page N is
|
|
650
|
+
* detected even when earlier pages are unchanged. Follows rel="next" with no
|
|
651
|
+
* page cap when `paginate`; entries past the final page are pruned. Returns
|
|
652
|
+
* one raw JSON-array body per page.
|
|
653
|
+
*/
|
|
654
|
+
async function conditionalListPages(
|
|
655
|
+
runGh: typeof gh,
|
|
656
|
+
cache: RestListCache,
|
|
657
|
+
path: string,
|
|
658
|
+
paginate: boolean,
|
|
659
|
+
onNotModified?: () => void,
|
|
660
|
+
): Promise<string[]> {
|
|
661
|
+
const bodies: string[] = [];
|
|
662
|
+
let n = 1;
|
|
663
|
+
for (;;) {
|
|
664
|
+
const url = n === 1 ? path : `${path}&page=${n}`;
|
|
665
|
+
const cached = cache.get(url);
|
|
666
|
+
const argv = ["api", "-i", ...(cached ? ["-H", `If-None-Match: ${cached.etag}`] : []), url];
|
|
667
|
+
let raw: string;
|
|
668
|
+
try {
|
|
669
|
+
raw = await runGh(argv);
|
|
670
|
+
} catch (err) {
|
|
671
|
+
if (!(err instanceof GhError) || !/^HTTP\/[\d.]+ 304\b/.test(err.stdout)) throw err;
|
|
672
|
+
// A 304 is gh's documented answer for an unchanged conditional request:
|
|
673
|
+
// exit 1, with the status line still on stdout. `cached` must exist —
|
|
674
|
+
// we only send If-None-Match when we hold an etag — so rethrow if not,
|
|
675
|
+
// fail-loud, never serve a page we did not cache.
|
|
676
|
+
if (cached === undefined) throw err;
|
|
677
|
+
bodies.push(cached.body);
|
|
678
|
+
onNotModified?.();
|
|
679
|
+
if (!paginate || !cached.hasNext) break;
|
|
680
|
+
n += 1;
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
const r = splitApiInclude(raw);
|
|
684
|
+
if (r.status === 304) {
|
|
685
|
+
// A future `gh` that exits 0 on a 304 would land here instead of the
|
|
686
|
+
// catch above; both arms answer identically. The cache entry keeps its
|
|
687
|
+
// stored etag — the 304's echo comes back strong-form, while the stored
|
|
688
|
+
// weak form keeps matching.
|
|
689
|
+
if (cached === undefined) {
|
|
690
|
+
throw new Error("a 304 answered a page never sent If-None-Match for");
|
|
691
|
+
}
|
|
692
|
+
bodies.push(cached.body);
|
|
693
|
+
onNotModified?.();
|
|
694
|
+
if (!paginate || !cached.hasNext) break;
|
|
695
|
+
n += 1;
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
if (r.etag !== undefined) cache.set(url, { etag: r.etag, body: r.body, hasNext: r.hasNext });
|
|
699
|
+
else cache.delete(url);
|
|
700
|
+
bodies.push(r.body);
|
|
701
|
+
if (!paginate || !r.hasNext) break;
|
|
702
|
+
n += 1;
|
|
703
|
+
}
|
|
704
|
+
// The list shrank: without this a later pass would revalidate pages that no
|
|
705
|
+
// longer exist. `n` is the last page fetched; prune every successor key.
|
|
706
|
+
for (let k = n + 1; cache.has(`${path}&page=${k}`); k += 1) {
|
|
707
|
+
cache.delete(`${path}&page=${k}`);
|
|
708
|
+
}
|
|
709
|
+
return bodies;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** The two GitHub API budgets this adapter spends: GraphQL for the
|
|
713
|
+
* closers/parent/rollup reads, core for everything else. `reset` is the REST
|
|
714
|
+
* spelling — epoch seconds. */
|
|
715
|
+
export interface RateLimitStatus {
|
|
716
|
+
graphql: { remaining: number; limit: number; reset: number };
|
|
717
|
+
core: { remaining: number; limit: number; reset: number };
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function rateLimitBucket(
|
|
721
|
+
raw: unknown,
|
|
722
|
+
): { remaining: number; limit: number; reset: number } | undefined {
|
|
723
|
+
if (raw === null || typeof raw !== "object") return undefined;
|
|
724
|
+
const row = raw as { readonly [key: string]: unknown };
|
|
725
|
+
const remaining = row["remaining"];
|
|
726
|
+
const limit = row["limit"];
|
|
727
|
+
const reset = row["reset"];
|
|
728
|
+
if (typeof remaining !== "number" || typeof limit !== "number" || typeof reset !== "number") {
|
|
729
|
+
return undefined;
|
|
730
|
+
}
|
|
731
|
+
return { remaining, limit, reset };
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Read the GitHub API rate-limit budget for `status` (the tracker layer's
|
|
736
|
+
* counterpart to the decision probe: the layers differ, so the two stay
|
|
737
|
+
* independent).
|
|
738
|
+
*
|
|
739
|
+
* Any failure — a revoked token, a flaky network, a payload shape this build
|
|
740
|
+
* does not recognise — answers undefined: a broken `gh` must cost one status
|
|
741
|
+
* row, never the whole report.
|
|
742
|
+
*/
|
|
743
|
+
export async function fetchRateLimit(
|
|
744
|
+
runGh: typeof gh = gh,
|
|
745
|
+
): Promise<RateLimitStatus | undefined> {
|
|
746
|
+
try {
|
|
747
|
+
const parsed = JSON.parse(
|
|
748
|
+
await runGh(["api", "rate_limit", "--jq", "{graphql: .resources.graphql, core: .resources.core}"]),
|
|
749
|
+
) as unknown;
|
|
750
|
+
if (parsed === null || typeof parsed !== "object") return undefined;
|
|
751
|
+
const row = parsed as { readonly [key: string]: unknown };
|
|
752
|
+
const graphql = rateLimitBucket(row["graphql"]);
|
|
753
|
+
const core = rateLimitBucket(row["core"]);
|
|
754
|
+
if (graphql === undefined || core === undefined) return undefined;
|
|
755
|
+
return { graphql, core };
|
|
756
|
+
} catch {
|
|
757
|
+
return undefined;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* True when a `gh` failure is GitHub refusing us at the rate-limit layer.
|
|
763
|
+
*
|
|
764
|
+
* Three distinct bodies arrive through the CLI and all mean "stop sending":
|
|
765
|
+
* the primary budget exhausted (`API rate limit exceeded`, or the 403 the REST
|
|
766
|
+
* layer reports as `HTTP 403 ... rate limit`), the secondary budget
|
|
767
|
+
* (`secondary rate limit`), and an abuse-control trip (`abuse detection`).
|
|
768
|
+
* gh's stderr carries none of the header values (`Retry-After`, `reset`) that
|
|
769
|
+
* would drive a precise backoff, so the cooldown is the tracker's fixed window
|
|
770
|
+
* below rather than a header-driven one.
|
|
771
|
+
*/
|
|
772
|
+
export function isRateLimitRefusal(err: unknown): boolean {
|
|
773
|
+
return (
|
|
774
|
+
err instanceof GhError &&
|
|
775
|
+
/API rate limit exceeded|secondary rate limit|abuse detection|HTTP 403.*rate limit/i.test(
|
|
776
|
+
err.stderr,
|
|
777
|
+
)
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* A rate-limit refusal rethrown by {@link makeTracker}. This is the one
|
|
783
|
+
* failure class the tracker names instead of swallowing: the circuit breaker
|
|
784
|
+
* throws it without spawning `gh`, and it is what a caller can distinguish
|
|
785
|
+
* from an ordinary transport fault.
|
|
786
|
+
*/
|
|
787
|
+
export class GhRateLimitError extends GhError {
|
|
788
|
+
/** Wall-clock millis after which a call may be attempted again. */
|
|
789
|
+
readonly retryAtMs: number;
|
|
790
|
+
|
|
791
|
+
constructor(argv: string[], code: number, stderr: string, retryAtMs: number, stdout = "") {
|
|
792
|
+
super(argv, code, stderr, stdout);
|
|
793
|
+
this.name = "GhRateLimitError";
|
|
794
|
+
this.retryAtMs = retryAtMs;
|
|
795
|
+
this.message = `${this.message} — GitHub rate limit; retry at ${new Date(retryAtMs).toISOString()}`;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** How long a rate-limit refusal holds the circuit breaker open. */
|
|
800
|
+
export const RATE_LIMIT_COOLDOWN_MS = 60_000;
|
|
801
|
+
|
|
802
|
+
/** Instrumentation hooks the daemon binds the tracker to (#198). */
|
|
803
|
+
export interface TrackerHooks {
|
|
804
|
+
/** Fired immediately before each `gh` spawn, so the daemon can count its own
|
|
805
|
+
* API spend. Not fired for a breaker fast-fail, which spawns nothing. */
|
|
806
|
+
onCall?: () => void;
|
|
807
|
+
/** Fired once per observed refusal, with the wall-clock moment. */
|
|
808
|
+
onRefusal?: (at: number) => void;
|
|
809
|
+
/** Fired once per list page GitHub answered 304 for — a spawn that cost no
|
|
810
|
+
* primary-rate-limit budget. Lets the daemon's call counter tell billed
|
|
811
|
+
* reads from free revalidations (#203 on top of #198's counting). */
|
|
812
|
+
onNotModified?: () => void;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
interface TrackerOpts {
|
|
816
|
+
/** Override {@link RATE_LIMIT_COOLDOWN_MS} (tests use a 0ms window). */
|
|
817
|
+
rateLimitCooldownMs?: number;
|
|
818
|
+
/** Conditional-request cache for REST list reads; defaults to the shared
|
|
819
|
+
* module-level cache. Tests pass a fresh Map. */
|
|
820
|
+
listCache?: RestListCache;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export function makeTracker(
|
|
824
|
+
p: ProjectConfig,
|
|
825
|
+
injectedRunGh: typeof gh = gh,
|
|
826
|
+
hooks: TrackerHooks = {},
|
|
827
|
+
opts: TrackerOpts = {},
|
|
828
|
+
): Tracker {
|
|
487
829
|
const repo = p.tracker.repo;
|
|
830
|
+
const refuseForMs = opts.rateLimitCooldownMs ?? RATE_LIMIT_COOLDOWN_MS;
|
|
831
|
+
const cache = opts.listCache ?? sharedRestListCache;
|
|
832
|
+
|
|
833
|
+
// The single funnel around the injected `gh`. A caught refusal records the
|
|
834
|
+
// user-observed moment, opens the 60s breaker and rethrows named; while the
|
|
835
|
+
// breaker is open every call fails fast WITHOUT spawning gh — a burst under a
|
|
836
|
+
// persistent limit degrades the tick into holds (`open-pr-lookup-error` /
|
|
837
|
+
// `parent-lookup-error` fail closed per candidate and retry next tick)
|
|
838
|
+
// instead of an error storm or a stalled loop. No in-wrapper sleeps.
|
|
839
|
+
let refusedUntil = 0;
|
|
840
|
+
const runGh = async (argv: string[], stdin?: string): Promise<string> => {
|
|
841
|
+
const now = Date.now();
|
|
842
|
+
if (now < refusedUntil) {
|
|
843
|
+
throw new GhRateLimitError(
|
|
844
|
+
argv,
|
|
845
|
+
0,
|
|
846
|
+
"circuit breaker open (a prior GitHub rate-limit refusal holds the cooldown)",
|
|
847
|
+
refusedUntil,
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
hooks.onCall?.();
|
|
851
|
+
try {
|
|
852
|
+
return await injectedRunGh(argv, stdin);
|
|
853
|
+
} catch (err) {
|
|
854
|
+
if (isRateLimitRefusal(err)) {
|
|
855
|
+
const at = Date.now();
|
|
856
|
+
refusedUntil = at + refuseForMs;
|
|
857
|
+
hooks.onRefusal?.(at);
|
|
858
|
+
const cause = err instanceof GhError ? err : undefined;
|
|
859
|
+
throw new GhRateLimitError(
|
|
860
|
+
argv,
|
|
861
|
+
cause?.code ?? 1,
|
|
862
|
+
cause?.stderr ?? String(err),
|
|
863
|
+
refusedUntil,
|
|
864
|
+
cause?.stdout ?? "",
|
|
865
|
+
);
|
|
866
|
+
}
|
|
867
|
+
throw err;
|
|
868
|
+
}
|
|
869
|
+
};
|
|
488
870
|
|
|
489
871
|
// Named rather than returned inline, so `rerunFailedChecks` can reuse
|
|
490
872
|
// `checkConclusions` instead of re-implementing the same `gh` call.
|
|
491
873
|
const tracker: Tracker = {
|
|
492
874
|
async listReady(): Promise<ReadyIssue[]> {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
"100",
|
|
507
|
-
"--json",
|
|
508
|
-
"number,title,body,labels,url,updatedAt",
|
|
509
|
-
]);
|
|
875
|
+
// REST, not `issue list --json`: the ready-queue read is a hot-path poll
|
|
876
|
+
// on every dispatch pass, and GraphQL budget is the scarce one (#188).
|
|
877
|
+
// Paginated rather than one page of 100 — now that an idle revalidation
|
|
878
|
+
// costs an unconditional-200's worth of nothing, truncating a deep queue
|
|
879
|
+
// to save calls saves nothing that matters; the cap was the call count,
|
|
880
|
+
// and the conditional path changed what a call costs.
|
|
881
|
+
const pages = await conditionalListPages(
|
|
882
|
+
runGh,
|
|
883
|
+
cache,
|
|
884
|
+
`repos/${repo}/issues?state=open&labels=${encodeURIComponent(p.queueLabel)}&per_page=100`,
|
|
885
|
+
/* paginate */ true,
|
|
886
|
+
hooks.onNotModified,
|
|
887
|
+
);
|
|
510
888
|
|
|
511
|
-
|
|
512
|
-
//
|
|
513
|
-
// the normal steady state, not an error.
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
889
|
+
// The REST endpoint always answers a JSON array — an empty queue is `[]`,
|
|
890
|
+
// not empty text — but an empty body still parses to no issues here, and
|
|
891
|
+
// the empty queue is the normal steady state, not an error.
|
|
892
|
+
return pages.flatMap(readyIssuesFromRest);
|
|
893
|
+
},
|
|
894
|
+
|
|
895
|
+
async listOpenIssues(): Promise<ReadyIssue[]> {
|
|
896
|
+
const pages = await conditionalListPages(
|
|
897
|
+
runGh,
|
|
898
|
+
cache,
|
|
899
|
+
`repos/${repo}/issues?state=open&per_page=100`,
|
|
900
|
+
/* paginate */ true,
|
|
901
|
+
hooks.onNotModified,
|
|
902
|
+
);
|
|
903
|
+
return pages.flatMap(readyIssuesFromRest);
|
|
525
904
|
},
|
|
526
905
|
|
|
527
906
|
async addLabel(issue: number, label: string): Promise<void> {
|
|
528
907
|
try {
|
|
529
|
-
|
|
908
|
+
// REST POST is natively idempotent: re-adding a label the issue already
|
|
909
|
+
// carries answers 200 with the label, so the noop classification below
|
|
910
|
+
// is reachable only on a genuinely odd failure — kept for parity.
|
|
911
|
+
await runGh([
|
|
912
|
+
"api",
|
|
913
|
+
"-X",
|
|
914
|
+
"POST",
|
|
915
|
+
`repos/${repo}/issues/${issue}/labels`,
|
|
916
|
+
"-f",
|
|
917
|
+
`labels[]=${label}`,
|
|
918
|
+
]);
|
|
530
919
|
} catch (err) {
|
|
531
920
|
if (!isLabelNoop(err, "add")) throw err;
|
|
532
921
|
}
|
|
@@ -534,7 +923,12 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
|
|
|
534
923
|
|
|
535
924
|
async removeLabel(issue: number, label: string): Promise<void> {
|
|
536
925
|
try {
|
|
537
|
-
await runGh([
|
|
926
|
+
await runGh([
|
|
927
|
+
"api",
|
|
928
|
+
"-X",
|
|
929
|
+
"DELETE",
|
|
930
|
+
`repos/${repo}/issues/${issue}/labels/${encodeURIComponent(label)}`,
|
|
931
|
+
]);
|
|
538
932
|
} catch (err) {
|
|
539
933
|
if (!isLabelNoop(err, "remove")) throw err;
|
|
540
934
|
}
|
|
@@ -602,8 +996,10 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
|
|
|
602
996
|
|
|
603
997
|
async issueState(issue: number): Promise<IssueState | undefined> {
|
|
604
998
|
try {
|
|
999
|
+
// REST answers lowercase `open`/`closed`; the shared parser expects the
|
|
1000
|
+
// CLI's uppercase spelling, so the answer is normalised before it.
|
|
605
1001
|
return issueStateFrom(
|
|
606
|
-
await runGh(["
|
|
1002
|
+
(await runGh(["api", `repos/${repo}/issues/${issue}`, "--jq", ".state"])).trim().toUpperCase(),
|
|
607
1003
|
);
|
|
608
1004
|
} catch {
|
|
609
1005
|
return undefined;
|
|
@@ -611,14 +1007,21 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
|
|
|
611
1007
|
},
|
|
612
1008
|
|
|
613
1009
|
async prState(url: string): Promise<PrState | undefined> {
|
|
614
|
-
// No `--repo`:
|
|
615
|
-
// from
|
|
616
|
-
//
|
|
617
|
-
// of the routed repos. Deriving `--repo` from the URL would only re-state
|
|
618
|
-
// what the URL already says.
|
|
1010
|
+
// No `--repo`: the full URL names the repository, and the daemon runs
|
|
1011
|
+
// from its own state directory rather than a checkout — so the REST path
|
|
1012
|
+
// halves come out of the URL itself, never out of the current directory.
|
|
619
1013
|
if (!PR_URL.test(url)) return undefined;
|
|
1014
|
+
const parts = prUrlParts(url);
|
|
1015
|
+
if (parts === undefined) return undefined;
|
|
620
1016
|
try {
|
|
621
|
-
return
|
|
1017
|
+
return prStateFromRest(
|
|
1018
|
+
await runGh([
|
|
1019
|
+
"api",
|
|
1020
|
+
`repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`,
|
|
1021
|
+
"--jq",
|
|
1022
|
+
"{state, merged_at}",
|
|
1023
|
+
]),
|
|
1024
|
+
);
|
|
622
1025
|
} catch {
|
|
623
1026
|
// Never throws, per the port's contract. A deleted PR, a revoked token
|
|
624
1027
|
// and a flaky network all mean "could not tell", and the caller's whole
|
|
@@ -713,8 +1116,17 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
|
|
|
713
1116
|
|
|
714
1117
|
async mergeable(prUrl: string): Promise<"conflicting" | "clean" | "unknown"> {
|
|
715
1118
|
if (!PR_URL.test(prUrl)) return "unknown";
|
|
1119
|
+
const parts = prUrlParts(prUrl);
|
|
1120
|
+
if (parts === undefined) return "unknown";
|
|
716
1121
|
try {
|
|
717
|
-
return
|
|
1122
|
+
return mergeableFromRest(
|
|
1123
|
+
await runGh([
|
|
1124
|
+
"api",
|
|
1125
|
+
`repos/${parts.owner}/${parts.repo}/pulls/${parts.number}`,
|
|
1126
|
+
"--jq",
|
|
1127
|
+
".mergeable",
|
|
1128
|
+
]),
|
|
1129
|
+
);
|
|
718
1130
|
} catch {
|
|
719
1131
|
return "unknown";
|
|
720
1132
|
}
|
|
@@ -735,22 +1147,19 @@ export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
|
|
|
735
1147
|
|
|
736
1148
|
async listLabeled(label: string, limit = 50): Promise<{ number: number; state: IssueState }[]> {
|
|
737
1149
|
try {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
"--json",
|
|
749
|
-
"number,state",
|
|
750
|
-
"--limit",
|
|
751
|
-
String(limit),
|
|
752
|
-
]),
|
|
1150
|
+
// One page, deliberately: `limit` is the caller's work bound
|
|
1151
|
+
// (RECONCILE_LIMIT), not a queue-depth cap, and the reconcile only
|
|
1152
|
+
// ever acts on the page it reads. The conditional cache still makes a
|
|
1153
|
+
// steady-state pass cost nothing even though the response is big.
|
|
1154
|
+
const pages = await conditionalListPages(
|
|
1155
|
+
runGh,
|
|
1156
|
+
cache,
|
|
1157
|
+
`repos/${repo}/issues?state=all&labels=${encodeURIComponent(label)}&per_page=${limit}`,
|
|
1158
|
+
/* paginate */ false,
|
|
1159
|
+
hooks.onNotModified,
|
|
753
1160
|
);
|
|
1161
|
+
// paginate: false means exactly one body, always a JSON array.
|
|
1162
|
+
return labeledIssuesFrom(pages[0] ?? "");
|
|
754
1163
|
} catch {
|
|
755
1164
|
// A reconcile that cannot list must remove no labels: an empty answer is
|
|
756
1165
|
// read as "no evidence", never as "nothing carries this label".
|