@blogic-cz/agent-tools 1.7.1 → 1.7.3

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.
@@ -0,0 +1,142 @@
1
+ import { Context } from "effect";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { chmod, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import type { CheckResult } from "#gh/types";
8
+
9
+ const CACHE_TTL_MS = 30_000;
10
+ const WATCH_PENDING_TTL_MS = 5_000;
11
+ const CACHE_DIR = join(homedir(), ".cache", "agent-tools", "gh-check-status-v1");
12
+
13
+ export const CheckStatusCacheDirectory = Context.Reference<string | undefined>(
14
+ "@agent-tools/CheckStatusCacheDirectory",
15
+ { defaultValue: () => CACHE_DIR },
16
+ );
17
+
18
+ type CacheEntry = { fetchedAt: number; checks: CheckResult[] };
19
+
20
+ const keyFor = (identity: string, repo: string, pr: number, headSha: string) =>
21
+ createHash("sha256")
22
+ .update(`${identity}\n${repo.toLowerCase()}\n${pr}\n${headSha}`)
23
+ .digest("hex");
24
+
25
+ const validChecks = (value: unknown): value is CheckResult[] =>
26
+ Array.isArray(value) &&
27
+ value.every(
28
+ (item) =>
29
+ item !== null &&
30
+ typeof item === "object" &&
31
+ typeof item.name === "string" &&
32
+ typeof item.state === "string" &&
33
+ typeof item.bucket === "string" &&
34
+ typeof item.link === "string",
35
+ );
36
+
37
+ export const readCheckStatusCache = async (
38
+ identity: string,
39
+ repo: string,
40
+ pr: number,
41
+ headSha: string,
42
+ now: number,
43
+ watchPending = false,
44
+ directory = CACHE_DIR,
45
+ ): Promise<CheckResult[] | null> => {
46
+ try {
47
+ const prefix = keyFor(identity, repo, pr, headSha);
48
+ const files = (await readdir(directory)).filter((name) => name.startsWith(`${prefix}.`));
49
+ let latest: CacheEntry | null = null;
50
+ const entries = await Promise.all(
51
+ files.map(async (name) => {
52
+ try {
53
+ return {
54
+ name,
55
+ value: JSON.parse(await readFile(join(directory, name), "utf8")) as unknown,
56
+ };
57
+ } catch {
58
+ return { name, value: null };
59
+ }
60
+ }),
61
+ );
62
+ const staleFiles: string[] = [];
63
+ for (const { name, value: entry } of entries) {
64
+ if (!name.endsWith(".json")) continue;
65
+ if (entry === null || typeof entry !== "object") continue;
66
+ const candidate = entry as Partial<CacheEntry>;
67
+ const fetchedAt = candidate.fetchedAt;
68
+ if (
69
+ typeof fetchedAt !== "number" ||
70
+ !Number.isFinite(fetchedAt) ||
71
+ !validChecks(candidate.checks) ||
72
+ candidate.checks.length === 0
73
+ ) {
74
+ staleFiles.push(join(directory, name));
75
+ continue;
76
+ }
77
+ if (fetchedAt > now || now - fetchedAt > CACHE_TTL_MS) {
78
+ staleFiles.push(join(directory, name));
79
+ continue;
80
+ }
81
+ if (watchPending && !candidate.checks.some((check) => check.bucket === "pending")) continue;
82
+ if (latest === null || fetchedAt > latest.fetchedAt)
83
+ latest = { fetchedAt, checks: candidate.checks };
84
+ }
85
+ await Promise.all(staleFiles.map((path) => rm(path, { force: true }).catch(() => undefined)));
86
+ if (latest === null || (watchPending && now - latest.fetchedAt > WATCH_PENDING_TTL_MS))
87
+ return null;
88
+ return latest.checks;
89
+ } catch {
90
+ return null;
91
+ }
92
+ };
93
+
94
+ export const writeCheckStatusCache = async (
95
+ identity: string,
96
+ repo: string,
97
+ pr: number,
98
+ headSha: string,
99
+ checks: CheckResult[],
100
+ fetchedAt: number,
101
+ directory = CACHE_DIR,
102
+ ): Promise<void> => {
103
+ if (checks.length === 0) return;
104
+ try {
105
+ await mkdir(directory, { recursive: true, mode: 0o700 });
106
+ await chmod(directory, 0o700);
107
+ await Promise.all(
108
+ (await readdir(directory))
109
+ .filter((name) => name.endsWith(".json"))
110
+ .map(async (name) => {
111
+ const path = join(directory, name);
112
+ try {
113
+ const entry: unknown = JSON.parse(await readFile(path, "utf8"));
114
+ if (
115
+ entry !== null &&
116
+ typeof entry === "object" &&
117
+ "fetchedAt" in entry &&
118
+ typeof entry.fetchedAt === "number" &&
119
+ Number.isFinite(entry.fetchedAt) &&
120
+ fetchedAt - entry.fetchedAt <= CACHE_TTL_MS
121
+ )
122
+ return;
123
+ await rm(path, { force: true });
124
+ } catch {
125
+ /* Another writer can remove a stale snapshot first. */
126
+ }
127
+ }),
128
+ );
129
+ const prefix = keyFor(identity, repo, pr, headSha);
130
+ const temporary = join(directory, `${prefix}.${process.pid}.${randomUUID()}.tmp`);
131
+ const destination = join(
132
+ directory,
133
+ `${prefix}.${fetchedAt}.${process.pid}.${randomUUID()}.json`,
134
+ );
135
+ await writeFile(temporary, JSON.stringify({ fetchedAt, checks } satisfies CacheEntry), {
136
+ mode: 0o600,
137
+ });
138
+ await rename(temporary, destination);
139
+ } catch {
140
+ // A cache write must never replace a successful GitHub response with an error.
141
+ }
142
+ };
@@ -22,6 +22,7 @@ import {
22
22
  } from "#gh/text-input";
23
23
  import {
24
24
  CI_CHECK_WATCH_TIMEOUT_MS,
25
+ PR_WATCH_TIMEOUT_SECONDS,
25
26
  DEFAULT_DELETE_BRANCH,
26
27
  DEFAULT_MERGE_STRATEGY,
27
28
  DEFAULT_REVIEW_EVENT,
@@ -190,6 +191,12 @@ export const classifyReviewTriage = (
190
191
  ): ReviewTriageClassification => {
191
192
  const reasons = [
192
193
  ...(checks.some((check) => check.bucket === "fail") ? ["failed_checks"] : []),
194
+ ...(checks.some((check) => check.bucket === "cancel") ? ["cancelled_checks"] : []),
195
+ ...(checks.some(
196
+ (check) => !["pass", "fail", "pending", "skipping", "cancel"].includes(check.bucket),
197
+ )
198
+ ? ["unknown_checks"]
199
+ : []),
193
200
  ...(summary.visibleOpenReviewThreadsCount > 0 ? ["visible_open_review_threads"] : []),
194
201
  ...(summary.unrepliedReviewThreadsCount > 0 ? ["unreplied_review_threads"] : []),
195
202
  ...(summary.unresolvedReviewThreadsCount > 0 ? ["unresolved_review_threads"] : []),
@@ -412,6 +419,14 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
412
419
  if (info.mergeable !== "MERGEABLE") blocking.push(`mergeable=${info.mergeable || "UNKNOWN"}`);
413
420
  if (checks.some((check) => check.bucket === "fail")) blocking.push("failing_checks");
414
421
  if (checks.some((check) => check.bucket === "pending")) blocking.push("pending_checks");
422
+ if (checks.some((check) => check.bucket === "cancel")) blocking.push("cancelled_checks");
423
+ if (
424
+ checks.some(
425
+ (check) => !["pass", "fail", "pending", "skipping", "cancel"].includes(check.bucket),
426
+ )
427
+ ) {
428
+ blocking.push("unknown_checks");
429
+ }
415
430
  if (summary.unresolvedReviewThreadsCount > 0) blocking.push("unresolved_threads");
416
431
  if (info.reviewDecision !== "" && info.reviewDecision !== "APPROVED") {
417
432
  blocking.push(`review=${info.reviewDecision}`);
@@ -899,18 +914,20 @@ export const prChecksFailedCommand = Command.make(
899
914
  ),
900
915
  repo: repoOption,
901
916
  withLogs: Flag.Boolean("with-logs").pipe(
902
- Flag.withDescription(
903
- "Inline the failed-step logs for each failed check so no follow-up job-logs call is needed",
904
- ),
917
+ Flag.withDescription("Inline a short summary from each failed step"),
918
+ Flag.withDefault(false),
919
+ ),
920
+ fullLogs: Flag.Boolean("full-logs").pipe(
921
+ Flag.withDescription("Inline all failed-step log lines; implies --with-logs"),
905
922
  Flag.withDefault(false),
906
923
  ),
907
924
  },
908
- ({ format, pr, repo, withLogs }) =>
925
+ ({ format, pr, repo, withLogs, fullLogs }) =>
909
926
  withRepo(
910
927
  repo,
911
928
  Effect.gen(function* () {
912
929
  const prNumber = Option.getOrNull(pr);
913
- const checks = yield* fetchFailedChecks(prNumber, withLogs);
930
+ const checks = yield* fetchFailedChecks(prNumber, withLogs, fullLogs);
914
931
  yield* logFormatted(checks, format);
915
932
  }),
916
933
  ),
@@ -924,13 +941,17 @@ export const prWatchCommand = Command.make(
924
941
  format: Flag.Literals("format", ["jsonl"]).pipe(Flag.withDefault("jsonl")),
925
942
  interval: Flag.Int("interval").pipe(
926
943
  Flag.withDefault(5),
944
+ Flag.withDescription("Polling interval in seconds (default: 5, range: 1..86400)"),
927
945
  Flag.filter(
928
- (n) => n >= 1 && n <= 60,
929
- () => "--interval must be 1..60 seconds",
946
+ (n) => n >= 1 && n <= 86400,
947
+ () => "--interval must be 1..86400 seconds",
930
948
  ),
931
949
  ),
932
950
  timeout: Flag.Int("timeout").pipe(
933
- Flag.withDefault(CI_CHECK_WATCH_TIMEOUT_MS / 1000),
951
+ Flag.withDefault(PR_WATCH_TIMEOUT_SECONDS),
952
+ Flag.withDescription(
953
+ "Timeout in seconds (default: 1800, minimum 1); timeout is not a CI verdict",
954
+ ),
934
955
  Flag.filter(
935
956
  (n) => n >= 1,
936
957
  () => "--timeout must be at least 1 second",
@@ -1,4 +1,5 @@
1
1
  import { Clock, Console, Duration, Effect, Option, Result } from "effect";
2
+ import { createHash } from "node:crypto";
2
3
 
3
4
  import type {
4
5
  BranchPRDetail,
@@ -17,10 +18,11 @@ import type {
17
18
  } from "#gh/types";
18
19
 
19
20
  import type { GitHubAuthError, GitHubNotFoundError } from "#gh/errors";
20
- import { GitHubCommandError, GitHubMergeError } from "#gh/errors";
21
+ import { GitHubCommandError, GitHubMergeError, GitHubTimeoutError } from "#gh/errors";
21
22
  import { GitHubService } from "#gh/service";
23
+ import { resolveGitHubToken } from "#gh/api";
22
24
  import type { GhResult } from "#gh/service";
23
- import { logText } from "#shared";
25
+ import { logFormatted, logText } from "#shared";
24
26
  import { redactSensitiveText } from "#shared/content-security";
25
27
 
26
28
  import type { ButStatusJson, PRViewJsonResult } from "./helpers";
@@ -35,7 +37,13 @@ import {
35
37
  formatLogEntries,
36
38
  listDispatchedRuns,
37
39
  parseRawJobLogs,
40
+ readJobLogApi,
38
41
  } from "#gh/workflow";
42
+ import {
43
+ CheckStatusCacheDirectory,
44
+ readCheckStatusCache,
45
+ writeCheckStatusCache,
46
+ } from "./check-status-cache";
39
47
 
40
48
  const CHECK_JSON_FIELDS = "name,state,bucket,link";
41
49
  const LONG_LIVED_BRANCHES = new Set(["main", "master", "develop", "staging", "production"]);
@@ -218,7 +226,7 @@ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureCont
218
226
  "view",
219
227
  String(runId),
220
228
  "--json",
221
- "databaseId,attempt,url,workflowName,status,conclusion,jobs",
229
+ "databaseId,attempt,headSha,url,workflowName,status,conclusion,jobs",
222
230
  ])
223
231
  .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
224
232
 
@@ -244,14 +252,16 @@ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureCont
244
252
  const context: FailedCheckRunContext = {
245
253
  runId: run.databaseId,
246
254
  attempt: run.attempt ?? null,
255
+ headSha: run.headSha ?? null,
247
256
  url: run.url,
248
257
  workflowName: run.workflowName,
249
258
  status: run.status,
250
259
  conclusion: run.conclusion,
251
260
  failedJobs,
252
261
  };
262
+ const stepsByJob = new Map(run.jobs.map((job) => [job.databaseId, job.steps] as const));
253
263
 
254
- return context;
264
+ return { context, stepsByJob };
255
265
  });
256
266
 
257
267
  // `gh pr checks` exits 1 on an *empty* result ("no checks reported on the 'x' branch"). Zero checks
@@ -265,7 +275,35 @@ export const NO_CHECKS_REPORTED_RE = /no checks reported/i;
265
275
  const CHECK_REGISTRATION_POLL_SECONDS = 5;
266
276
  const CHECK_REGISTRATION_GRACE_SECONDS = 60;
267
277
 
268
- export const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (pr: number | null) {
278
+ const summarizeFailedStepEntries = (entries: Array<{ step: string; message: string }>) => {
279
+ const selected = entries
280
+ .map((entry, index) => ({
281
+ entry,
282
+ index,
283
+ rank: /assert|expected|received|actual|failed|failure|error|exception|fatal/i.test(
284
+ entry.message,
285
+ )
286
+ ? 0
287
+ : 1,
288
+ }))
289
+ .toSorted((a, b) => a.rank - b.rank || b.index - a.index)
290
+ .slice(0, 20)
291
+ .toSorted((a, b) => a.index - b.index);
292
+ const kept: Array<{ step: string; message: string }> = [];
293
+ for (const { entry } of selected.toReversed()) {
294
+ const clipped = { step: entry.step.slice(0, 80), message: entry.message.slice(0, 150) };
295
+ if (formatLogEntries([clipped, ...kept]).length <= 3_900) kept.unshift(clipped);
296
+ }
297
+ let output = formatLogEntries(kept);
298
+ if (kept.length < entries.length)
299
+ output += `\n… ${entries.length - kept.length} log lines omitted`;
300
+ return output;
301
+ };
302
+
303
+ export const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (
304
+ pr: number | null,
305
+ cache?: { headSha: string; watchPending?: boolean },
306
+ ) {
269
307
  const gh = yield* GitHubService;
270
308
 
271
309
  const args = ["pr", "checks"];
@@ -273,7 +311,43 @@ export const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (pr
273
311
  args.push(String(pr));
274
312
  }
275
313
 
276
- return yield* gh
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
+ }
348
+
349
+ const fetchedAt = yield* Clock.currentTimeMillis;
350
+ const checks = yield* gh
277
351
  .runGhJson<CheckResult[]>([...args, "--json", CHECK_JSON_FIELDS])
278
352
  .pipe(
279
353
  Effect.catchTag("GitHubCommandError", (error) =>
@@ -282,6 +356,20 @@ export const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (pr
282
356
  : Effect.fail(error),
283
357
  ),
284
358
  );
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));
371
+ }
372
+ return checks;
285
373
  });
286
374
 
287
375
  const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function* (
@@ -289,6 +377,7 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
289
377
  checks: CheckResult[],
290
378
  options: {
291
379
  withLogs: boolean;
380
+ fullLogs?: boolean;
292
381
  evidence?: { headSha: string | null; baseSha: string | null } | null;
293
382
  } = { withLogs: false },
294
383
  ) {
@@ -311,26 +400,22 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
311
400
  Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)),
312
401
  ));
313
402
 
314
- const runContexts = new Map<number, FailedCheckRunContext | null>();
315
- const contexts = yield* Effect.forEach(
316
- runIds,
317
- (runId) =>
318
- fetchWorkflowRunFailureContext(runId).pipe(
319
- Effect.map((context) => [runId, context] as const),
320
- ),
321
- { concurrency: "unbounded" },
403
+ const runContexts = new Map(
404
+ yield* Effect.forEach(
405
+ runIds,
406
+ (runId) =>
407
+ fetchWorkflowRunFailureContext(runId).pipe(Effect.map((found) => [runId, found] as const)),
408
+ { concurrency: "unbounded" },
409
+ ),
322
410
  );
323
411
 
324
- for (const [runId, context] of contexts) {
325
- runContexts.set(runId, context);
326
- }
327
-
328
412
  const enrichedFailedChecks: FailedCheckDetail[] = yield* Effect.forEach(
329
413
  failedChecks,
330
414
  (check) =>
331
415
  Effect.gen(function* () {
332
416
  const runId = extractRunIdFromCheckLink(check.link);
333
- const run = runId === null ? null : (runContexts.get(runId) ?? null);
417
+ const found = runId === null ? null : (runContexts.get(runId) ?? null);
418
+ const run = found?.context ?? null;
334
419
  const detail: FailedCheckDetail = { ...check, runId, run };
335
420
 
336
421
  if (!options.withLogs || runId === null) {
@@ -347,6 +432,7 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
347
432
  job: matchedJob.name,
348
433
  jobId: matchedJob.databaseId,
349
434
  failedStepNames: matchedJob.failedSteps,
435
+ steps: found?.stepsByJob.get(matchedJob.databaseId) ?? [],
350
436
  failedStepsOnly: true,
351
437
  format: "json",
352
438
  repo: null,
@@ -355,12 +441,20 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
355
441
  if (failedStepLogs === null || !("entries" in failedStepLogs) || !failedStepLogs.entries) {
356
442
  return detail;
357
443
  }
358
- const formatted = formatLogEntries(failedStepLogs.entries);
444
+ const formatted = options.fullLogs
445
+ ? formatLogEntries(failedStepLogs.entries)
446
+ : summarizeFailedStepEntries(failedStepLogs.entries);
359
447
  return formatted.length > 0
360
448
  ? {
361
449
  ...detail,
362
450
  failedStepLogs: formatted,
363
- diagnosis: diagnoseLogEntries(failedStepLogs.entries),
451
+ ...(run?.headSha !== null &&
452
+ run?.headSha !== undefined &&
453
+ evidence?.headSha !== null &&
454
+ evidence?.headSha !== undefined &&
455
+ run.headSha !== evidence.headSha
456
+ ? {}
457
+ : { diagnosis: diagnoseLogEntries(failedStepLogs.entries) }),
364
458
  }
365
459
  : detail;
366
460
  }),
@@ -376,10 +470,12 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
376
470
  }
377
471
 
378
472
  const commands = [`bun agent-tools-gh workflow view --run ${check.runId}`];
379
- const firstFailedJob = check.run?.failedJobs[0];
380
- if (firstFailedJob) {
473
+ const matchedJob = check.run
474
+ ? matchFailedJobForCheck(check.name, check.run.failedJobs)
475
+ : null;
476
+ if (matchedJob) {
381
477
  commands.push(
382
- `bun agent-tools-gh workflow job-logs --run ${check.runId} --job ${JSON.stringify(firstFailedJob.name)} --failed-steps-only`,
478
+ `bun agent-tools-gh workflow job-logs --run ${check.runId} --job ${JSON.stringify(matchedJob.name)} --failed-steps-only`,
383
479
  );
384
480
  }
385
481
 
@@ -398,6 +494,20 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
398
494
  ? `Detected ${failedChecks.length} failed check(s) while ${pendingChecks.length} check(s) are still running.`
399
495
  : `Detected ${failedChecks.length} failed check(s).`;
400
496
 
497
+ let remainingLogCharacters = 16_000;
498
+ const reportFailedChecks = options.fullLogs
499
+ ? enrichedFailedChecks
500
+ : enrichedFailedChecks.map((check) => {
501
+ if (check.failedStepLogs === undefined) return check;
502
+ const limit = Math.max(0, remainingLogCharacters);
503
+ remainingLogCharacters -= check.failedStepLogs.length;
504
+ return check.failedStepLogs.length <= limit
505
+ ? check
506
+ : Object.assign({}, check, {
507
+ failedStepLogs: `${check.failedStepLogs.slice(0, Math.max(0, limit - 40))}\n… report log summary omitted`,
508
+ });
509
+ });
510
+
401
511
  const hint =
402
512
  failedChecks.length === 0
403
513
  ? pendingChecks.length > 0
@@ -417,7 +527,7 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
417
527
  pending: pendingChecks.length,
418
528
  passed: passedChecks.length,
419
529
  },
420
- failedChecks: enrichedFailedChecks,
530
+ failedChecks: reportFailedChecks,
421
531
  pendingChecks,
422
532
  hint,
423
533
  nextCommands,
@@ -1432,13 +1542,8 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
1432
1542
  watchArgs.push("--fail-fast");
1433
1543
  }
1434
1544
 
1435
- // Block for the caller's requested --timeout (no artificial cap — blocking isn't the problem;
1436
- // --timeout is validated >= 1s at the CLI boundary). On timeout return a snapshot, never
1437
- // nothing — that was the actual token-wasting bug.
1438
- // A push registers its checks a moment after the ref lands, and `gh pr checks --watch`
1439
- // exits non-zero in that window. A watch was asked to wait, so keep waiting: the outer
1440
- // timeout still bounds it, and a PR that genuinely has no checks returns an empty
1441
- // snapshot at that deadline rather than an error.
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.
1442
1547
  let watchResult: GhResult | null = null;
1443
1548
  let registered = false;
1444
1549
  let graceExpired = false;
@@ -1489,12 +1594,14 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
1489
1594
  `likely dropped, and re-watching will find nothing again — dispatch them on the PR ` +
1490
1595
  `head instead:\n agent-tools-gh pr trigger-checks --pr ${prRef} --workflow <file.yml>`,
1491
1596
  );
1492
- } else if (!quiet && watchOutcome === null && results.some((c) => c.bucket === "pending")) {
1493
- const pending = results.filter((c) => c.bucket === "pending").length;
1494
- yield* Console.warn(
1495
- `ℹ️ Watch timed out after ${timeoutSeconds}s; ${pending} check(s) still pending (snapshot returned). ` +
1496
- `Re-run to keep watching:\n ${buildChecksCommand(pr, true)}`,
1497
- );
1597
+ }
1598
+ if (watchOutcome === null && results.some((check) => check.bucket === "pending")) {
1599
+ const pending = results.filter((check) => check.bucket === "pending").length;
1600
+ yield* logFormatted(results, quiet ? "json" : "toon");
1601
+ return yield* new GitHubTimeoutError({
1602
+ message: `Watch timed out, ${pending} pending after ${timeoutSeconds}s. This is not a CI verdict.`,
1603
+ timeoutMs: timeoutSeconds * 1000,
1604
+ });
1498
1605
  }
1499
1606
  return results;
1500
1607
  }
@@ -1592,6 +1699,7 @@ export const collectWithStableState = <S, A, E1, R1, E2, R2>(
1592
1699
  export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (
1593
1700
  pr: number | null,
1594
1701
  withLogs = false,
1702
+ fullLogs = false,
1595
1703
  ) {
1596
1704
  const initial = yield* viewPR(pr);
1597
1705
  const snapshot = yield* collectWithStableState(
@@ -1605,7 +1713,8 @@ export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (
1605
1713
  );
1606
1714
  if (snapshot !== null) {
1607
1715
  return yield* buildFailedChecksReport(snapshot.state.number, snapshot.value, {
1608
- withLogs,
1716
+ withLogs: withLogs || fullLogs,
1717
+ fullLogs,
1609
1718
  evidence: { headSha: snapshot.state.headSha, baseSha: snapshot.state.baseSha },
1610
1719
  });
1611
1720
  }
@@ -1711,7 +1820,14 @@ export const watchPRs = Effect.fn("pr.watchPRs")(function* (
1711
1820
  const initial = yield* watchPRState(pr);
1712
1821
  const stableResult = yield* collectWithStableState(
1713
1822
  initial,
1714
- () => requireDeadline.pipe(Effect.andThen(fetchCheckResults(pr))),
1823
+ (state) =>
1824
+ requireDeadline.pipe(
1825
+ Effect.andThen(
1826
+ state.headRefOid
1827
+ ? fetchCheckResults(pr, { headSha: state.headRefOid, watchPending: true })
1828
+ : fetchCheckResults(pr),
1829
+ ),
1830
+ ),
1715
1831
  () => requireDeadline.pipe(Effect.andThen(watchPRState(pr))),
1716
1832
  (before, after) => before.headRefOid === after.headRefOid && before.state === after.state,
1717
1833
  ).pipe(Effect.result);
@@ -1770,7 +1886,13 @@ export const watchPRs = Effect.fn("pr.watchPRs")(function* (
1770
1886
  timedOut = true;
1771
1887
  break;
1772
1888
  }
1773
- const state = yield* snapshot(number);
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
+ );
1774
1896
  if (state === "timeout") {
1775
1897
  timedOut = true;
1776
1898
  break;
@@ -1870,12 +1992,27 @@ export const watchPRs = Effect.fn("pr.watchPRs")(function* (
1870
1992
  }),
1871
1993
  step: () => undefined,
1872
1994
  });
1995
+ const watcherTimedOut = terminal.size < prs.length;
1873
1996
  yield* emit({
1874
1997
  type: "watcher_terminal",
1875
1998
  repo: `${repo.owner}/${repo.name}`,
1876
- status: terminal.size === prs.length ? "terminal" : "timeout",
1999
+ status: watcherTimedOut ? "timeout" : "terminal",
1877
2000
  terminal: [...terminal].toSorted((a, b) => a - b),
2001
+ ...(watcherTimedOut
2002
+ ? {
2003
+ verdict: null,
2004
+ message: `Watch timed out, ${prs.length - terminal.size} pending. This is not a CI verdict.`,
2005
+ timeoutSeconds: options.timeoutSeconds,
2006
+ pending: prs.filter((number) => !terminal.has(number)).toSorted((a, b) => a - b),
2007
+ }
2008
+ : {}),
1878
2009
  });
2010
+ if (watcherTimedOut) {
2011
+ return yield* new GitHubTimeoutError({
2012
+ message: `Watch timed out, ${prs.length - terminal.size} pending. This is not a CI verdict.`,
2013
+ timeoutMs: options.timeoutSeconds * 1000,
2014
+ });
2015
+ }
1879
2016
  });
1880
2017
 
1881
2018
  type RerunDiscovery = RerunCheckAttempt & { status?: string };
@@ -1906,14 +2043,13 @@ const readJobDiagnosis = Effect.fn("pr.readJobDiagnosis")(function* (
1906
2043
  attempt: number,
1907
2044
  repo: string,
1908
2045
  ) {
1909
- const gh = yield* GitHubService;
1910
2046
  const jobs = yield* fetchAttemptJobs(runId, attempt, repo);
1911
2047
  if (jobs === null) return null;
1912
2048
  const matches = jobs.filter((job) => job.name === jobName);
1913
2049
  if (matches.length !== 1) return null;
1914
- const logs = yield* gh
1915
- .runGh(["api", `repos/${repo}/actions/jobs/${matches[0]?.id}/logs`])
1916
- .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
2050
+ const logs = yield* readJobLogApi(`repos/${repo}/actions/jobs/${matches[0]?.id}/logs`).pipe(
2051
+ Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)),
2052
+ );
1917
2053
  return logs === null ? null : diagnoseLogEntries(parseRawJobLogs(logs.stdout));
1918
2054
  });
1919
2055
 
@@ -224,6 +224,35 @@ export class GitHubService extends Context.Service<
224
224
  });
225
225
  }
226
226
 
227
+ if (/rate limit|abuse detection|HTTP 429/i.test(result.stderr)) {
228
+ const resource =
229
+ args[0] === "api"
230
+ ? args[1]?.replace(/^\//, "") === "graphql"
231
+ ? "graphql"
232
+ : args[1]?.replace(/^\//, "").startsWith("search/")
233
+ ? "search"
234
+ : "core"
235
+ : undefined;
236
+ const quotaFilter =
237
+ resource === undefined
238
+ ? '.resources | to_entries[] | "\\(.key): reset time: \\(.value.reset | todateiso8601); remaining quota: \\(.value.remaining)"'
239
+ : `.resources.${resource} | "reset time: \\(.reset | todateiso8601); remaining quota: \\(.remaining)"`;
240
+ const quota = yield* executeGh(["api", "rate_limit", "--jq", quotaFilter]).pipe(
241
+ Effect.orElseSucceed(() => null),
242
+ );
243
+ const detail =
244
+ quota?.exitCode === 0 && quota.stdout.trim()
245
+ ? quota.stdout.trim()
246
+ : "reset time: unavailable; remaining quota: unavailable (quota lookup failed)";
247
+ return yield* new GitHubCommandError({
248
+ message: `${result.stderr.trim()}; ${detail}`,
249
+ command: `gh ${args.join(" ")}`,
250
+ exitCode: result.exitCode,
251
+ stderr: result.stderr,
252
+ hint: "Wait until the quota resets before you retry. The failed command was not replayed.",
253
+ });
254
+ }
255
+
227
256
  // Transient network/5xx — flag retryable so the wrapper below can replay safe reads.
228
257
  if (NETWORK_ERROR_RE.test(result.stderr)) {
229
258
  return yield* new GitHubCommandError({