@blogic-cz/agent-tools 1.7.5 → 1.8.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.
Files changed (128) hide show
  1. package/README.md +2 -1
  2. package/dist/db-tool/service.d.ts.map +1 -1
  3. package/dist/gh-tool/branch.d.ts +2 -3
  4. package/dist/gh-tool/branch.d.ts.map +1 -1
  5. package/dist/gh-tool/environment.d.ts +1 -1
  6. package/dist/gh-tool/environment.d.ts.map +1 -1
  7. package/dist/gh-tool/gist.d.ts +8 -28
  8. package/dist/gh-tool/gist.d.ts.map +1 -1
  9. package/dist/gh-tool/issue/commands.d.ts +9 -9
  10. package/dist/gh-tool/issue/commands.d.ts.map +1 -1
  11. package/dist/gh-tool/issue/core.d.ts +103 -9
  12. package/dist/gh-tool/issue/core.d.ts.map +1 -1
  13. package/dist/gh-tool/issue/triage.d.ts +6 -6
  14. package/dist/gh-tool/issue/triage.d.ts.map +1 -1
  15. package/dist/gh-tool/pr/commands.d.ts +53 -46
  16. package/dist/gh-tool/pr/commands.d.ts.map +1 -1
  17. package/dist/gh-tool/pr/core.d.ts +156 -86
  18. package/dist/gh-tool/pr/core.d.ts.map +1 -1
  19. package/dist/gh-tool/pr/review.d.ts +16 -16
  20. package/dist/gh-tool/pr/review.d.ts.map +1 -1
  21. package/dist/gh-tool/pr/snapshot.d.ts +9 -0
  22. package/dist/gh-tool/pr/snapshot.d.ts.map +1 -0
  23. package/dist/gh-tool/pr/stack-read.d.ts +1 -6
  24. package/dist/gh-tool/pr/stack-read.d.ts.map +1 -1
  25. package/dist/gh-tool/pr/stack.d.ts +3 -3
  26. package/dist/gh-tool/pr/stack.d.ts.map +1 -1
  27. package/dist/gh-tool/{api.d.ts → read/api.d.ts} +9 -3
  28. package/dist/gh-tool/read/api.d.ts.map +1 -0
  29. package/dist/gh-tool/read/cache.d.ts +44 -0
  30. package/dist/gh-tool/read/cache.d.ts.map +1 -0
  31. package/dist/gh-tool/read/catalog.d.ts +170 -0
  32. package/dist/gh-tool/read/catalog.d.ts.map +1 -0
  33. package/dist/gh-tool/read/classes.d.ts +94 -0
  34. package/dist/gh-tool/read/classes.d.ts.map +1 -0
  35. package/dist/gh-tool/read/commands.d.ts +3 -0
  36. package/dist/gh-tool/read/commands.d.ts.map +1 -0
  37. package/dist/gh-tool/read/gated.d.ts +14 -0
  38. package/dist/gh-tool/read/gated.d.ts.map +1 -0
  39. package/dist/gh-tool/read/index.d.ts +5 -0
  40. package/dist/gh-tool/read/index.d.ts.map +1 -0
  41. package/dist/gh-tool/read/issues.d.ts +68 -0
  42. package/dist/gh-tool/read/issues.d.ts.map +1 -0
  43. package/dist/gh-tool/read/keys.d.ts +61 -0
  44. package/dist/gh-tool/read/keys.d.ts.map +1 -0
  45. package/dist/gh-tool/read/porcelain.d.ts +68 -0
  46. package/dist/gh-tool/read/porcelain.d.ts.map +1 -0
  47. package/dist/gh-tool/read/pulls.d.ts +137 -0
  48. package/dist/gh-tool/read/pulls.d.ts.map +1 -0
  49. package/dist/gh-tool/read/reads.d.ts +209 -0
  50. package/dist/gh-tool/read/reads.d.ts.map +1 -0
  51. package/dist/gh-tool/read/rest.d.ts +49 -0
  52. package/dist/gh-tool/read/rest.d.ts.map +1 -0
  53. package/dist/gh-tool/read/review-guards.d.ts +20 -0
  54. package/dist/gh-tool/read/review-guards.d.ts.map +1 -0
  55. package/dist/gh-tool/read/review-model.d.ts +190 -0
  56. package/dist/gh-tool/read/review-model.d.ts.map +1 -0
  57. package/dist/gh-tool/read/reviews.d.ts +9 -0
  58. package/dist/gh-tool/read/reviews.d.ts.map +1 -0
  59. package/dist/gh-tool/read/stacks.d.ts +7 -0
  60. package/dist/gh-tool/read/stacks.d.ts.map +1 -0
  61. package/dist/gh-tool/read/store.d.ts +70 -0
  62. package/dist/gh-tool/read/store.d.ts.map +1 -0
  63. package/dist/gh-tool/{service.d.ts → read/transport.d.ts} +5 -3
  64. package/dist/gh-tool/read/transport.d.ts.map +1 -0
  65. package/dist/gh-tool/read/waits.d.ts +22 -0
  66. package/dist/gh-tool/read/waits.d.ts.map +1 -0
  67. package/dist/gh-tool/read/writes.d.ts +16 -0
  68. package/dist/gh-tool/read/writes.d.ts.map +1 -0
  69. package/dist/gh-tool/release.d.ts +10 -26
  70. package/dist/gh-tool/release.d.ts.map +1 -1
  71. package/dist/gh-tool/repo.d.ts +4 -4
  72. package/dist/gh-tool/repo.d.ts.map +1 -1
  73. package/dist/gh-tool/types.d.ts +1 -1
  74. package/dist/gh-tool/types.d.ts.map +1 -1
  75. package/dist/gh-tool/workflow.d.ts +43 -21
  76. package/dist/gh-tool/workflow.d.ts.map +1 -1
  77. package/dist/observability-tool/shared.d.ts.map +1 -1
  78. package/dist/shared/content-security.d.ts +2 -0
  79. package/dist/shared/content-security.d.ts.map +1 -1
  80. package/package.json +6 -6
  81. package/src/api-tool/proxy-entry.ts +3 -1
  82. package/src/db-tool/service.ts +4 -1
  83. package/src/gh-tool/branch.ts +5 -8
  84. package/src/gh-tool/environment.ts +2 -1
  85. package/src/gh-tool/gist.ts +8 -35
  86. package/src/gh-tool/issue/commands.ts +1 -1
  87. package/src/gh-tool/issue/core.ts +55 -122
  88. package/src/gh-tool/issue/triage.ts +4 -26
  89. package/src/gh-tool/main.ts +5 -2
  90. package/src/gh-tool/pr/commands.ts +17 -15
  91. package/src/gh-tool/pr/core.ts +486 -582
  92. package/src/gh-tool/pr/review.ts +106 -454
  93. package/src/gh-tool/pr/snapshot.ts +23 -0
  94. package/src/gh-tool/pr/stack-read.ts +1 -73
  95. package/src/gh-tool/pr/stack.ts +37 -36
  96. package/src/gh-tool/{api.ts → read/api.ts} +40 -7
  97. package/src/gh-tool/read/cache.ts +192 -0
  98. package/src/gh-tool/read/catalog.ts +310 -0
  99. package/src/gh-tool/read/classes.ts +30 -0
  100. package/src/gh-tool/read/commands.ts +25 -0
  101. package/src/gh-tool/read/gated.ts +56 -0
  102. package/src/gh-tool/read/index.ts +4 -0
  103. package/src/gh-tool/read/issues.ts +107 -0
  104. package/src/gh-tool/read/keys.ts +102 -0
  105. package/src/gh-tool/read/porcelain.ts +58 -0
  106. package/src/gh-tool/read/pulls.ts +266 -0
  107. package/src/gh-tool/read/reads.ts +306 -0
  108. package/src/gh-tool/read/rest.ts +111 -0
  109. package/src/gh-tool/read/review-guards.ts +61 -0
  110. package/src/gh-tool/read/review-model.ts +286 -0
  111. package/src/gh-tool/read/reviews.ts +114 -0
  112. package/src/gh-tool/read/stacks.ts +73 -0
  113. package/src/gh-tool/read/store.ts +504 -0
  114. package/src/gh-tool/{service.ts → read/transport.ts} +26 -6
  115. package/src/gh-tool/read/waits.ts +52 -0
  116. package/src/gh-tool/read/writes.ts +37 -0
  117. package/src/gh-tool/release.ts +15 -168
  118. package/src/gh-tool/repo.ts +5 -78
  119. package/src/gh-tool/types.ts +1 -1
  120. package/src/gh-tool/workflow.ts +123 -214
  121. package/src/observability-tool/shared.ts +2 -1
  122. package/src/shared/content-security.ts +10 -1
  123. package/src/shared/credential-patterns.ts +1 -1
  124. package/dist/gh-tool/api.d.ts.map +0 -1
  125. package/dist/gh-tool/pr/check-status-cache.d.ts +0 -6
  126. package/dist/gh-tool/pr/check-status-cache.d.ts.map +0 -1
  127. package/dist/gh-tool/service.d.ts.map +0 -1
  128. package/src/gh-tool/pr/check-status-cache.ts +0 -142
@@ -1,5 +1,4 @@
1
- import { Clock, Console, Duration, Effect, Option, Result } from "effect";
2
- import { createHash } from "node:crypto";
1
+ import { Clock, Console, Effect, Option, Result } from "effect";
3
2
 
4
3
  import type {
5
4
  BranchPRDetail,
@@ -9,24 +8,19 @@ import type {
9
8
  MergeResult,
10
9
  MergeStrategy,
11
10
  PRInfo,
12
- PRViewInfo,
13
11
  RerunCheckAttempt,
14
12
  RerunChecksReport,
15
13
  RerunChecksRun,
16
14
  RerunRetryEvidence,
17
- WorkflowRunDetail,
18
15
  } from "#gh/types";
19
16
 
20
17
  import type { GitHubAuthError, GitHubNotFoundError } from "#gh/errors";
21
18
  import { GitHubCommandError, GitHubMergeError, GitHubTimeoutError } from "#gh/errors";
22
- import { GitHubService } from "#gh/service";
23
- import { resolveGitHubToken } from "#gh/api";
24
- import type { GhResult } from "#gh/service";
19
+ import { GitHubService } from "#gh/read/transport";
25
20
  import { logFormatted, logText } from "#shared";
26
21
  import { redactSensitiveText } from "#shared/content-security";
27
22
 
28
- import type { ButStatusJson, PRViewJsonResult } from "./helpers";
29
- import { pollUntilResolved } from "#shared/poll-until-resolved";
23
+ import type { ButStatusJson } from "./helpers";
30
24
  import { runLocalCommand } from "./helpers";
31
25
  import { readStack } from "./stack-read";
32
26
  import {
@@ -39,17 +33,19 @@ import {
39
33
  parseRawJobLogs,
40
34
  readJobLogApi,
41
35
  } from "#gh/workflow";
42
- import {
43
- CheckStatusCacheDirectory,
44
- readCheckStatusCache,
45
- writeCheckStatusCache,
46
- } from "./check-status-cache";
36
+ import { reads, resolveRepo } from "#gh/read/reads";
37
+ import { waits } from "#gh/read/waits";
38
+ import { project } from "#gh/read/porcelain";
39
+ import { writes } from "#gh/read/writes";
40
+ import { passthrough } from "#gh/read/catalog";
41
+ import type { AsyncMergeResult } from "#gh/read/catalog";
42
+ import { repoSlug } from "#gh/read/keys";
43
+ import { stableHead as collectWithStableState } from "./snapshot";
44
+ export { stableHead as collectWithStableState } from "./snapshot";
47
45
 
48
- const CHECK_JSON_FIELDS = "name,state,bucket,link";
49
46
  const LONG_LIVED_BRANCHES = new Set(["main", "master", "develop", "staging", "production"]);
50
- const STABLE_SNAPSHOT_ATTEMPTS = 3;
51
47
  const GITHUB_ACTIONS_RUN_ID_RE = /github\.com\/[^/]+\/[^/]+\/actions\/runs\/(\d+)/;
52
- const MERGE_SHA_POLL_INTERVAL_MS = 1000;
48
+ const MERGE_SHA_POLL_INTERVAL_MS = 5000;
53
49
  const MERGE_SHA_WAIT_SECONDS = 5;
54
50
  const isFullCommitSha = (value: unknown): value is string =>
55
51
  typeof value === "string" && value.length === 40 && /^[0-9a-f]+$/i.test(value);
@@ -101,26 +97,20 @@ const resolveMergeSha = Effect.fn("pr.resolveMergeSha")(function* (
101
97
  return knownSha;
102
98
  }
103
99
 
104
- const gh = yield* GitHubService;
105
100
  const readMergeSha = () =>
106
- gh
107
- .runGhJson<{ mergeCommit: { oid: string } | null }>([
108
- "pr",
109
- "view",
110
- String(pr),
111
- "--json",
112
- "mergeCommit",
113
- ])
101
+ reads
102
+ .prView(null, { by: "number", number: pr }, "origin")
114
103
  .pipe(Effect.map((result) => result.mergeCommit?.oid ?? null));
115
104
  const result = yield* readMergeSha().pipe(
116
105
  Effect.andThen((initial) =>
117
- pollUntilResolved({
118
- initial,
119
- isPending: (value) => value === null,
120
- fetchLatest: readMergeSha,
121
- intervalMs: MERGE_SHA_POLL_INTERVAL_MS,
122
- budgetSeconds: MERGE_SHA_WAIT_SECONDS,
123
- }),
106
+ waits
107
+ .until(readMergeSha(), (sha) => sha !== null, {
108
+ initial: { value: initial },
109
+ intervalMs: MERGE_SHA_POLL_INTERVAL_MS,
110
+ timeoutMs: MERGE_SHA_WAIT_SECONDS * 1000,
111
+ readAtDeadline: true,
112
+ })
113
+ .pipe(Effect.map((outcome) => (outcome.kind === "done" ? outcome.value : outcome.last))),
124
114
  ),
125
115
  Effect.map((sha) => ({ sha, failed: false as const })),
126
116
  Effect.catch((error) =>
@@ -218,16 +208,8 @@ const matchFailedJobForCheck = <Job extends { name: string }>(
218
208
  const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureContext")(function* (
219
209
  runId: number,
220
210
  ) {
221
- const gh = yield* GitHubService;
222
-
223
- const run = yield* gh
224
- .runGhJson<WorkflowRunDetail>([
225
- "run",
226
- "view",
227
- String(runId),
228
- "--json",
229
- "databaseId,attempt,headSha,url,workflowName,status,conclusion,jobs",
230
- ])
211
+ const run = yield* reads
212
+ .runView(yield* resolveRepo(null), runId)
231
213
  .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
232
214
 
233
215
  if (run === null) {
@@ -319,52 +301,8 @@ export const fetchCheckReport = Effect.fn("pr.fetchCheckReport")(function* (
319
301
  pr: number | null,
320
302
  cache?: { headSha: string; watchPending?: boolean },
321
303
  ) {
322
- const gh = yield* GitHubService;
323
-
324
- const args = ["pr", "checks"];
325
- if (pr !== null) {
326
- args.push(String(pr));
327
- }
328
-
329
- const directory = yield* CheckStatusCacheDirectory;
330
- const now = yield* Clock.currentTimeMillis;
331
- let repo: string | null = null;
332
- let identity: string | null = null;
333
- if (cache !== undefined && pr !== null && directory !== undefined) {
334
- const repoInfo = yield* gh.getRepoInfo().pipe(Effect.result);
335
- const token = yield* resolveGitHubToken().pipe(Effect.result);
336
- if (Result.isSuccess(repoInfo) && Result.isSuccess(token)) {
337
- const host = yield* Effect.try({
338
- try: () => new URL(repoInfo.success.url).host,
339
- catch: () => "",
340
- }).pipe(Effect.orElseSucceed(() => ""));
341
- if (host.length > 0) {
342
- const cacheRepo = `${host}/${repoInfo.success.owner}/${repoInfo.success.name}`;
343
- const cacheIdentity = createHash("sha256").update(token.success).digest("hex");
344
- const cached = yield* Effect.tryPromise({
345
- try: () =>
346
- readCheckStatusCache(
347
- cacheIdentity,
348
- cacheRepo,
349
- pr,
350
- cache.headSha,
351
- now,
352
- cache.watchPending,
353
- directory,
354
- ),
355
- catch: () => null,
356
- }).pipe(Effect.orElseSucceed(() => null));
357
- if (cached !== null)
358
- return { checksStatus: "reported", checks: cached } satisfies CheckReport;
359
- repo = cacheRepo;
360
- identity = cacheIdentity;
361
- }
362
- }
363
- }
364
-
365
- const fetchedAt = yield* Clock.currentTimeMillis;
366
- const checks = yield* gh
367
- .runGhJson<CheckResult[]>([...args, "--json", CHECK_JSON_FIELDS])
304
+ const checks = yield* reads
305
+ .prChecks(yield* resolveRepo(null), pr, cache?.headSha ?? null)
368
306
  .pipe(
369
307
  Effect.catchTag("GitHubCommandError", (error) =>
370
308
  NO_CHECKS_REPORTED_RE.test(error.stderr) || NO_CHECKS_REPORTED_RE.test(error.message)
@@ -379,19 +317,6 @@ export const fetchCheckReport = Effect.fn("pr.fetchCheckReport")(function* (
379
317
  checks: null,
380
318
  } satisfies CheckReport;
381
319
  }
382
- if (
383
- cache !== undefined &&
384
- pr !== null &&
385
- repo !== null &&
386
- identity !== null &&
387
- checks.length > 0
388
- ) {
389
- yield* Effect.tryPromise({
390
- try: () =>
391
- writeCheckStatusCache(identity, repo, pr, cache.headSha, checks, fetchedAt, directory),
392
- catch: () => undefined,
393
- }).pipe(Effect.orElseSucceed(() => undefined));
394
- }
395
320
  return { checksStatus: "reported", checks } satisfies CheckReport;
396
321
  });
397
322
 
@@ -428,7 +353,7 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
428
353
  runIds,
429
354
  (runId) =>
430
355
  fetchWorkflowRunFailureContext(runId).pipe(Effect.map((found) => [runId, found] as const)),
431
- { concurrency: "unbounded" },
356
+ { concurrency: 2 },
432
357
  ),
433
358
  );
434
359
 
@@ -453,7 +378,7 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
453
378
  const failedStepLogs = yield* fetchJobLogs({
454
379
  runId,
455
380
  job: matchedJob.name,
456
- jobId: matchedJob.databaseId,
381
+ jobRef: { id: matchedJob.databaseId, status: matchedJob.status },
457
382
  failedStepNames: matchedJob.failedSteps,
458
383
  steps: found?.stepsByJob.get(matchedJob.databaseId) ?? [],
459
384
  failedStepsOnly: true,
@@ -557,19 +482,32 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
557
482
  };
558
483
  });
559
484
 
560
- export const fetchPRView = Effect.fn("pr.fetchPRView")(function* (prNumber: number | null) {
561
- const gh = yield* GitHubService;
562
-
563
- const args = ["pr", "view"];
564
- if (prNumber !== null) {
565
- args.push(String(prNumber));
566
- }
567
- args.push(
568
- "--json",
569
- "number,url,title,headRefName,baseRefName,headRefOid,state,isDraft,mergeable,body,author,reviewDecision,reviewRequests",
485
+ export const fetchPRView = Effect.fn("pr.fetchPRView")(function* (
486
+ prNumber: number | null,
487
+ consistency: "cached" | "origin" = "cached",
488
+ ) {
489
+ const raw = yield* reads.prView(
490
+ yield* resolveRepo(null),
491
+ prNumber === null ? { by: "currentBranch" } : { by: "number", number: prNumber },
492
+ consistency,
570
493
  );
571
-
572
- const info = yield* gh.runGhJson<PRViewInfo & { headRefOid?: string }>(args);
494
+ const info = project(raw, [
495
+ "number",
496
+ "url",
497
+ "title",
498
+ "headRefName",
499
+ "baseRefName",
500
+ "headRefOid",
501
+ "state",
502
+ "isDraft",
503
+ "mergeable",
504
+ "body",
505
+ "author",
506
+ "reviewDecision",
507
+ "reviewRequests",
508
+ "headSha",
509
+ "baseSha",
510
+ ]);
573
511
  return {
574
512
  ...info,
575
513
  body: info.body ?? "",
@@ -578,17 +516,15 @@ export const fetchPRView = Effect.fn("pr.fetchPRView")(function* (prNumber: numb
578
516
  };
579
517
  });
580
518
 
581
- export const viewPR = Effect.fn("pr.viewPR")(function* (prNumber: number | null) {
519
+ export const viewPR = Effect.fn("pr.viewPR")(function* (
520
+ prNumber: number | null,
521
+ consistency: "cached" | "origin" = "cached",
522
+ ) {
582
523
  const gh = yield* GitHubService;
583
524
 
584
- const info = yield* fetchPRView(prNumber);
525
+ const info = yield* fetchPRView(prNumber, consistency);
585
526
  const repo = yield* gh.getRepoInfo();
586
- const baseSha = yield* gh.runGh([
587
- "api",
588
- `repos/${repo.owner}/${repo.name}/pulls/${info.number}`,
589
- "--jq",
590
- ".base.sha",
591
- ]);
527
+ const baseSha = yield* passthrough.baseSha(repo, info.number);
592
528
  return {
593
529
  ...info,
594
530
  baseSha: baseSha.stdout.trim() || null,
@@ -648,7 +584,7 @@ export const detectPRStatus = Effect.fn("pr.detectPRStatus")(function* () {
648
584
  ),
649
585
  ];
650
586
 
651
- const gh = yield* GitHubService;
587
+ const repo = yield* resolveRepo(null);
652
588
 
653
589
  type BranchResult = {
654
590
  branch: string;
@@ -665,40 +601,41 @@ export const detectPRStatus = Effect.fn("pr.detectPRStatus")(function* () {
665
601
  branchNames.map((branchName) =>
666
602
  Effect.all(
667
603
  {
668
- openPr: gh
669
- .runGhJson<PRInfo[]>([
670
- "pr",
671
- "list",
672
- "--head",
673
- branchName,
674
- "--json",
675
- "number,url,title,headRefName,baseRefName,state,isDraft,mergeable",
676
- "--limit",
677
- "1",
678
- ])
604
+ openPr: reads
605
+ .prList(repo, {
606
+ state: "open",
607
+ limit: 1,
608
+ author: null,
609
+ base: null,
610
+ head: branchName,
611
+ search: null,
612
+ })
679
613
  .pipe(
680
- Effect.map((prs) => prs[0] ?? null),
614
+ Effect.map((prs) =>
615
+ prs[0] === undefined
616
+ ? null
617
+ : project(prs[0], [
618
+ "number",
619
+ "url",
620
+ "title",
621
+ "headRefName",
622
+ "baseRefName",
623
+ "state",
624
+ "isDraft",
625
+ "mergeable",
626
+ ]),
627
+ ),
681
628
  Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)),
682
629
  ),
683
- closedPr: gh
684
- .runGhJson<
685
- Array<{
686
- number: number;
687
- url: string;
688
- state: string;
689
- }>
690
- >([
691
- "pr",
692
- "list",
693
- "--head",
694
- branchName,
695
- "--state",
696
- "closed",
697
- "--json",
698
- "number,url,state",
699
- "--limit",
700
- "1",
701
- ])
630
+ closedPr: reads
631
+ .prList(repo, {
632
+ state: "closed",
633
+ limit: 1,
634
+ author: null,
635
+ base: null,
636
+ head: branchName,
637
+ search: null,
638
+ })
702
639
  .pipe(
703
640
  Effect.map((prs) => {
704
641
  const pr = prs[0];
@@ -716,7 +653,7 @@ export const detectPRStatus = Effect.fn("pr.detectPRStatus")(function* () {
716
653
  Effect.catch(() => Effect.succeed(false)),
717
654
  ),
718
655
  },
719
- { concurrency: "unbounded" },
656
+ { concurrency: 2 },
720
657
  ).pipe(
721
658
  Effect.map((r): BranchResult => ({
722
659
  branch: branchName,
@@ -724,7 +661,7 @@ export const detectPRStatus = Effect.fn("pr.detectPRStatus")(function* () {
724
661
  })),
725
662
  ),
726
663
  ),
727
- { concurrency: "unbounded" },
664
+ { concurrency: 2 },
728
665
  );
729
666
 
730
667
  const foundPrs = branchResults.flatMap((r) => (r.openPr === null ? [] : [r.openPr]));
@@ -762,59 +699,44 @@ export const listPRs = Effect.fn("pr.listPRs")(function* (opts: {
762
699
  head: string | null;
763
700
  search: string | null;
764
701
  }) {
765
- const gh = yield* GitHubService;
766
- const args = [
767
- "pr",
768
- "list",
769
- "--state",
770
- opts.state,
771
- "--limit",
772
- String(opts.limit),
773
- "--json",
774
- "number,url,title,headRefName,baseRefName,state,isDraft,author,createdAt,reviewDecision",
775
- ];
776
- if (opts.author !== null) args.push("--author", opts.author);
777
- if (opts.base !== null) args.push("--base", opts.base);
778
- if (opts.head !== null) args.push("--head", opts.head);
779
- if (opts.search !== null) args.push("--search", opts.search);
780
- return yield* gh.runGhJson<PRInfo[]>(args);
702
+ const values = yield* reads.prList(yield* resolveRepo(null), opts);
703
+ return values.map((value) =>
704
+ project(value, [
705
+ "number",
706
+ "url",
707
+ "title",
708
+ "headRefName",
709
+ "baseRefName",
710
+ "state",
711
+ "isDraft",
712
+ "author",
713
+ "createdAt",
714
+ "reviewDecision",
715
+ ]),
716
+ );
781
717
  });
782
718
 
783
719
  // GitHub recomputes mergeability asynchronously after CI; poll until it settles out of "UNKNOWN".
784
720
  const MAX_MERGEABLE_WAIT_SECONDS = 180;
785
- const MERGEABLE_POLL_INTERVAL_MS = 3000;
721
+ const MERGEABLE_POLL_INTERVAL_MS = 5000;
786
722
 
787
723
  export const waitForMergeable = Effect.fn("pr.waitForMergeable")(function* (
788
724
  pr: number | null,
789
725
  timeoutSeconds: number,
790
726
  ) {
791
727
  const cappedSeconds = Math.min(timeoutSeconds, MAX_MERGEABLE_WAIT_SECONDS);
792
- const start = yield* Clock.currentTimeMillis;
793
- const deadlineMs = Number(start) + cappedSeconds * 1000;
794
-
795
- // Effect.whileLoop (not recursion) so TestClock.adjust can advance Effect.sleep without real waits.
796
- let latest = yield* viewPR(pr);
797
- let timedOut = false;
798
- yield* Effect.whileLoop({
799
- while: () => latest.mergeable === "UNKNOWN" && !timedOut,
800
- body: () =>
801
- Effect.gen(function* () {
802
- const now = yield* Clock.currentTimeMillis;
803
- if (Number(now) >= deadlineMs) {
804
- timedOut = true;
805
- return;
806
- }
807
- // Cap the sleep to the remaining budget so the total wait doesn't overshoot the deadline.
808
- const remaining = deadlineMs - Number(now);
809
- yield* Effect.sleep(Duration.millis(Math.min(MERGEABLE_POLL_INTERVAL_MS, remaining)));
810
- latest = yield* fetchPRView(pr).pipe(
811
- Effect.map((info) => ({ ...info, baseSha: latest.baseSha })),
812
- );
813
- }),
814
- step: () => undefined,
815
- });
816
-
817
- return latest;
728
+ const initial = yield* viewPR(pr, "origin");
729
+ const outcome = yield* waits.until(
730
+ fetchPRView(pr, "origin").pipe(Effect.map((info) => ({ ...info, baseSha: initial.baseSha }))),
731
+ (info) => info.mergeable !== "UNKNOWN",
732
+ {
733
+ initial: { value: initial },
734
+ intervalMs: MERGEABLE_POLL_INTERVAL_MS,
735
+ timeoutMs: cappedSeconds * 1000,
736
+ readAtDeadline: true,
737
+ },
738
+ );
739
+ return outcome.kind === "done" ? outcome.value : outcome.last;
818
740
  });
819
741
 
820
742
  export const createPR = Effect.fn("pr.createPR")(function* (opts: {
@@ -839,24 +761,12 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
839
761
  headSeparator === -1 ? repo.owner : head.slice(0, headSeparator)
840
762
  ).toLowerCase();
841
763
  const headBranch = headSeparator === -1 ? head : head.slice(headSeparator + 1);
842
- const lookupArgs = [
843
- "pr",
844
- "list",
845
- "--repo",
846
- `${repo.owner}/${repo.name}`,
847
- "--state",
848
- "open",
849
- "--head",
850
- headBranch,
851
- "--base",
852
- baseBranch,
853
- "--json",
854
- "number,url,title,headRefName,baseRefName,state,isDraft,mergeable,headRepositoryOwner",
855
- "--limit",
856
- "2",
857
- ];
858
- type Candidate = PRInfo & { headRepositoryOwner?: { login: string } };
859
- const candidates = yield* gh.runGhJson<Candidate[]>(lookupArgs);
764
+ const lookup = reads.prList(
765
+ repo,
766
+ { state: "open", head: headBranch, base: baseBranch, limit: 2, author: null, search: null },
767
+ "origin",
768
+ );
769
+ const candidates = yield* lookup;
860
770
  // The limited query may include another fork's branch. Refuse a saturated result
861
771
  // rather than assuming an owner-filtered match beyond the limit does not exist.
862
772
  if (candidates.length > 1) {
@@ -910,7 +820,7 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
910
820
  hint: "Use --update-if-exists to update it, or 'pr edit --pr <number>'.",
911
821
  });
912
822
  }
913
- const current = yield* fetchPRView(existing.number);
823
+ const current = yield* fetchPRView(existing.number, "origin");
914
824
  if (
915
825
  current.state !== "OPEN" ||
916
826
  current.headRefName !== headBranch ||
@@ -952,14 +862,14 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
952
862
  createArgs.push("--draft");
953
863
  }
954
864
 
955
- const createResult = yield* gh.runGh(createArgs);
865
+ const createResult = yield* writes.run(createArgs, [`repo:${repoSlug(repo)}`]);
956
866
 
957
867
  const urlMatch = createResult.stdout.match(/\/pull\/(\d+)/);
958
868
  if (urlMatch?.[1]) {
959
869
  return { ...(yield* viewPR(Number(urlMatch[1]))), updated: false };
960
870
  }
961
871
 
962
- const prs = yield* gh.runGhJson<Candidate[]>(lookupArgs);
872
+ const prs = yield* lookup;
963
873
  if (
964
874
  prs.length === 1 &&
965
875
  prs[0]?.state === "OPEN" &&
@@ -983,14 +893,9 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
983
893
  // GitHub rejects GraphQL `mergePullRequest` for a PR that belongs to a native stack and points at
984
894
  // the asynchronous REST merge instead. `gh pr merge` only speaks GraphQL.
985
895
  const ASYNC_MERGE_REQUIRED_RE = /asynchronous merge/i;
986
- const ASYNC_MERGE_POLL_INTERVAL_MS = 2000;
896
+ const ASYNC_MERGE_POLL_INTERVAL_MS = 5000;
987
897
  const MAX_ASYNC_MERGE_WAIT_SECONDS = 120;
988
898
 
989
- type AsyncMergeResult = {
990
- status: "pending" | "merged" | "enqueued" | "failed";
991
- details?: { message?: string; uuid?: string; sha?: string };
992
- };
993
-
994
899
  const asyncMergeFailure = (
995
900
  pr: number,
996
901
  message: string,
@@ -1012,24 +917,24 @@ const mergeViaAsyncApi = Effect.fn("pr.mergeViaAsyncApi")(function* (opts: {
1012
917
  const repo = yield* gh.getRepoInfo();
1013
918
  const asyncPath = `repos/${repo.owner}/${repo.name}/pulls/${opts.pr}/merge-async`;
1014
919
 
1015
- let latest = yield* gh.runGhJson<AsyncMergeResult>([
1016
- "api",
1017
- "--method",
1018
- "PUT",
1019
- asyncPath,
1020
- "-f",
1021
- `merge_method=${opts.strategy}`,
1022
- ]);
920
+ let latest = yield* writes.json<AsyncMergeResult>(
921
+ ["api", "--method", "PUT", asyncPath, "-f", `merge_method=${opts.strategy}`],
922
+ [`pr:${repoSlug(repo)}#${opts.pr}`, `pulls:${repoSlug(repo)}`],
923
+ );
1023
924
 
1024
925
  const uuid = latest.details?.uuid;
1025
926
  if (uuid !== undefined) {
1026
- latest = yield* pollUntilResolved({
1027
- initial: latest,
1028
- isPending: (value) => value.status === "pending",
1029
- fetchLatest: () => gh.runGhJson<AsyncMergeResult>(["api", `${asyncPath}/${uuid}`]),
1030
- intervalMs: ASYNC_MERGE_POLL_INTERVAL_MS,
1031
- budgetSeconds: MAX_ASYNC_MERGE_WAIT_SECONDS,
1032
- });
927
+ const outcome = yield* waits.until(
928
+ passthrough.asyncMergeStatus(`${asyncPath}/${uuid}`),
929
+ (value) => value.status !== "pending",
930
+ {
931
+ initial: { value: latest },
932
+ intervalMs: ASYNC_MERGE_POLL_INTERVAL_MS,
933
+ timeoutMs: MAX_ASYNC_MERGE_WAIT_SECONDS * 1000,
934
+ readAtDeadline: true,
935
+ },
936
+ );
937
+ latest = outcome.kind === "done" ? outcome.value : outcome.last;
1033
938
  }
1034
939
 
1035
940
  if (latest.status === "merged") {
@@ -1079,27 +984,18 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1079
984
  });
1080
985
  }
1081
986
 
1082
- const info = yield* gh
1083
- .runGhJson<PRViewJsonResult & { headRefOid?: unknown }>([
1084
- "pr",
1085
- "view",
1086
- String(opts.pr),
1087
- "--json",
1088
- "number,url,title,headRefName,baseRefName,state,isDraft,mergeable" +
1089
- (matchHeadCommit !== undefined ? ",headRefOid" : ""),
1090
- ])
1091
- .pipe(
1092
- Effect.mapError((error) =>
1093
- matchHeadCommit === undefined
1094
- ? error
1095
- : new GitHubMergeError({
1096
- message: `Could not verify the head commit of PR #${opts.pr}: ${error.message}`,
1097
- reason: "unknown",
1098
- hint: "The merge is refused while the head is unknown. Re-read the PR and verify its reviewed head before retrying.",
1099
- nextCommand: `agent-tools-gh pr view --pr ${opts.pr}`,
1100
- }),
1101
- ),
1102
- );
987
+ const info = yield* reads.prView(null, { by: "number", number: opts.pr }, "origin").pipe(
988
+ Effect.mapError((error) =>
989
+ matchHeadCommit === undefined
990
+ ? error
991
+ : new GitHubMergeError({
992
+ message: `Could not verify the head commit of PR #${opts.pr}: ${error.message}`,
993
+ reason: "unknown",
994
+ hint: "The merge is refused while the head is unknown. Re-read the PR and verify its reviewed head before retrying.",
995
+ nextCommand: `agent-tools-gh pr view --pr ${opts.pr}`,
996
+ }),
997
+ ),
998
+ );
1103
999
 
1104
1000
  if (
1105
1001
  matchHeadCommit !== undefined &&
@@ -1181,18 +1077,18 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1181
1077
  // branch if EVERY retarget succeeds (fail-closed).
1182
1078
  const dependentOpenPrs =
1183
1079
  opts.deleteBranch && !headIsLongLived && info.headRefName
1184
- ? yield* gh.runGhJson<Array<{ number: number; headRefName: string; baseRefName: string }>>([
1185
- "pr",
1186
- "list",
1187
- "--base",
1188
- info.headRefName,
1189
- "--state",
1190
- "open",
1191
- "--limit",
1192
- "100",
1193
- "--json",
1194
- "number,headRefName,baseRefName",
1195
- ])
1080
+ ? yield* reads.prList(
1081
+ yield* resolveRepo(null),
1082
+ {
1083
+ state: "open",
1084
+ limit: 100,
1085
+ base: info.headRefName,
1086
+ head: null,
1087
+ author: null,
1088
+ search: null,
1089
+ },
1090
+ "origin",
1091
+ )
1196
1092
  : [];
1197
1093
 
1198
1094
  if (!opts.confirm) {
@@ -1242,15 +1138,18 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1242
1138
  // closed.
1243
1139
  if (willDeleteBranch && dependentOpenPrs.length > 0 && repo) {
1244
1140
  for (const child of dependentOpenPrs) {
1245
- const retargeted = yield* gh
1246
- .runGh([
1247
- "api",
1248
- "--method",
1249
- "PATCH",
1250
- `repos/${repo.owner}/${repo.name}/pulls/${child.number}`,
1251
- "-f",
1252
- `base=${info.baseRefName}`,
1253
- ])
1141
+ const retargeted = yield* writes
1142
+ .run(
1143
+ [
1144
+ "api",
1145
+ "--method",
1146
+ "PATCH",
1147
+ `repos/${repo.owner}/${repo.name}/pulls/${child.number}`,
1148
+ "-f",
1149
+ `base=${info.baseRefName}`,
1150
+ ],
1151
+ [`pr:${repoSlug(repo)}#${child.number}`, `pulls:${repoSlug(repo)}`],
1152
+ )
1254
1153
  .pipe(
1255
1154
  Effect.as(true),
1256
1155
  Effect.orElseSucceed(() => false),
@@ -1272,15 +1171,18 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1272
1171
  return failed;
1273
1172
  }
1274
1173
  for (const child of retargetedChildren) {
1275
- const rolledBack = yield* gh
1276
- .runGh([
1277
- "api",
1278
- "--method",
1279
- "PATCH",
1280
- `repos/${repo.owner}/${repo.name}/pulls/${child}`,
1281
- "-f",
1282
- `base=${info.headRefName}`,
1283
- ])
1174
+ const rolledBack = yield* writes
1175
+ .run(
1176
+ [
1177
+ "api",
1178
+ "--method",
1179
+ "PATCH",
1180
+ `repos/${repo.owner}/${repo.name}/pulls/${child}`,
1181
+ "-f",
1182
+ `base=${info.headRefName}`,
1183
+ ],
1184
+ [`pr:${repoSlug(repo)}#${child}`, `pulls:${repoSlug(repo)}`],
1185
+ )
1284
1186
  .pipe(
1285
1187
  Effect.as(true),
1286
1188
  Effect.orElseSucceed(() => false),
@@ -1292,13 +1194,14 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1292
1194
  return failed;
1293
1195
  });
1294
1196
 
1197
+ const mergeRepo = yield* resolveRepo(null);
1295
1198
  const mergeArgs = ["pr", "merge", String(opts.pr), `--${opts.strategy}`];
1296
1199
  if (matchHeadCommit !== undefined) {
1297
1200
  mergeArgs.push("--match-head-commit", matchHeadCommit);
1298
1201
  }
1299
1202
 
1300
- const shaFromAsyncMerge = yield* gh
1301
- .runGh(mergeArgs)
1203
+ const shaFromAsyncMerge = yield* writes
1204
+ .run(mergeArgs, [`pr:${repoSlug(mergeRepo)}#${opts.pr}`, `pulls:${repoSlug(mergeRepo)}`])
1302
1205
  .pipe(
1303
1206
  Effect.map(() => null),
1304
1207
  Effect.catchTag("GitHubCommandError", (error) =>
@@ -1400,13 +1303,16 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1400
1303
  // `gh pr merge --delete-branch` aborts its remote delete when the head branch is checked out in a
1401
1304
  // worktree; deleting the remote ref explicitly is worktree-independent. Local cleanup is separate.
1402
1305
  if (willDeleteBranch && repo && info.headRefName) {
1403
- const remoteDeleted = yield* gh
1404
- .runGh([
1405
- "api",
1406
- "--method",
1407
- "DELETE",
1408
- `repos/${repo.owner}/${repo.name}/git/refs/heads/${info.headRefName}`,
1409
- ])
1306
+ const remoteDeleted = yield* writes
1307
+ .run(
1308
+ [
1309
+ "api",
1310
+ "--method",
1311
+ "DELETE",
1312
+ `repos/${repo.owner}/${repo.name}/git/refs/heads/${info.headRefName}`,
1313
+ ],
1314
+ [`pr:${repoSlug(repo)}#${opts.pr}`, `pulls:${repoSlug(repo)}`],
1315
+ )
1410
1316
  .pipe(
1411
1317
  Effect.as(true),
1412
1318
  Effect.orElseSucceed(() => false),
@@ -1434,8 +1340,6 @@ export const closePR = Effect.fn("pr.closePR")(function* (opts: {
1434
1340
  comment: string | null;
1435
1341
  deleteBranch: boolean;
1436
1342
  }) {
1437
- const gh = yield* GitHubService;
1438
-
1439
1343
  const args = ["pr", "close", String(opts.pr)];
1440
1344
 
1441
1345
  if (opts.comment !== null) {
@@ -1446,15 +1350,18 @@ export const closePR = Effect.fn("pr.closePR")(function* (opts: {
1446
1350
  args.push("--delete-branch");
1447
1351
  }
1448
1352
 
1449
- yield* gh.runGh(args);
1353
+ const repo = yield* resolveRepo(null);
1354
+ yield* writes.run(args, [
1355
+ `pr:${repoSlug(repo)}#${opts.pr}`,
1356
+ `pulls:${repoSlug(repo)}`,
1357
+ `issue-comments:${repoSlug(repo)}#${opts.pr}`,
1358
+ ]);
1450
1359
 
1451
1360
  return yield* viewPR(opts.pr);
1452
1361
  });
1453
1362
 
1454
1363
  export const readyPR = Effect.fn("pr.readyPR")(function* (opts: { pr: number | null }) {
1455
- const gh = yield* GitHubService;
1456
-
1457
- const info = yield* viewPR(opts.pr);
1364
+ const info = yield* viewPR(opts.pr, "origin");
1458
1365
 
1459
1366
  // Idempotent: a PR that's already ready needs no mutation, and `gh pr ready` on a
1460
1367
  // non-draft PR is a no-op that would just add a pointless call.
@@ -1462,22 +1369,28 @@ export const readyPR = Effect.fn("pr.readyPR")(function* (opts: { pr: number | n
1462
1369
  return { ...info, wasAlreadyReady: true };
1463
1370
  }
1464
1371
 
1465
- yield* gh.runGh(["pr", "ready", String(info.number)]);
1372
+ const repo = yield* resolveRepo(null);
1373
+ yield* writes.run(
1374
+ ["pr", "ready", String(info.number)],
1375
+ [`pr:${repoSlug(repo)}#${info.number}`, `pulls:${repoSlug(repo)}`],
1376
+ );
1466
1377
 
1467
1378
  const updated = yield* viewPR(info.number);
1468
1379
  return { ...updated, wasAlreadyReady: false };
1469
1380
  });
1470
1381
 
1471
1382
  export const draftPR = Effect.fn("pr.draftPR")(function* (opts: { pr: number | null }) {
1472
- const gh = yield* GitHubService;
1473
-
1474
- const info = yield* viewPR(opts.pr);
1383
+ const info = yield* viewPR(opts.pr, "origin");
1475
1384
 
1476
1385
  if (info.isDraft) {
1477
1386
  return { ...info, wasAlreadyDraft: true };
1478
1387
  }
1479
1388
 
1480
- yield* gh.runGh(["pr", "ready", String(info.number), "--undo"]);
1389
+ const repo = yield* resolveRepo(null);
1390
+ yield* writes.run(
1391
+ ["pr", "ready", String(info.number), "--undo"],
1392
+ [`pr:${repoSlug(repo)}#${info.number}`, `pulls:${repoSlug(repo)}`],
1393
+ );
1481
1394
 
1482
1395
  const updated = yield* viewPR(info.number);
1483
1396
  return { ...updated, wasAlreadyDraft: false };
@@ -1540,7 +1453,7 @@ export const editPR = Effect.fn("pr.editPR")(function* (opts: {
1540
1453
  editArgs.push("-f", `base=${opts.base}`);
1541
1454
  }
1542
1455
 
1543
- yield* gh.runGh(editArgs);
1456
+ yield* writes.run(editArgs, [`pr:${repoSlug(repo)}#${opts.pr}`, `pulls:${repoSlug(repo)}`]);
1544
1457
 
1545
1458
  return yield* viewPR(opts.pr);
1546
1459
  });
@@ -1552,62 +1465,44 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
1552
1465
  timeoutSeconds: number,
1553
1466
  quiet = false,
1554
1467
  ) {
1555
- const gh = yield* GitHubService;
1556
-
1557
- const args = ["pr", "checks"];
1558
- if (pr !== null) {
1559
- args.push(String(pr));
1560
- }
1561
-
1562
1468
  if (watch) {
1563
- const watchArgs = [...args, "--watch"];
1564
- if (failFast) {
1565
- watchArgs.push("--fail-fast");
1566
- }
1567
-
1568
- // A push can register checks after the ref lands. Wait through that window
1569
- // within the caller's timeout, then report the final snapshot.
1570
- let watchResult: GhResult | null = null;
1571
- let registered = false;
1572
- let graceExpired = false;
1573
- const watchThroughRegistration = Effect.gen(function* () {
1574
- const graceStart = yield* Clock.currentTimeMillis;
1575
- const graceDeadlineMs =
1576
- Number(graceStart) + Math.min(CHECK_REGISTRATION_GRACE_SECONDS, timeoutSeconds) * 1000;
1577
- yield* Effect.whileLoop({
1578
- while: () => !registered && !graceExpired,
1579
- body: () =>
1580
- gh.runGh(watchArgs).pipe(
1581
- Effect.flatMap((result) => {
1582
- watchResult = result;
1583
- registered = true;
1584
- return Effect.void;
1585
- }),
1586
- Effect.catchTag("GitHubCommandError", (error) =>
1587
- NO_CHECKS_REPORTED_RE.test(error.stderr) || NO_CHECKS_REPORTED_RE.test(error.message)
1588
- ? Effect.gen(function* () {
1589
- const now = yield* Clock.currentTimeMillis;
1590
- if (Number(now) >= graceDeadlineMs) {
1591
- graceExpired = true;
1592
- return;
1593
- }
1594
- yield* Effect.sleep(Duration.seconds(CHECK_REGISTRATION_POLL_SECONDS));
1595
- })
1596
- : Effect.fail(error),
1597
- ),
1598
- ),
1599
- step: () => undefined,
1600
- });
1601
- return watchResult;
1469
+ const started = Number(yield* Clock.currentTimeMillis);
1470
+ const graceDeadlineMs =
1471
+ started + Math.min(CHECK_REGISTRATION_GRACE_SECONDS, timeoutSeconds) * 1000;
1472
+ const poll = Effect.gen(function* () {
1473
+ const checks = yield* fetchCheckResults(pr);
1474
+ return { checks, at: Number(yield* Clock.currentTimeMillis) };
1602
1475
  });
1603
-
1604
- const watchOutcome = yield* watchThroughRegistration.pipe(
1605
- Effect.timeoutOrElse({
1606
- duration: timeoutSeconds * 1000,
1607
- orElse: () => Effect.succeed(null),
1608
- }),
1609
- );
1610
-
1476
+ const watchOutcome = yield* waits
1477
+ .until(
1478
+ poll,
1479
+ ({ checks, at }) =>
1480
+ checks.length === 0
1481
+ ? at >= graceDeadlineMs
1482
+ : (failFast && checks.some((check) => check.bucket === "fail")) ||
1483
+ checks.every((check) => check.bucket !== "pending"),
1484
+ { intervalMs: CHECK_REGISTRATION_POLL_SECONDS * 1000, timeoutMs: timeoutSeconds * 1000 },
1485
+ )
1486
+ .pipe(
1487
+ Effect.timeoutOrElse({
1488
+ duration: timeoutSeconds * 1000,
1489
+ orElse: () => Effect.succeed(null),
1490
+ }),
1491
+ );
1492
+ const last =
1493
+ watchOutcome === null
1494
+ ? null
1495
+ : watchOutcome.kind === "done"
1496
+ ? watchOutcome.value
1497
+ : watchOutcome.last;
1498
+ const graceExpired = last !== null && last.checks.length === 0 && last.at >= graceDeadlineMs;
1499
+ if (watchOutcome?.kind === "done" && last?.checks.some((check) => check.bucket === "fail"))
1500
+ return yield* new GitHubCommandError({
1501
+ command: buildChecksCommand(pr, true),
1502
+ exitCode: 1,
1503
+ message: "",
1504
+ stderr: "",
1505
+ });
1611
1506
  const results = yield* fetchCheckResults(pr);
1612
1507
  if (!quiet && graceExpired) {
1613
1508
  const prRef = pr === null ? "<number>" : String(pr);
@@ -1618,7 +1513,10 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
1618
1513
  `head instead:\n agent-tools-gh pr trigger-checks --pr ${prRef} --workflow <file.yml>`,
1619
1514
  );
1620
1515
  }
1621
- if (watchOutcome === null && results.some((check) => check.bucket === "pending")) {
1516
+ if (
1517
+ (watchOutcome === null || watchOutcome.kind === "timedOut") &&
1518
+ results.some((check) => check.bucket === "pending")
1519
+ ) {
1622
1520
  const pending = results.filter((check) => check.bucket === "pending").length;
1623
1521
  yield* logFormatted(results, quiet ? "json" : "toon");
1624
1522
  return yield* new GitHubTimeoutError({
@@ -1702,23 +1600,6 @@ export const triggerChecks = Effect.fn("pr.triggerChecks")(function* (
1702
1600
  };
1703
1601
  });
1704
1602
 
1705
- export const collectWithStableState = <S, A, E1, R1, E2, R2>(
1706
- initial: S,
1707
- collect: (state: S) => Effect.Effect<A, E1, R1>,
1708
- refresh: (state: S) => Effect.Effect<S, E2, R2>,
1709
- unchanged: (before: S, after: S) => boolean,
1710
- ): Effect.Effect<{ state: S; value: A } | null, E1 | E2, R1 | R2> =>
1711
- Effect.gen(function* () {
1712
- let before = initial;
1713
- for (let attempt = 0; attempt < STABLE_SNAPSHOT_ATTEMPTS; attempt += 1) {
1714
- const value = yield* collect(before);
1715
- const after = yield* refresh(before);
1716
- if (unchanged(before, after)) return { state: after, value };
1717
- before = after;
1718
- }
1719
- return null;
1720
- });
1721
-
1722
1603
  export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (
1723
1604
  pr: number | null,
1724
1605
  withLogs = false,
@@ -1729,7 +1610,7 @@ export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (
1729
1610
  initial,
1730
1611
  (info) => fetchCheckResults(info.number),
1731
1612
  (info) =>
1732
- fetchPRView(info.number).pipe(
1613
+ fetchPRView(info.number, "origin").pipe(
1733
1614
  Effect.map((refreshed) => ({ ...refreshed, baseSha: initial.baseSha })),
1734
1615
  ),
1735
1616
  (before, after) => after.headSha === before.headSha,
@@ -1786,23 +1667,25 @@ export const fetchChecksForCommand = Effect.fn("pr.fetchChecksForCommand")(funct
1786
1667
  return yield* Effect.fail(watchedChecks.failure);
1787
1668
  });
1788
1669
 
1789
- type WatchPR = { number: number; state: string; headRefOid?: string | null };
1790
1670
  type WatchRun = {
1791
1671
  databaseId: number;
1792
1672
  attempt?: number | null;
1793
1673
  headSha?: string | null;
1794
- jobs?: Array<{ databaseId: number; name: string }>;
1674
+ jobs?: ReadonlyArray<{ databaseId: number; name: string }>;
1675
+ };
1676
+
1677
+ const checkJobId = (link: string): number | null => {
1678
+ const modern = link.match(/github\.com\/[^/]+\/[^/]+\/actions\/runs\/\d+\/job\/(\d+)/);
1679
+ const legacy = link.match(/github\.com\/[^/]+\/[^/]+\/runs\/(\d+)/);
1680
+ const id = Number(modern?.[1] ?? legacy?.[1]);
1681
+ return Number.isSafeInteger(id) && id > 0 ? id : null;
1795
1682
  };
1796
1683
 
1797
1684
  const watchPRState = Effect.fn("pr.watchPRState")(function* (pr: number) {
1798
- const gh = yield* GitHubService;
1799
- return yield* gh.runGhJson<WatchPR>([
1800
- "pr",
1801
- "view",
1802
- String(pr),
1803
- "--json",
1804
- "number,state,headRefOid",
1805
- ]);
1685
+ return project(
1686
+ yield* reads.prView(yield* resolveRepo(null), { by: "number", number: pr }, "origin"),
1687
+ ["number", "state", "headRefOid"],
1688
+ );
1806
1689
  });
1807
1690
 
1808
1691
  export const fetchChecksBatch = Effect.fn("pr.fetchChecksBatch")(function* (
@@ -1895,159 +1778,184 @@ export const watchPRs = Effect.fn("pr.watchPRs")(function* (
1895
1778
  const { state: after, value: checks } = stableResult.success;
1896
1779
  const head = after.headRefOid ?? null;
1897
1780
  const runs = new Map<number, WatchRun | null>();
1781
+ const identities = new Map<number, { runId: number; attempt: number } | null>();
1898
1782
  yield* Effect.forEach(
1899
- [...new Set(checks.map((check) => extractRunIdFromCheckLink(check.link)))].filter(
1900
- (runId): runId is number => runId !== null,
1783
+ [...new Set(checks.map((check) => checkJobId(check.link)))].filter(
1784
+ (id): id is number => id !== null,
1901
1785
  ),
1786
+ (id) =>
1787
+ Effect.gen(function* () {
1788
+ if (!(yield* beforeDeadline)) return;
1789
+ identities.set(
1790
+ id,
1791
+ yield* reads
1792
+ .jobIdentity(repo, id)
1793
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null))),
1794
+ );
1795
+ }),
1796
+ { concurrency: 2 },
1797
+ );
1798
+ yield* Effect.forEach(
1799
+ [
1800
+ ...new Set(
1801
+ checks
1802
+ .filter((check) => checkJobId(check.link) === null)
1803
+ .map((check) => extractRunIdFromCheckLink(check.link)),
1804
+ ),
1805
+ ].filter((runId): runId is number => runId !== null),
1902
1806
  (runId) =>
1903
1807
  Effect.gen(function* () {
1904
1808
  if (!(yield* beforeDeadline)) return;
1905
- const run = yield* gh
1906
- .runGhJson<WatchRun>([
1907
- "run",
1908
- "view",
1909
- String(runId),
1910
- "--json",
1911
- "databaseId,attempt,headSha,jobs",
1912
- ])
1809
+ const run = yield* reads
1810
+ .runView(repo, runId)
1913
1811
  .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
1914
1812
  runs.set(runId, run);
1915
1813
  }),
1916
- { concurrency: 5 },
1814
+ { concurrency: 2 },
1917
1815
  );
1918
1816
  if (!(yield* beforeDeadline)) return "timeout" as const;
1919
1817
  return {
1920
1818
  pr: after,
1921
1819
  head,
1922
1820
  checks: checks
1923
- .map((check) => ({
1924
- check,
1925
- run:
1926
- extractRunIdFromCheckLink(check.link) === null
1927
- ? null
1928
- : (runs.get(extractRunIdFromCheckLink(check.link) ?? -1) ?? null),
1929
- }))
1821
+ .map((check) => {
1822
+ const jobId = checkJobId(check.link);
1823
+ const identity = jobId === null ? null : identities.get(jobId);
1824
+ const runId = extractRunIdFromCheckLink(check.link);
1825
+ const resolvedRunId = runId ?? identity?.runId ?? null;
1826
+ const run: WatchRun | null =
1827
+ jobId === null
1828
+ ? runId === null
1829
+ ? null
1830
+ : (runs.get(runId) ?? null)
1831
+ : resolvedRunId === null
1832
+ ? null
1833
+ : {
1834
+ databaseId: resolvedRunId,
1835
+ attempt: identity?.attempt ?? null,
1836
+ headSha: head,
1837
+ jobs: [{ databaseId: jobId, name: check.name }],
1838
+ };
1839
+ return { check, run };
1840
+ })
1930
1841
  .filter(({ run }) => run === null || run.headSha === null || run.headSha === head),
1931
1842
  };
1932
1843
  });
1933
1844
 
1934
1845
  let timedOut = false;
1935
- yield* Effect.whileLoop({
1936
- while: () => terminal.size < prs.length && !timedOut,
1937
- body: () =>
1938
- Effect.gen(function* () {
1939
- for (const number of prs) {
1940
- if (terminal.has(number)) continue;
1941
- if (!(yield* beforeDeadline)) {
1942
- timedOut = true;
1943
- break;
1944
- }
1945
- const remainingMs = Math.max(1, deadline - Number(yield* Clock.currentTimeMillis));
1946
- const state = yield* snapshot(number).pipe(
1947
- Effect.timeoutOrElse({
1948
- duration: remainingMs,
1949
- orElse: () => Effect.succeed("timeout" as const),
1950
- }),
1951
- );
1952
- if (state === "timeout") {
1953
- timedOut = true;
1954
- break;
1955
- }
1956
- if (state === null) continue;
1957
- const headKey = `${number}/${state.head ?? ""}`;
1958
- const oldHeadKey = headByPR.get(number);
1959
- if (oldHeadKey !== undefined && oldHeadKey !== headKey) {
1960
- checksObserved.delete(oldHeadKey);
1961
- emptySnapshots.delete(oldHeadKey);
1962
- }
1963
- headByPR.set(number, headKey);
1964
- if (state.checks.length > 0) {
1965
- checksObserved.add(headKey);
1966
- emptySnapshots.set(headKey, 0);
1967
- } else {
1968
- emptySnapshots.set(headKey, (emptySnapshots.get(headKey) ?? 0) + 1);
1969
- }
1970
- for (const { check, run } of state.checks) {
1971
- const matches = failedJobsMatchingCheck(check.name, run?.jobs ?? []);
1972
- const jobId = matches.length === 1 ? (matches[0]?.databaseId ?? null) : null;
1973
- const fallback = jobId === null ? `${check.name}|${check.link}` : String(jobId);
1974
- const identity = [
1975
- repo.owner + "/" + repo.name,
1976
- number,
1977
- state.head ?? "",
1978
- run?.databaseId ?? "external",
1979
- run?.attempt ?? "",
1980
- fallback,
1981
- ].join("/");
1982
- const logical = `${number}/${check.name}`;
1983
- const previousIdentity = currentIdentity.get(logical);
1984
- const revision = `${check.state}/${check.bucket}`;
1985
- if (lastRevision.get(identity) !== revision) {
1986
- const event: Record<string, unknown> = {
1987
- type: "check",
1988
- repo: `${repo.owner}/${repo.name}`,
1989
- pr: number,
1990
- headSha: state.head,
1991
- ...(run?.databaseId === undefined || run.databaseId === null
1992
- ? {}
1993
- : { runId: run.databaseId }),
1994
- ...(run?.attempt === undefined || run.attempt === null
1995
- ? {}
1996
- : { attempt: run.attempt }),
1997
- ...(jobId === null ? {} : { jobId }),
1998
- name: check.name,
1999
- state: check.state,
2000
- bucket: check.bucket,
2001
- link: check.link,
2002
- identity,
2003
- };
2004
- if (previousIdentity !== undefined && previousIdentity !== identity) {
2005
- event.supersedes = previousIdentity;
2006
- }
2007
- if (!(yield* beforeDeadline)) {
2008
- timedOut = true;
2009
- break;
2010
- }
2011
- yield* emit(event);
2012
- lastRevision.set(identity, revision);
1846
+ yield* waits.until(
1847
+ Effect.gen(function* () {
1848
+ for (const number of prs) {
1849
+ if (terminal.has(number)) continue;
1850
+ if (!(yield* beforeDeadline)) {
1851
+ timedOut = true;
1852
+ break;
1853
+ }
1854
+ const remainingMs = Math.max(1, deadline - Number(yield* Clock.currentTimeMillis));
1855
+ const state = yield* snapshot(number).pipe(
1856
+ Effect.timeoutOrElse({
1857
+ duration: remainingMs,
1858
+ orElse: () => Effect.succeed("timeout" as const),
1859
+ }),
1860
+ );
1861
+ if (state === "timeout") {
1862
+ timedOut = true;
1863
+ break;
1864
+ }
1865
+ if (state === null) continue;
1866
+ const headKey = `${number}/${state.head ?? ""}`;
1867
+ const oldHeadKey = headByPR.get(number);
1868
+ if (oldHeadKey !== undefined && oldHeadKey !== headKey) {
1869
+ checksObserved.delete(oldHeadKey);
1870
+ emptySnapshots.delete(oldHeadKey);
1871
+ }
1872
+ headByPR.set(number, headKey);
1873
+ if (state.checks.length > 0) {
1874
+ checksObserved.add(headKey);
1875
+ emptySnapshots.set(headKey, 0);
1876
+ } else {
1877
+ emptySnapshots.set(headKey, (emptySnapshots.get(headKey) ?? 0) + 1);
1878
+ }
1879
+ for (const { check, run } of state.checks) {
1880
+ const matches = failedJobsMatchingCheck(check.name, run?.jobs ?? []);
1881
+ const jobId = matches.length === 1 ? (matches[0]?.databaseId ?? null) : null;
1882
+ const fallback = jobId === null ? `${check.name}|${check.link}` : String(jobId);
1883
+ const identity = [
1884
+ repo.owner + "/" + repo.name,
1885
+ number,
1886
+ state.head ?? "",
1887
+ run?.databaseId ?? "external",
1888
+ run?.attempt ?? "",
1889
+ fallback,
1890
+ ].join("/");
1891
+ const logical = `${number}/${check.name}`;
1892
+ const previousIdentity = currentIdentity.get(logical);
1893
+ const revision = `${check.state}/${check.bucket}`;
1894
+ if (lastRevision.get(identity) !== revision) {
1895
+ const event: Record<string, unknown> = {
1896
+ type: "check",
1897
+ repo: `${repo.owner}/${repo.name}`,
1898
+ pr: number,
1899
+ headSha: state.head,
1900
+ ...(run?.databaseId === undefined || run.databaseId === null
1901
+ ? {}
1902
+ : { runId: run.databaseId }),
1903
+ ...(run?.attempt === undefined || run.attempt === null
1904
+ ? {}
1905
+ : { attempt: run.attempt }),
1906
+ ...(jobId === null ? {} : { jobId }),
1907
+ name: check.name,
1908
+ state: check.state,
1909
+ bucket: check.bucket,
1910
+ link: check.link,
1911
+ identity,
1912
+ };
1913
+ if (previousIdentity !== undefined && previousIdentity !== identity) {
1914
+ event.supersedes = previousIdentity;
2013
1915
  }
2014
- currentIdentity.set(logical, identity);
2015
- }
2016
- if (timedOut) break;
2017
- // Empty snapshots need three consecutive observations, including after checks were seen.
2018
- const hasTerminalCoverage =
2019
- state.checks.length > 0 || (emptySnapshots.get(headKey) ?? 0) >= 3;
2020
- const checksTerminal =
2021
- state.checks.length === 0 ||
2022
- state.checks.every(({ check }) => check.bucket !== "pending");
2023
- if (state.pr.state !== "OPEN" || (hasTerminalCoverage && checksTerminal)) {
2024
1916
  if (!(yield* beforeDeadline)) {
2025
1917
  timedOut = true;
2026
1918
  break;
2027
1919
  }
2028
- const identity = `${repo.owner}/${repo.name}/${number}/${state.head ?? ""}/terminal/${state.pr.state}`;
2029
- yield* emit({
2030
- type: "pr_terminal",
2031
- repo: `${repo.owner}/${repo.name}`,
2032
- pr: number,
2033
- headSha: state.head,
2034
- state: state.pr.state,
2035
- checksObserved: checksObserved.has(headKey),
2036
- identity,
2037
- });
2038
- terminal.add(number);
1920
+ yield* emit(event);
1921
+ lastRevision.set(identity, revision);
2039
1922
  }
1923
+ currentIdentity.set(logical, identity);
2040
1924
  }
2041
- const now = Number(yield* Clock.currentTimeMillis);
2042
- timedOut = timedOut || (terminal.size < prs.length && now >= deadline);
2043
- if (!timedOut && terminal.size < prs.length) {
2044
- yield* Effect.sleep(
2045
- Duration.millis(Math.min(options.intervalSeconds * 1000, deadline - now)),
2046
- );
1925
+ if (timedOut) break;
1926
+ // Empty snapshots need three consecutive observations, including after checks were seen.
1927
+ const hasTerminalCoverage =
1928
+ state.checks.length > 0 || (emptySnapshots.get(headKey) ?? 0) >= 3;
1929
+ const checksTerminal =
1930
+ state.checks.length === 0 ||
1931
+ state.checks.every(({ check }) => check.bucket !== "pending");
1932
+ if (state.pr.state !== "OPEN" || (hasTerminalCoverage && checksTerminal)) {
1933
+ if (!(yield* beforeDeadline)) {
1934
+ timedOut = true;
1935
+ break;
1936
+ }
1937
+ const identity = `${repo.owner}/${repo.name}/${number}/${state.head ?? ""}/terminal/${state.pr.state}`;
1938
+ yield* emit({
1939
+ type: "pr_terminal",
1940
+ repo: `${repo.owner}/${repo.name}`,
1941
+ pr: number,
1942
+ headSha: state.head,
1943
+ state: state.pr.state,
1944
+ checksObserved: checksObserved.has(headKey),
1945
+ identity,
1946
+ });
1947
+ terminal.add(number);
2047
1948
  }
2048
- }),
2049
- step: () => undefined,
2050
- });
1949
+ }
1950
+ const now = Number(yield* Clock.currentTimeMillis);
1951
+ timedOut = timedOut || (terminal.size < prs.length && now >= deadline);
1952
+ }),
1953
+ () => terminal.size >= prs.length || timedOut,
1954
+ {
1955
+ intervalMs: options.intervalSeconds * 1000,
1956
+ timeoutMs: options.timeoutSeconds * 1000,
1957
+ },
1958
+ );
2051
1959
  const watcherTimedOut = terminal.size < prs.length;
2052
1960
  yield* emit({
2053
1961
  type: "watcher_terminal",
@@ -2071,27 +1979,21 @@ export const watchPRs = Effect.fn("pr.watchPRs")(function* (
2071
1979
  }
2072
1980
  });
2073
1981
 
2074
- type RerunDiscovery = RerunCheckAttempt & { status?: string };
2075
-
2076
- const fetchAttemptJobs = Effect.fn("pr.fetchAttemptJobs")(function* (
2077
- runId: string,
2078
- attempt: number,
2079
- repo: string,
2080
- ) {
2081
- const gh = yield* GitHubService;
2082
- const jobs: Array<{ id: number; name: string }> = [];
2083
- for (let page = 1; ; page += 1) {
2084
- const response = yield* gh
2085
- .runGhJson<{ jobs?: Array<{ id: number; name: string }> }>([
2086
- "api",
2087
- `repos/${repo}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=100&page=${page}`,
2088
- ])
1982
+ const fetchAttemptJobs = (runId: string, attempt: number, repo: string) =>
1983
+ Effect.gen(function* () {
1984
+ return yield* reads
1985
+ .completedAttemptJobs(yield* resolveRepo(repo), Number(runId), attempt)
2089
1986
  .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
2090
- if (response?.jobs === undefined) return null;
2091
- jobs.push(...response.jobs);
2092
- if (response.jobs.length < 100) return jobs;
2093
- }
2094
- });
1987
+ });
1988
+
1989
+ const readRerunState = (runId: string, withStatus: boolean) =>
1990
+ Effect.gen(function* () {
1991
+ const run = yield* reads.runView(yield* resolveRepo(null), Number(runId), "origin");
1992
+ const fields = withStatus
1993
+ ? (["databaseId", "attempt", "status", "jobs"] as const)
1994
+ : (["databaseId", "attempt", "jobs"] as const);
1995
+ return project(run, fields);
1996
+ });
2095
1997
 
2096
1998
  const readJobDiagnosis = Effect.fn("pr.readJobDiagnosis")(function* (
2097
1999
  runId: string,
@@ -2115,24 +2017,22 @@ const discoverRerun = Effect.fn("pr.discoverRerun")(function* (
2115
2017
  currentJobIds: readonly number[],
2116
2018
  deadline: number,
2117
2019
  ) {
2118
- const gh = yield* GitHubService;
2119
- let latest: RerunDiscovery | null = null;
2120
- while (Number(yield* Clock.currentTimeMillis) <= deadline) {
2121
- latest = yield* gh
2122
- .runGhJson<RerunDiscovery>(["run", "view", runId, "--json", "databaseId,attempt,status,jobs"])
2123
- .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
2124
- if (
2020
+ const read = readRerunState(runId, true).pipe(
2021
+ Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)),
2022
+ );
2023
+ const outcome = yield* waits.until(
2024
+ read,
2025
+ (latest) =>
2125
2026
  latest !== null &&
2126
2027
  ((latest.attempt ?? 0) > (currentAttempt ?? 0) ||
2127
- latest.jobs.some((job) => !currentJobIds.includes(job.databaseId)))
2128
- ) {
2129
- return latest;
2130
- }
2131
- const remaining = deadline - Number(yield* Clock.currentTimeMillis);
2132
- if (remaining <= 0) return null;
2133
- yield* Effect.sleep(Duration.millis(Math.min(1000, remaining)));
2134
- }
2135
- return null;
2028
+ latest.jobs.some((job) => !currentJobIds.includes(job.databaseId))),
2029
+ {
2030
+ intervalMs: 5000,
2031
+ timeoutMs: Math.max(0, deadline - Number(yield* Clock.currentTimeMillis)),
2032
+ readAtDeadline: true,
2033
+ },
2034
+ );
2035
+ return outcome.kind === "done" ? outcome.value : null;
2136
2036
  });
2137
2037
 
2138
2038
  export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
@@ -2166,9 +2066,9 @@ export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
2166
2066
  evidence: RerunRetryEvidence;
2167
2067
  }> = [];
2168
2068
  for (const [runId, runChecks] of checksByRun) {
2169
- const run = yield* gh
2170
- .runGhJson<RerunCheckAttempt>(["run", "view", runId, "--json", "databaseId,attempt,jobs"])
2171
- .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
2069
+ const run = yield* readRerunState(runId, false).pipe(
2070
+ Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)),
2071
+ );
2172
2072
  let evidence: RerunRetryEvidence = { state: "eligible", diagnosis: diagnoseLogEntries([]) };
2173
2073
  if (failedOnly) {
2174
2074
  const jobIds = run === null ? null : resolveJobIdsForFailedChecks(runChecks, run.jobs);
@@ -2248,8 +2148,8 @@ export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
2248
2148
  const deadline = Number(yield* Clock.currentTimeMillis) + (options.timeoutSeconds ?? 60) * 1000;
2249
2149
  const results: RerunChecksRun[] = [];
2250
2150
  for (const candidate of candidates) {
2251
- const success = yield* gh
2252
- .runGh(["run", "rerun", candidate.runId, ...(failedOnly ? ["--failed"] : [])])
2151
+ const success = yield* writes
2152
+ .run(["run", "rerun", candidate.runId, ...(failedOnly ? ["--failed"] : [])], [`repo:${repo}`])
2253
2153
  .pipe(
2254
2154
  Effect.as(true),
2255
2155
  Effect.catch(() => Effect.succeed(false)),
@@ -2276,7 +2176,7 @@ export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
2276
2176
  deadline,
2277
2177
  )
2278
2178
  : Effect.succeed(null),
2279
- { concurrency: "unbounded" },
2179
+ { concurrency: 2 },
2280
2180
  );
2281
2181
  yield* Effect.forEach(
2282
2182
  candidates,
@@ -2293,25 +2193,29 @@ export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
2293
2193
  return;
2294
2194
  }
2295
2195
  let latest = next;
2296
- while (latest.status !== "completed") {
2297
- const remaining = deadline - Number(yield* Clock.currentTimeMillis);
2298
- if (remaining <= 0) break;
2299
- yield* Effect.sleep(Duration.millis(Math.min(1000, remaining)));
2300
- if (Number(yield* Clock.currentTimeMillis) >= deadline) break;
2301
- latest = yield* gh
2302
- .runGhJson<RerunDiscovery>([
2303
- "run",
2304
- "view",
2305
- candidate.runId,
2306
- "--json",
2307
- "databaseId,attempt,status,jobs",
2308
- ])
2309
- .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(latest)));
2310
- }
2196
+ const outcome = yield* waits.until(
2197
+ Effect.suspend(() =>
2198
+ readRerunState(candidate.runId, true).pipe(
2199
+ Effect.catchTag("GitHubCommandError", () => Effect.succeed(latest)),
2200
+ Effect.tap((refreshed) =>
2201
+ Effect.sync(() => {
2202
+ latest = refreshed;
2203
+ }),
2204
+ ),
2205
+ ),
2206
+ ),
2207
+ (value) => value.status === "completed",
2208
+ {
2209
+ initial: { value: latest },
2210
+ intervalMs: 5000,
2211
+ timeoutMs: Math.max(0, deadline - Number(yield* Clock.currentTimeMillis)),
2212
+ },
2213
+ );
2214
+ latest = outcome.kind === "done" ? outcome.value : outcome.last;
2311
2215
  result.latestAttempt = latest;
2312
2216
  result.status = latest.status === "completed" ? "completed" : "watch_timeout";
2313
2217
  }),
2314
- { concurrency: "unbounded" },
2218
+ { concurrency: 2 },
2315
2219
  );
2316
2220
  }
2317
2221
  const report: RerunChecksReport = {