@blogic-cz/agent-tools 1.7.4 → 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 +179 -74
  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 +19 -23
  91. package/src/gh-tool/pr/core.ts +542 -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) {
@@ -265,7 +247,8 @@ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureCont
265
247
  });
266
248
 
267
249
  // `gh pr checks` exits 1 on an *empty* result ("no checks reported on the 'x' branch"). Zero checks
268
- // is an ordinary state, so map it to [] and keep the zero-check paths downstream reachable.
250
+ // is an ordinary state, so it is not an error, but it is also not an empty list: a PR with green
251
+ // checks (sabservis/nexus-be#1396) once came back empty.
269
252
  export const NO_CHECKS_REPORTED_RE = /no checks reported/i;
270
253
  // `gh` reports "no checks reported" both for the seconds after a push, before its checks
271
254
  // register, and forever for a PR that has none — the message cannot tell them apart. So the
@@ -274,6 +257,12 @@ export const NO_CHECKS_REPORTED_RE = /no checks reported/i;
274
257
  // for the caller's whole --timeout.
275
258
  const CHECK_REGISTRATION_POLL_SECONDS = 5;
276
259
  const CHECK_REGISTRATION_GRACE_SECONDS = 60;
260
+ const NO_CHECKS_REPORTED_REASON =
261
+ "gh reported no checks for this head. Checks may not be registered yet, the PR may have no CI, or GitHub returned an empty rollup. This is not a CI verdict.";
262
+
263
+ export type CheckReport =
264
+ | { checksStatus: "reported"; checks: CheckResult[] }
265
+ | { checksStatus: "none_reported"; reason: string; checks: null };
277
266
 
278
267
  const summarizeFailedStepEntries = (entries: Array<{ step: string; message: string }>) => {
279
268
  const selected = entries
@@ -304,72 +293,31 @@ export const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (
304
293
  pr: number | null,
305
294
  cache?: { headSha: string; watchPending?: boolean },
306
295
  ) {
307
- const gh = yield* GitHubService;
308
-
309
- const args = ["pr", "checks"];
310
- if (pr !== null) {
311
- args.push(String(pr));
312
- }
313
-
314
- const directory = yield* CheckStatusCacheDirectory;
315
- const now = yield* Clock.currentTimeMillis;
316
- let repo: string | null = null;
317
- let identity: string | null = null;
318
- if (cache !== undefined && pr !== null && directory !== undefined) {
319
- const repoInfo = yield* gh.getRepoInfo().pipe(Effect.result);
320
- const token = yield* resolveGitHubToken().pipe(Effect.result);
321
- if (Result.isSuccess(repoInfo) && Result.isSuccess(token)) {
322
- const host = yield* Effect.try({
323
- try: () => new URL(repoInfo.success.url).host,
324
- catch: () => "",
325
- }).pipe(Effect.orElseSucceed(() => ""));
326
- if (host.length > 0) {
327
- const cacheRepo = `${host}/${repoInfo.success.owner}/${repoInfo.success.name}`;
328
- const cacheIdentity = createHash("sha256").update(token.success).digest("hex");
329
- const cached = yield* Effect.tryPromise({
330
- try: () =>
331
- readCheckStatusCache(
332
- cacheIdentity,
333
- cacheRepo,
334
- pr,
335
- cache.headSha,
336
- now,
337
- cache.watchPending,
338
- directory,
339
- ),
340
- catch: () => null,
341
- }).pipe(Effect.orElseSucceed(() => null));
342
- if (cached !== null) return cached;
343
- repo = cacheRepo;
344
- identity = cacheIdentity;
345
- }
346
- }
347
- }
296
+ const report = yield* fetchCheckReport(pr, cache);
297
+ return report.checks ?? [];
298
+ });
348
299
 
349
- const fetchedAt = yield* Clock.currentTimeMillis;
350
- const checks = yield* gh
351
- .runGhJson<CheckResult[]>([...args, "--json", CHECK_JSON_FIELDS])
300
+ export const fetchCheckReport = Effect.fn("pr.fetchCheckReport")(function* (
301
+ pr: number | null,
302
+ cache?: { headSha: string; watchPending?: boolean },
303
+ ) {
304
+ const checks = yield* reads
305
+ .prChecks(yield* resolveRepo(null), pr, cache?.headSha ?? null)
352
306
  .pipe(
353
307
  Effect.catchTag("GitHubCommandError", (error) =>
354
308
  NO_CHECKS_REPORTED_RE.test(error.stderr) || NO_CHECKS_REPORTED_RE.test(error.message)
355
- ? Effect.succeed<CheckResult[]>([])
309
+ ? Effect.succeed(null)
356
310
  : Effect.fail(error),
357
311
  ),
358
312
  );
359
- if (
360
- cache !== undefined &&
361
- pr !== null &&
362
- repo !== null &&
363
- identity !== null &&
364
- checks.length > 0
365
- ) {
366
- yield* Effect.tryPromise({
367
- try: () =>
368
- writeCheckStatusCache(identity, repo, pr, cache.headSha, checks, fetchedAt, directory),
369
- catch: () => undefined,
370
- }).pipe(Effect.orElseSucceed(() => undefined));
313
+ if (checks === null || checks.length === 0) {
314
+ return {
315
+ checksStatus: "none_reported",
316
+ reason: NO_CHECKS_REPORTED_REASON,
317
+ checks: null,
318
+ } satisfies CheckReport;
371
319
  }
372
- return checks;
320
+ return { checksStatus: "reported", checks } satisfies CheckReport;
373
321
  });
374
322
 
375
323
  const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function* (
@@ -405,7 +353,7 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
405
353
  runIds,
406
354
  (runId) =>
407
355
  fetchWorkflowRunFailureContext(runId).pipe(Effect.map((found) => [runId, found] as const)),
408
- { concurrency: "unbounded" },
356
+ { concurrency: 2 },
409
357
  ),
410
358
  );
411
359
 
@@ -430,7 +378,7 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
430
378
  const failedStepLogs = yield* fetchJobLogs({
431
379
  runId,
432
380
  job: matchedJob.name,
433
- jobId: matchedJob.databaseId,
381
+ jobRef: { id: matchedJob.databaseId, status: matchedJob.status },
434
382
  failedStepNames: matchedJob.failedSteps,
435
383
  steps: found?.stepsByJob.get(matchedJob.databaseId) ?? [],
436
384
  failedStepsOnly: true,
@@ -534,19 +482,32 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
534
482
  };
535
483
  });
536
484
 
537
- export const fetchPRView = Effect.fn("pr.fetchPRView")(function* (prNumber: number | null) {
538
- const gh = yield* GitHubService;
539
-
540
- const args = ["pr", "view"];
541
- if (prNumber !== null) {
542
- args.push(String(prNumber));
543
- }
544
- args.push(
545
- "--json",
546
- "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,
547
493
  );
548
-
549
- 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
+ ]);
550
511
  return {
551
512
  ...info,
552
513
  body: info.body ?? "",
@@ -555,17 +516,15 @@ export const fetchPRView = Effect.fn("pr.fetchPRView")(function* (prNumber: numb
555
516
  };
556
517
  });
557
518
 
558
- 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
+ ) {
559
523
  const gh = yield* GitHubService;
560
524
 
561
- const info = yield* fetchPRView(prNumber);
525
+ const info = yield* fetchPRView(prNumber, consistency);
562
526
  const repo = yield* gh.getRepoInfo();
563
- const baseSha = yield* gh.runGh([
564
- "api",
565
- `repos/${repo.owner}/${repo.name}/pulls/${info.number}`,
566
- "--jq",
567
- ".base.sha",
568
- ]);
527
+ const baseSha = yield* passthrough.baseSha(repo, info.number);
569
528
  return {
570
529
  ...info,
571
530
  baseSha: baseSha.stdout.trim() || null,
@@ -625,7 +584,7 @@ export const detectPRStatus = Effect.fn("pr.detectPRStatus")(function* () {
625
584
  ),
626
585
  ];
627
586
 
628
- const gh = yield* GitHubService;
587
+ const repo = yield* resolveRepo(null);
629
588
 
630
589
  type BranchResult = {
631
590
  branch: string;
@@ -642,40 +601,41 @@ export const detectPRStatus = Effect.fn("pr.detectPRStatus")(function* () {
642
601
  branchNames.map((branchName) =>
643
602
  Effect.all(
644
603
  {
645
- openPr: gh
646
- .runGhJson<PRInfo[]>([
647
- "pr",
648
- "list",
649
- "--head",
650
- branchName,
651
- "--json",
652
- "number,url,title,headRefName,baseRefName,state,isDraft,mergeable",
653
- "--limit",
654
- "1",
655
- ])
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
+ })
656
613
  .pipe(
657
- 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
+ ),
658
628
  Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)),
659
629
  ),
660
- closedPr: gh
661
- .runGhJson<
662
- Array<{
663
- number: number;
664
- url: string;
665
- state: string;
666
- }>
667
- >([
668
- "pr",
669
- "list",
670
- "--head",
671
- branchName,
672
- "--state",
673
- "closed",
674
- "--json",
675
- "number,url,state",
676
- "--limit",
677
- "1",
678
- ])
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
+ })
679
639
  .pipe(
680
640
  Effect.map((prs) => {
681
641
  const pr = prs[0];
@@ -693,7 +653,7 @@ export const detectPRStatus = Effect.fn("pr.detectPRStatus")(function* () {
693
653
  Effect.catch(() => Effect.succeed(false)),
694
654
  ),
695
655
  },
696
- { concurrency: "unbounded" },
656
+ { concurrency: 2 },
697
657
  ).pipe(
698
658
  Effect.map((r): BranchResult => ({
699
659
  branch: branchName,
@@ -701,7 +661,7 @@ export const detectPRStatus = Effect.fn("pr.detectPRStatus")(function* () {
701
661
  })),
702
662
  ),
703
663
  ),
704
- { concurrency: "unbounded" },
664
+ { concurrency: 2 },
705
665
  );
706
666
 
707
667
  const foundPrs = branchResults.flatMap((r) => (r.openPr === null ? [] : [r.openPr]));
@@ -739,59 +699,44 @@ export const listPRs = Effect.fn("pr.listPRs")(function* (opts: {
739
699
  head: string | null;
740
700
  search: string | null;
741
701
  }) {
742
- const gh = yield* GitHubService;
743
- const args = [
744
- "pr",
745
- "list",
746
- "--state",
747
- opts.state,
748
- "--limit",
749
- String(opts.limit),
750
- "--json",
751
- "number,url,title,headRefName,baseRefName,state,isDraft,author,createdAt,reviewDecision",
752
- ];
753
- if (opts.author !== null) args.push("--author", opts.author);
754
- if (opts.base !== null) args.push("--base", opts.base);
755
- if (opts.head !== null) args.push("--head", opts.head);
756
- if (opts.search !== null) args.push("--search", opts.search);
757
- 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
+ );
758
717
  });
759
718
 
760
719
  // GitHub recomputes mergeability asynchronously after CI; poll until it settles out of "UNKNOWN".
761
720
  const MAX_MERGEABLE_WAIT_SECONDS = 180;
762
- const MERGEABLE_POLL_INTERVAL_MS = 3000;
721
+ const MERGEABLE_POLL_INTERVAL_MS = 5000;
763
722
 
764
723
  export const waitForMergeable = Effect.fn("pr.waitForMergeable")(function* (
765
724
  pr: number | null,
766
725
  timeoutSeconds: number,
767
726
  ) {
768
727
  const cappedSeconds = Math.min(timeoutSeconds, MAX_MERGEABLE_WAIT_SECONDS);
769
- const start = yield* Clock.currentTimeMillis;
770
- const deadlineMs = Number(start) + cappedSeconds * 1000;
771
-
772
- // Effect.whileLoop (not recursion) so TestClock.adjust can advance Effect.sleep without real waits.
773
- let latest = yield* viewPR(pr);
774
- let timedOut = false;
775
- yield* Effect.whileLoop({
776
- while: () => latest.mergeable === "UNKNOWN" && !timedOut,
777
- body: () =>
778
- Effect.gen(function* () {
779
- const now = yield* Clock.currentTimeMillis;
780
- if (Number(now) >= deadlineMs) {
781
- timedOut = true;
782
- return;
783
- }
784
- // Cap the sleep to the remaining budget so the total wait doesn't overshoot the deadline.
785
- const remaining = deadlineMs - Number(now);
786
- yield* Effect.sleep(Duration.millis(Math.min(MERGEABLE_POLL_INTERVAL_MS, remaining)));
787
- latest = yield* fetchPRView(pr).pipe(
788
- Effect.map((info) => ({ ...info, baseSha: latest.baseSha })),
789
- );
790
- }),
791
- step: () => undefined,
792
- });
793
-
794
- 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;
795
740
  });
796
741
 
797
742
  export const createPR = Effect.fn("pr.createPR")(function* (opts: {
@@ -816,24 +761,12 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
816
761
  headSeparator === -1 ? repo.owner : head.slice(0, headSeparator)
817
762
  ).toLowerCase();
818
763
  const headBranch = headSeparator === -1 ? head : head.slice(headSeparator + 1);
819
- const lookupArgs = [
820
- "pr",
821
- "list",
822
- "--repo",
823
- `${repo.owner}/${repo.name}`,
824
- "--state",
825
- "open",
826
- "--head",
827
- headBranch,
828
- "--base",
829
- baseBranch,
830
- "--json",
831
- "number,url,title,headRefName,baseRefName,state,isDraft,mergeable,headRepositoryOwner",
832
- "--limit",
833
- "2",
834
- ];
835
- type Candidate = PRInfo & { headRepositoryOwner?: { login: string } };
836
- 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;
837
770
  // The limited query may include another fork's branch. Refuse a saturated result
838
771
  // rather than assuming an owner-filtered match beyond the limit does not exist.
839
772
  if (candidates.length > 1) {
@@ -887,7 +820,7 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
887
820
  hint: "Use --update-if-exists to update it, or 'pr edit --pr <number>'.",
888
821
  });
889
822
  }
890
- const current = yield* fetchPRView(existing.number);
823
+ const current = yield* fetchPRView(existing.number, "origin");
891
824
  if (
892
825
  current.state !== "OPEN" ||
893
826
  current.headRefName !== headBranch ||
@@ -929,14 +862,14 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
929
862
  createArgs.push("--draft");
930
863
  }
931
864
 
932
- const createResult = yield* gh.runGh(createArgs);
865
+ const createResult = yield* writes.run(createArgs, [`repo:${repoSlug(repo)}`]);
933
866
 
934
867
  const urlMatch = createResult.stdout.match(/\/pull\/(\d+)/);
935
868
  if (urlMatch?.[1]) {
936
869
  return { ...(yield* viewPR(Number(urlMatch[1]))), updated: false };
937
870
  }
938
871
 
939
- const prs = yield* gh.runGhJson<Candidate[]>(lookupArgs);
872
+ const prs = yield* lookup;
940
873
  if (
941
874
  prs.length === 1 &&
942
875
  prs[0]?.state === "OPEN" &&
@@ -960,14 +893,9 @@ export const createPR = Effect.fn("pr.createPR")(function* (opts: {
960
893
  // GitHub rejects GraphQL `mergePullRequest` for a PR that belongs to a native stack and points at
961
894
  // the asynchronous REST merge instead. `gh pr merge` only speaks GraphQL.
962
895
  const ASYNC_MERGE_REQUIRED_RE = /asynchronous merge/i;
963
- const ASYNC_MERGE_POLL_INTERVAL_MS = 2000;
896
+ const ASYNC_MERGE_POLL_INTERVAL_MS = 5000;
964
897
  const MAX_ASYNC_MERGE_WAIT_SECONDS = 120;
965
898
 
966
- type AsyncMergeResult = {
967
- status: "pending" | "merged" | "enqueued" | "failed";
968
- details?: { message?: string; uuid?: string; sha?: string };
969
- };
970
-
971
899
  const asyncMergeFailure = (
972
900
  pr: number,
973
901
  message: string,
@@ -989,24 +917,24 @@ const mergeViaAsyncApi = Effect.fn("pr.mergeViaAsyncApi")(function* (opts: {
989
917
  const repo = yield* gh.getRepoInfo();
990
918
  const asyncPath = `repos/${repo.owner}/${repo.name}/pulls/${opts.pr}/merge-async`;
991
919
 
992
- let latest = yield* gh.runGhJson<AsyncMergeResult>([
993
- "api",
994
- "--method",
995
- "PUT",
996
- asyncPath,
997
- "-f",
998
- `merge_method=${opts.strategy}`,
999
- ]);
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
+ );
1000
924
 
1001
925
  const uuid = latest.details?.uuid;
1002
926
  if (uuid !== undefined) {
1003
- latest = yield* pollUntilResolved({
1004
- initial: latest,
1005
- isPending: (value) => value.status === "pending",
1006
- fetchLatest: () => gh.runGhJson<AsyncMergeResult>(["api", `${asyncPath}/${uuid}`]),
1007
- intervalMs: ASYNC_MERGE_POLL_INTERVAL_MS,
1008
- budgetSeconds: MAX_ASYNC_MERGE_WAIT_SECONDS,
1009
- });
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;
1010
938
  }
1011
939
 
1012
940
  if (latest.status === "merged") {
@@ -1056,27 +984,18 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1056
984
  });
1057
985
  }
1058
986
 
1059
- const info = yield* gh
1060
- .runGhJson<PRViewJsonResult & { headRefOid?: unknown }>([
1061
- "pr",
1062
- "view",
1063
- String(opts.pr),
1064
- "--json",
1065
- "number,url,title,headRefName,baseRefName,state,isDraft,mergeable" +
1066
- (matchHeadCommit !== undefined ? ",headRefOid" : ""),
1067
- ])
1068
- .pipe(
1069
- Effect.mapError((error) =>
1070
- matchHeadCommit === undefined
1071
- ? error
1072
- : new GitHubMergeError({
1073
- message: `Could not verify the head commit of PR #${opts.pr}: ${error.message}`,
1074
- reason: "unknown",
1075
- hint: "The merge is refused while the head is unknown. Re-read the PR and verify its reviewed head before retrying.",
1076
- nextCommand: `agent-tools-gh pr view --pr ${opts.pr}`,
1077
- }),
1078
- ),
1079
- );
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
+ );
1080
999
 
1081
1000
  if (
1082
1001
  matchHeadCommit !== undefined &&
@@ -1158,18 +1077,18 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1158
1077
  // branch if EVERY retarget succeeds (fail-closed).
1159
1078
  const dependentOpenPrs =
1160
1079
  opts.deleteBranch && !headIsLongLived && info.headRefName
1161
- ? yield* gh.runGhJson<Array<{ number: number; headRefName: string; baseRefName: string }>>([
1162
- "pr",
1163
- "list",
1164
- "--base",
1165
- info.headRefName,
1166
- "--state",
1167
- "open",
1168
- "--limit",
1169
- "100",
1170
- "--json",
1171
- "number,headRefName,baseRefName",
1172
- ])
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
+ )
1173
1092
  : [];
1174
1093
 
1175
1094
  if (!opts.confirm) {
@@ -1219,15 +1138,18 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1219
1138
  // closed.
1220
1139
  if (willDeleteBranch && dependentOpenPrs.length > 0 && repo) {
1221
1140
  for (const child of dependentOpenPrs) {
1222
- const retargeted = yield* gh
1223
- .runGh([
1224
- "api",
1225
- "--method",
1226
- "PATCH",
1227
- `repos/${repo.owner}/${repo.name}/pulls/${child.number}`,
1228
- "-f",
1229
- `base=${info.baseRefName}`,
1230
- ])
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
+ )
1231
1153
  .pipe(
1232
1154
  Effect.as(true),
1233
1155
  Effect.orElseSucceed(() => false),
@@ -1249,15 +1171,18 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1249
1171
  return failed;
1250
1172
  }
1251
1173
  for (const child of retargetedChildren) {
1252
- const rolledBack = yield* gh
1253
- .runGh([
1254
- "api",
1255
- "--method",
1256
- "PATCH",
1257
- `repos/${repo.owner}/${repo.name}/pulls/${child}`,
1258
- "-f",
1259
- `base=${info.headRefName}`,
1260
- ])
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
+ )
1261
1186
  .pipe(
1262
1187
  Effect.as(true),
1263
1188
  Effect.orElseSucceed(() => false),
@@ -1269,13 +1194,14 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1269
1194
  return failed;
1270
1195
  });
1271
1196
 
1197
+ const mergeRepo = yield* resolveRepo(null);
1272
1198
  const mergeArgs = ["pr", "merge", String(opts.pr), `--${opts.strategy}`];
1273
1199
  if (matchHeadCommit !== undefined) {
1274
1200
  mergeArgs.push("--match-head-commit", matchHeadCommit);
1275
1201
  }
1276
1202
 
1277
- const shaFromAsyncMerge = yield* gh
1278
- .runGh(mergeArgs)
1203
+ const shaFromAsyncMerge = yield* writes
1204
+ .run(mergeArgs, [`pr:${repoSlug(mergeRepo)}#${opts.pr}`, `pulls:${repoSlug(mergeRepo)}`])
1279
1205
  .pipe(
1280
1206
  Effect.map(() => null),
1281
1207
  Effect.catchTag("GitHubCommandError", (error) =>
@@ -1377,13 +1303,16 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
1377
1303
  // `gh pr merge --delete-branch` aborts its remote delete when the head branch is checked out in a
1378
1304
  // worktree; deleting the remote ref explicitly is worktree-independent. Local cleanup is separate.
1379
1305
  if (willDeleteBranch && repo && info.headRefName) {
1380
- const remoteDeleted = yield* gh
1381
- .runGh([
1382
- "api",
1383
- "--method",
1384
- "DELETE",
1385
- `repos/${repo.owner}/${repo.name}/git/refs/heads/${info.headRefName}`,
1386
- ])
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
+ )
1387
1316
  .pipe(
1388
1317
  Effect.as(true),
1389
1318
  Effect.orElseSucceed(() => false),
@@ -1411,8 +1340,6 @@ export const closePR = Effect.fn("pr.closePR")(function* (opts: {
1411
1340
  comment: string | null;
1412
1341
  deleteBranch: boolean;
1413
1342
  }) {
1414
- const gh = yield* GitHubService;
1415
-
1416
1343
  const args = ["pr", "close", String(opts.pr)];
1417
1344
 
1418
1345
  if (opts.comment !== null) {
@@ -1423,15 +1350,18 @@ export const closePR = Effect.fn("pr.closePR")(function* (opts: {
1423
1350
  args.push("--delete-branch");
1424
1351
  }
1425
1352
 
1426
- 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
+ ]);
1427
1359
 
1428
1360
  return yield* viewPR(opts.pr);
1429
1361
  });
1430
1362
 
1431
1363
  export const readyPR = Effect.fn("pr.readyPR")(function* (opts: { pr: number | null }) {
1432
- const gh = yield* GitHubService;
1433
-
1434
- const info = yield* viewPR(opts.pr);
1364
+ const info = yield* viewPR(opts.pr, "origin");
1435
1365
 
1436
1366
  // Idempotent: a PR that's already ready needs no mutation, and `gh pr ready` on a
1437
1367
  // non-draft PR is a no-op that would just add a pointless call.
@@ -1439,22 +1369,28 @@ export const readyPR = Effect.fn("pr.readyPR")(function* (opts: { pr: number | n
1439
1369
  return { ...info, wasAlreadyReady: true };
1440
1370
  }
1441
1371
 
1442
- 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
+ );
1443
1377
 
1444
1378
  const updated = yield* viewPR(info.number);
1445
1379
  return { ...updated, wasAlreadyReady: false };
1446
1380
  });
1447
1381
 
1448
1382
  export const draftPR = Effect.fn("pr.draftPR")(function* (opts: { pr: number | null }) {
1449
- const gh = yield* GitHubService;
1450
-
1451
- const info = yield* viewPR(opts.pr);
1383
+ const info = yield* viewPR(opts.pr, "origin");
1452
1384
 
1453
1385
  if (info.isDraft) {
1454
1386
  return { ...info, wasAlreadyDraft: true };
1455
1387
  }
1456
1388
 
1457
- 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
+ );
1458
1394
 
1459
1395
  const updated = yield* viewPR(info.number);
1460
1396
  return { ...updated, wasAlreadyDraft: false };
@@ -1517,7 +1453,7 @@ export const editPR = Effect.fn("pr.editPR")(function* (opts: {
1517
1453
  editArgs.push("-f", `base=${opts.base}`);
1518
1454
  }
1519
1455
 
1520
- yield* gh.runGh(editArgs);
1456
+ yield* writes.run(editArgs, [`pr:${repoSlug(repo)}#${opts.pr}`, `pulls:${repoSlug(repo)}`]);
1521
1457
 
1522
1458
  return yield* viewPR(opts.pr);
1523
1459
  });
@@ -1529,62 +1465,44 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
1529
1465
  timeoutSeconds: number,
1530
1466
  quiet = false,
1531
1467
  ) {
1532
- const gh = yield* GitHubService;
1533
-
1534
- const args = ["pr", "checks"];
1535
- if (pr !== null) {
1536
- args.push(String(pr));
1537
- }
1538
-
1539
1468
  if (watch) {
1540
- const watchArgs = [...args, "--watch"];
1541
- if (failFast) {
1542
- watchArgs.push("--fail-fast");
1543
- }
1544
-
1545
- // A push can register checks after the ref lands. Wait through that window
1546
- // within the caller's timeout, then report the final snapshot.
1547
- let watchResult: GhResult | null = null;
1548
- let registered = false;
1549
- let graceExpired = false;
1550
- const watchThroughRegistration = Effect.gen(function* () {
1551
- const graceStart = yield* Clock.currentTimeMillis;
1552
- const graceDeadlineMs =
1553
- Number(graceStart) + Math.min(CHECK_REGISTRATION_GRACE_SECONDS, timeoutSeconds) * 1000;
1554
- yield* Effect.whileLoop({
1555
- while: () => !registered && !graceExpired,
1556
- body: () =>
1557
- gh.runGh(watchArgs).pipe(
1558
- Effect.flatMap((result) => {
1559
- watchResult = result;
1560
- registered = true;
1561
- return Effect.void;
1562
- }),
1563
- Effect.catchTag("GitHubCommandError", (error) =>
1564
- NO_CHECKS_REPORTED_RE.test(error.stderr) || NO_CHECKS_REPORTED_RE.test(error.message)
1565
- ? Effect.gen(function* () {
1566
- const now = yield* Clock.currentTimeMillis;
1567
- if (Number(now) >= graceDeadlineMs) {
1568
- graceExpired = true;
1569
- return;
1570
- }
1571
- yield* Effect.sleep(Duration.seconds(CHECK_REGISTRATION_POLL_SECONDS));
1572
- })
1573
- : Effect.fail(error),
1574
- ),
1575
- ),
1576
- step: () => undefined,
1577
- });
1578
- 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) };
1579
1475
  });
1580
-
1581
- const watchOutcome = yield* watchThroughRegistration.pipe(
1582
- Effect.timeoutOrElse({
1583
- duration: timeoutSeconds * 1000,
1584
- orElse: () => Effect.succeed(null),
1585
- }),
1586
- );
1587
-
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
+ });
1588
1506
  const results = yield* fetchCheckResults(pr);
1589
1507
  if (!quiet && graceExpired) {
1590
1508
  const prRef = pr === null ? "<number>" : String(pr);
@@ -1595,7 +1513,10 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
1595
1513
  `head instead:\n agent-tools-gh pr trigger-checks --pr ${prRef} --workflow <file.yml>`,
1596
1514
  );
1597
1515
  }
1598
- 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
+ ) {
1599
1520
  const pending = results.filter((check) => check.bucket === "pending").length;
1600
1521
  yield* logFormatted(results, quiet ? "json" : "toon");
1601
1522
  return yield* new GitHubTimeoutError({
@@ -1679,23 +1600,6 @@ export const triggerChecks = Effect.fn("pr.triggerChecks")(function* (
1679
1600
  };
1680
1601
  });
1681
1602
 
1682
- export const collectWithStableState = <S, A, E1, R1, E2, R2>(
1683
- initial: S,
1684
- collect: (state: S) => Effect.Effect<A, E1, R1>,
1685
- refresh: (state: S) => Effect.Effect<S, E2, R2>,
1686
- unchanged: (before: S, after: S) => boolean,
1687
- ): Effect.Effect<{ state: S; value: A } | null, E1 | E2, R1 | R2> =>
1688
- Effect.gen(function* () {
1689
- let before = initial;
1690
- for (let attempt = 0; attempt < STABLE_SNAPSHOT_ATTEMPTS; attempt += 1) {
1691
- const value = yield* collect(before);
1692
- const after = yield* refresh(before);
1693
- if (unchanged(before, after)) return { state: after, value };
1694
- before = after;
1695
- }
1696
- return null;
1697
- });
1698
-
1699
1603
  export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (
1700
1604
  pr: number | null,
1701
1605
  withLogs = false,
@@ -1706,7 +1610,7 @@ export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (
1706
1610
  initial,
1707
1611
  (info) => fetchCheckResults(info.number),
1708
1612
  (info) =>
1709
- fetchPRView(info.number).pipe(
1613
+ fetchPRView(info.number, "origin").pipe(
1710
1614
  Effect.map((refreshed) => ({ ...refreshed, baseSha: initial.baseSha })),
1711
1615
  ),
1712
1616
  (before, after) => after.headSha === before.headSha,
@@ -1763,23 +1667,58 @@ export const fetchChecksForCommand = Effect.fn("pr.fetchChecksForCommand")(funct
1763
1667
  return yield* Effect.fail(watchedChecks.failure);
1764
1668
  });
1765
1669
 
1766
- type WatchPR = { number: number; state: string; headRefOid?: string | null };
1767
1670
  type WatchRun = {
1768
1671
  databaseId: number;
1769
1672
  attempt?: number | null;
1770
1673
  headSha?: string | null;
1771
- 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;
1772
1682
  };
1773
1683
 
1774
1684
  const watchPRState = Effect.fn("pr.watchPRState")(function* (pr: number) {
1775
- const gh = yield* GitHubService;
1776
- return yield* gh.runGhJson<WatchPR>([
1777
- "pr",
1778
- "view",
1779
- String(pr),
1780
- "--json",
1781
- "number,state,headRefOid",
1782
- ]);
1685
+ return project(
1686
+ yield* reads.prView(yield* resolveRepo(null), { by: "number", number: pr }, "origin"),
1687
+ ["number", "state", "headRefOid"],
1688
+ );
1689
+ });
1690
+
1691
+ export const fetchChecksBatch = Effect.fn("pr.fetchChecksBatch")(function* (
1692
+ prs: readonly number[],
1693
+ ) {
1694
+ return yield* Effect.forEach(
1695
+ prs,
1696
+ (pr) =>
1697
+ Effect.gen(function* () {
1698
+ const snapshot = yield* collectWithStableState(
1699
+ yield* watchPRState(pr),
1700
+ () => fetchCheckReport(pr),
1701
+ () => watchPRState(pr),
1702
+ (before, after) => before.headRefOid === after.headRefOid && before.state === after.state,
1703
+ );
1704
+ if (snapshot === null) {
1705
+ return yield* new GitHubCommandError({
1706
+ command: "gh-tool pr checks --prs",
1707
+ exitCode: 1,
1708
+ stderr: "",
1709
+ message: `PR #${pr} head changed repeatedly while collecting checks`,
1710
+ retryable: true,
1711
+ });
1712
+ }
1713
+ return {
1714
+ pr,
1715
+ state: snapshot.state.state,
1716
+ headSha: snapshot.state.headRefOid ?? null,
1717
+ ...snapshot.value,
1718
+ };
1719
+ }),
1720
+ { concurrency: 5 },
1721
+ );
1783
1722
  });
1784
1723
 
1785
1724
  export const watchPRs = Effect.fn("pr.watchPRs")(function* (
@@ -1839,159 +1778,184 @@ export const watchPRs = Effect.fn("pr.watchPRs")(function* (
1839
1778
  const { state: after, value: checks } = stableResult.success;
1840
1779
  const head = after.headRefOid ?? null;
1841
1780
  const runs = new Map<number, WatchRun | null>();
1781
+ const identities = new Map<number, { runId: number; attempt: number } | null>();
1842
1782
  yield* Effect.forEach(
1843
- [...new Set(checks.map((check) => extractRunIdFromCheckLink(check.link)))].filter(
1844
- (runId): runId is number => runId !== null,
1783
+ [...new Set(checks.map((check) => checkJobId(check.link)))].filter(
1784
+ (id): id is number => id !== null,
1845
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),
1846
1806
  (runId) =>
1847
1807
  Effect.gen(function* () {
1848
1808
  if (!(yield* beforeDeadline)) return;
1849
- const run = yield* gh
1850
- .runGhJson<WatchRun>([
1851
- "run",
1852
- "view",
1853
- String(runId),
1854
- "--json",
1855
- "databaseId,attempt,headSha,jobs",
1856
- ])
1809
+ const run = yield* reads
1810
+ .runView(repo, runId)
1857
1811
  .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
1858
1812
  runs.set(runId, run);
1859
1813
  }),
1860
- { concurrency: 5 },
1814
+ { concurrency: 2 },
1861
1815
  );
1862
1816
  if (!(yield* beforeDeadline)) return "timeout" as const;
1863
1817
  return {
1864
1818
  pr: after,
1865
1819
  head,
1866
1820
  checks: checks
1867
- .map((check) => ({
1868
- check,
1869
- run:
1870
- extractRunIdFromCheckLink(check.link) === null
1871
- ? null
1872
- : (runs.get(extractRunIdFromCheckLink(check.link) ?? -1) ?? null),
1873
- }))
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
+ })
1874
1841
  .filter(({ run }) => run === null || run.headSha === null || run.headSha === head),
1875
1842
  };
1876
1843
  });
1877
1844
 
1878
1845
  let timedOut = false;
1879
- yield* Effect.whileLoop({
1880
- while: () => terminal.size < prs.length && !timedOut,
1881
- body: () =>
1882
- Effect.gen(function* () {
1883
- for (const number of prs) {
1884
- if (terminal.has(number)) continue;
1885
- if (!(yield* beforeDeadline)) {
1886
- timedOut = true;
1887
- break;
1888
- }
1889
- const remainingMs = Math.max(1, deadline - Number(yield* Clock.currentTimeMillis));
1890
- const state = yield* snapshot(number).pipe(
1891
- Effect.timeoutOrElse({
1892
- duration: remainingMs,
1893
- orElse: () => Effect.succeed("timeout" as const),
1894
- }),
1895
- );
1896
- if (state === "timeout") {
1897
- timedOut = true;
1898
- break;
1899
- }
1900
- if (state === null) continue;
1901
- const headKey = `${number}/${state.head ?? ""}`;
1902
- const oldHeadKey = headByPR.get(number);
1903
- if (oldHeadKey !== undefined && oldHeadKey !== headKey) {
1904
- checksObserved.delete(oldHeadKey);
1905
- emptySnapshots.delete(oldHeadKey);
1906
- }
1907
- headByPR.set(number, headKey);
1908
- if (state.checks.length > 0) {
1909
- checksObserved.add(headKey);
1910
- emptySnapshots.set(headKey, 0);
1911
- } else {
1912
- emptySnapshots.set(headKey, (emptySnapshots.get(headKey) ?? 0) + 1);
1913
- }
1914
- for (const { check, run } of state.checks) {
1915
- const matches = failedJobsMatchingCheck(check.name, run?.jobs ?? []);
1916
- const jobId = matches.length === 1 ? (matches[0]?.databaseId ?? null) : null;
1917
- const fallback = jobId === null ? `${check.name}|${check.link}` : String(jobId);
1918
- const identity = [
1919
- repo.owner + "/" + repo.name,
1920
- number,
1921
- state.head ?? "",
1922
- run?.databaseId ?? "external",
1923
- run?.attempt ?? "",
1924
- fallback,
1925
- ].join("/");
1926
- const logical = `${number}/${check.name}`;
1927
- const previousIdentity = currentIdentity.get(logical);
1928
- const revision = `${check.state}/${check.bucket}`;
1929
- if (lastRevision.get(identity) !== revision) {
1930
- const event: Record<string, unknown> = {
1931
- type: "check",
1932
- repo: `${repo.owner}/${repo.name}`,
1933
- pr: number,
1934
- headSha: state.head,
1935
- ...(run?.databaseId === undefined || run.databaseId === null
1936
- ? {}
1937
- : { runId: run.databaseId }),
1938
- ...(run?.attempt === undefined || run.attempt === null
1939
- ? {}
1940
- : { attempt: run.attempt }),
1941
- ...(jobId === null ? {} : { jobId }),
1942
- name: check.name,
1943
- state: check.state,
1944
- bucket: check.bucket,
1945
- link: check.link,
1946
- identity,
1947
- };
1948
- if (previousIdentity !== undefined && previousIdentity !== identity) {
1949
- event.supersedes = previousIdentity;
1950
- }
1951
- if (!(yield* beforeDeadline)) {
1952
- timedOut = true;
1953
- break;
1954
- }
1955
- yield* emit(event);
1956
- 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;
1957
1915
  }
1958
- currentIdentity.set(logical, identity);
1959
- }
1960
- if (timedOut) break;
1961
- // Empty snapshots need three consecutive observations, including after checks were seen.
1962
- const hasTerminalCoverage =
1963
- state.checks.length > 0 || (emptySnapshots.get(headKey) ?? 0) >= 3;
1964
- const checksTerminal =
1965
- state.checks.length === 0 ||
1966
- state.checks.every(({ check }) => check.bucket !== "pending");
1967
- if (state.pr.state !== "OPEN" || (hasTerminalCoverage && checksTerminal)) {
1968
1916
  if (!(yield* beforeDeadline)) {
1969
1917
  timedOut = true;
1970
1918
  break;
1971
1919
  }
1972
- const identity = `${repo.owner}/${repo.name}/${number}/${state.head ?? ""}/terminal/${state.pr.state}`;
1973
- yield* emit({
1974
- type: "pr_terminal",
1975
- repo: `${repo.owner}/${repo.name}`,
1976
- pr: number,
1977
- headSha: state.head,
1978
- state: state.pr.state,
1979
- checksObserved: checksObserved.has(headKey),
1980
- identity,
1981
- });
1982
- terminal.add(number);
1920
+ yield* emit(event);
1921
+ lastRevision.set(identity, revision);
1983
1922
  }
1923
+ currentIdentity.set(logical, identity);
1984
1924
  }
1985
- const now = Number(yield* Clock.currentTimeMillis);
1986
- timedOut = timedOut || (terminal.size < prs.length && now >= deadline);
1987
- if (!timedOut && terminal.size < prs.length) {
1988
- yield* Effect.sleep(
1989
- Duration.millis(Math.min(options.intervalSeconds * 1000, deadline - now)),
1990
- );
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);
1991
1948
  }
1992
- }),
1993
- step: () => undefined,
1994
- });
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
+ );
1995
1959
  const watcherTimedOut = terminal.size < prs.length;
1996
1960
  yield* emit({
1997
1961
  type: "watcher_terminal",
@@ -2015,27 +1979,21 @@ export const watchPRs = Effect.fn("pr.watchPRs")(function* (
2015
1979
  }
2016
1980
  });
2017
1981
 
2018
- type RerunDiscovery = RerunCheckAttempt & { status?: string };
2019
-
2020
- const fetchAttemptJobs = Effect.fn("pr.fetchAttemptJobs")(function* (
2021
- runId: string,
2022
- attempt: number,
2023
- repo: string,
2024
- ) {
2025
- const gh = yield* GitHubService;
2026
- const jobs: Array<{ id: number; name: string }> = [];
2027
- for (let page = 1; ; page += 1) {
2028
- const response = yield* gh
2029
- .runGhJson<{ jobs?: Array<{ id: number; name: string }> }>([
2030
- "api",
2031
- `repos/${repo}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=100&page=${page}`,
2032
- ])
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)
2033
1986
  .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
2034
- if (response?.jobs === undefined) return null;
2035
- jobs.push(...response.jobs);
2036
- if (response.jobs.length < 100) return jobs;
2037
- }
2038
- });
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
+ });
2039
1997
 
2040
1998
  const readJobDiagnosis = Effect.fn("pr.readJobDiagnosis")(function* (
2041
1999
  runId: string,
@@ -2059,24 +2017,22 @@ const discoverRerun = Effect.fn("pr.discoverRerun")(function* (
2059
2017
  currentJobIds: readonly number[],
2060
2018
  deadline: number,
2061
2019
  ) {
2062
- const gh = yield* GitHubService;
2063
- let latest: RerunDiscovery | null = null;
2064
- while (Number(yield* Clock.currentTimeMillis) <= deadline) {
2065
- latest = yield* gh
2066
- .runGhJson<RerunDiscovery>(["run", "view", runId, "--json", "databaseId,attempt,status,jobs"])
2067
- .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
2068
- 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) =>
2069
2026
  latest !== null &&
2070
2027
  ((latest.attempt ?? 0) > (currentAttempt ?? 0) ||
2071
- latest.jobs.some((job) => !currentJobIds.includes(job.databaseId)))
2072
- ) {
2073
- return latest;
2074
- }
2075
- const remaining = deadline - Number(yield* Clock.currentTimeMillis);
2076
- if (remaining <= 0) return null;
2077
- yield* Effect.sleep(Duration.millis(Math.min(1000, remaining)));
2078
- }
2079
- 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;
2080
2036
  });
2081
2037
 
2082
2038
  export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
@@ -2110,9 +2066,9 @@ export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
2110
2066
  evidence: RerunRetryEvidence;
2111
2067
  }> = [];
2112
2068
  for (const [runId, runChecks] of checksByRun) {
2113
- const run = yield* gh
2114
- .runGhJson<RerunCheckAttempt>(["run", "view", runId, "--json", "databaseId,attempt,jobs"])
2115
- .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
2069
+ const run = yield* readRerunState(runId, false).pipe(
2070
+ Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)),
2071
+ );
2116
2072
  let evidence: RerunRetryEvidence = { state: "eligible", diagnosis: diagnoseLogEntries([]) };
2117
2073
  if (failedOnly) {
2118
2074
  const jobIds = run === null ? null : resolveJobIdsForFailedChecks(runChecks, run.jobs);
@@ -2192,8 +2148,8 @@ export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
2192
2148
  const deadline = Number(yield* Clock.currentTimeMillis) + (options.timeoutSeconds ?? 60) * 1000;
2193
2149
  const results: RerunChecksRun[] = [];
2194
2150
  for (const candidate of candidates) {
2195
- const success = yield* gh
2196
- .runGh(["run", "rerun", candidate.runId, ...(failedOnly ? ["--failed"] : [])])
2151
+ const success = yield* writes
2152
+ .run(["run", "rerun", candidate.runId, ...(failedOnly ? ["--failed"] : [])], [`repo:${repo}`])
2197
2153
  .pipe(
2198
2154
  Effect.as(true),
2199
2155
  Effect.catch(() => Effect.succeed(false)),
@@ -2220,7 +2176,7 @@ export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
2220
2176
  deadline,
2221
2177
  )
2222
2178
  : Effect.succeed(null),
2223
- { concurrency: "unbounded" },
2179
+ { concurrency: 2 },
2224
2180
  );
2225
2181
  yield* Effect.forEach(
2226
2182
  candidates,
@@ -2237,25 +2193,29 @@ export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
2237
2193
  return;
2238
2194
  }
2239
2195
  let latest = next;
2240
- while (latest.status !== "completed") {
2241
- const remaining = deadline - Number(yield* Clock.currentTimeMillis);
2242
- if (remaining <= 0) break;
2243
- yield* Effect.sleep(Duration.millis(Math.min(1000, remaining)));
2244
- if (Number(yield* Clock.currentTimeMillis) >= deadline) break;
2245
- latest = yield* gh
2246
- .runGhJson<RerunDiscovery>([
2247
- "run",
2248
- "view",
2249
- candidate.runId,
2250
- "--json",
2251
- "databaseId,attempt,status,jobs",
2252
- ])
2253
- .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(latest)));
2254
- }
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;
2255
2215
  result.latestAttempt = latest;
2256
2216
  result.status = latest.status === "completed" ? "completed" : "watch_timeout";
2257
2217
  }),
2258
- { concurrency: "unbounded" },
2218
+ { concurrency: 2 },
2259
2219
  );
2260
2220
  }
2261
2221
  const report: RerunChecksReport = {