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.
@@ -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, but removing one that is not
176
- * present is a 404, and a concurrent daemon restart can easily race into both.
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 form is gh's own, and it is why the two operations are classified
179
- * separately: `gh issue edit --remove-label x` on a label the *repository* does
180
- * not define exits 1 with `'x' not found` (2.97.0), which for a removal is the
181
- * end state already holding a label nobody defined cannot be on an issue
182
- * while for an add it is a genuine failure. Removing a label the repo defines
183
- * but the issue does not carry is already a silent success, so this path is
184
- * reached only by the undefined-label case, most often a state label an
185
- * operator declined to create at setup.
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
- /** Parse `[{number, state}]` from `gh issue list` or the sub-issues API. */
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
- export function makeTracker(p: ProjectConfig, runGh: typeof gh = gh): Tracker {
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
- const raw = await runGh([
494
- "issue",
495
- "list",
496
- "--repo",
497
- repo,
498
- "--state",
499
- "open",
500
- "--label",
501
- p.queueLabel,
502
- // ponytail: one page is the cap. A queue deeper than 100 ready issues
503
- // truncates silently; upgrade path is `--paginate` via the API, but a
504
- // backlog that size is a staffing problem before it is a paging one.
505
- "--limit",
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
- const text = raw.trim();
512
- // `gh` prints nothing at all in some no-match paths; an empty queue is
513
- // the normal steady state, not an error.
514
- if (!text) return [];
515
-
516
- const issues = JSON.parse(text) as GhIssue[];
517
- return issues.map((issue) => ({
518
- number: issue.number,
519
- title: issue.title ?? "",
520
- body: issue.body ?? "",
521
- labels: (issue.labels ?? []).map((label) => label.name),
522
- url: issue.url,
523
- updatedAt: issue.updatedAt,
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
- await runGh(["issue", "edit", String(issue), "--repo", repo, "--add-label", label]);
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(["issue", "edit", String(issue), "--repo", repo, "--remove-label", label]);
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(["issue", "view", String(issue), "--repo", repo, "--json", "state", "--jq", ".state"]),
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`: a full URL is self-locating, and verified so on gh 2.97.0
615
- // from a directory that is not a git repository at all which is exactly
616
- // where the daemon runs (its state directory), while the PR lives in one
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 prStateFrom(await runGh(["pr", "view", url, "--json", "state", "--jq", ".state"]));
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 mergeableFrom(await runGh(["pr", "view", prUrl, "--json", "mergeable", "--jq", ".mergeable"]));
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
- return labeledIssuesFrom(
739
- await runGh([
740
- "issue",
741
- "list",
742
- "--repo",
743
- repo,
744
- "--label",
745
- label,
746
- "--state",
747
- "all",
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".