@blogic-cz/agent-tools 0.14.58 → 0.14.59

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.
@@ -9,6 +9,10 @@ import type {
9
9
  MergeStrategy,
10
10
  PRInfo,
11
11
  PRViewInfo,
12
+ RerunCheckAttempt,
13
+ RerunChecksReport,
14
+ RerunChecksRun,
15
+ RerunRetryEvidence,
12
16
  WorkflowRunDetail,
13
17
  } from "#gh/types";
14
18
 
@@ -17,9 +21,10 @@ import { GitHubService } from "#gh/service";
17
21
 
18
22
  import type { ButStatusJson, PRViewJsonResult } from "./helpers";
19
23
  import { runLocalCommand } from "./helpers";
20
- import { fetchJobLogs } from "#gh/workflow";
24
+ import { diagnoseLogEntries, fetchJobLogs, formatLogEntries, parseRawJobLogs } from "#gh/workflow";
21
25
 
22
26
  const CHECK_JSON_FIELDS = "name,state,bucket,link";
27
+ const STABLE_SNAPSHOT_ATTEMPTS = 3;
23
28
  const GITHUB_ACTIONS_RUN_ID_RE = /github\.com\/[^/]+\/[^/]+\/actions\/runs\/(\d+)/;
24
29
 
25
30
  const validatePRTitle = Effect.fn("pr.validatePRTitle")(function* (title: string) {
@@ -61,16 +66,6 @@ const validatePRTitle = Effect.fn("pr.validatePRTitle")(function* (title: string
61
66
  });
62
67
  });
63
68
 
64
- type WorkflowRunJobsForRerun = {
65
- databaseId: number;
66
- jobs: Array<{
67
- databaseId: number;
68
- name: string;
69
- status: string;
70
- conclusion: string | null;
71
- }>;
72
- };
73
-
74
69
  const buildChecksCommand = (pr: number | null, includeWatch: boolean): string =>
75
70
  `bun agent-tools-gh pr checks${pr !== null ? ` --pr ${pr}` : ""}${includeWatch ? " --watch" : ""}`;
76
71
 
@@ -117,7 +112,7 @@ const failedJobsMatchingCheck = <Job extends { name: string }>(
117
112
 
118
113
  const resolveJobIdsForFailedChecks = (
119
114
  checks: CheckResult[],
120
- jobs: WorkflowRunJobsForRerun["jobs"],
115
+ jobs: RerunCheckAttempt["jobs"],
121
116
  ): number[] | null => {
122
117
  const failedJobs = jobs.filter(isFailedWorkflowJob);
123
118
  const jobIds = new Set<number>();
@@ -154,7 +149,7 @@ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureCont
154
149
  "view",
155
150
  String(runId),
156
151
  "--json",
157
- "databaseId,url,workflowName,status,conclusion,jobs",
152
+ "databaseId,attempt,url,workflowName,status,conclusion,jobs",
158
153
  ])
159
154
  .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
160
155
 
@@ -166,6 +161,8 @@ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureCont
166
161
  .filter((job) => job.conclusion === "failure" || job.status === "failure")
167
162
  .map((job) => ({
168
163
  databaseId: job.databaseId,
164
+ jobId: job.databaseId,
165
+ checkId: null,
169
166
  name: job.name,
170
167
  status: job.status,
171
168
  conclusion: job.conclusion,
@@ -177,6 +174,7 @@ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureCont
177
174
 
178
175
  const context: FailedCheckRunContext = {
179
176
  runId: run.databaseId,
177
+ attempt: run.attempt ?? null,
180
178
  url: run.url,
181
179
  workflowName: run.workflowName,
182
180
  status: run.status,
@@ -201,7 +199,10 @@ const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (pr: numbe
201
199
  const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function* (
202
200
  pr: number | null,
203
201
  checks: CheckResult[],
204
- options: { withLogs: boolean } = { withLogs: false },
202
+ options: {
203
+ withLogs: boolean;
204
+ evidence?: { headSha: string | null; baseSha: string | null } | null;
205
+ } = { withLogs: false },
205
206
  ) {
206
207
  const failedChecks = checks.filter((check) => check.bucket === "fail");
207
208
  const pendingChecks = checks.filter((check) => check.bucket === "pending");
@@ -215,6 +216,13 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
215
216
  ),
216
217
  ];
217
218
 
219
+ const evidence =
220
+ options.evidence ??
221
+ (yield* viewPR(pr).pipe(
222
+ Effect.map((info) => ({ headSha: info.headSha ?? null, baseSha: info.baseSha ?? null })),
223
+ Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)),
224
+ ));
225
+
218
226
  const runContexts = new Map<number, FailedCheckRunContext | null>();
219
227
  const contexts = yield* Effect.forEach(
220
228
  runIds,
@@ -252,14 +260,21 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
252
260
  jobId: matchedJob.databaseId,
253
261
  failedStepNames: matchedJob.failedSteps,
254
262
  failedStepsOnly: true,
255
- format: "text",
263
+ format: "json",
256
264
  repo: null,
257
- }).pipe(
258
- Effect.map((result) => ("formatted" in result ? result.formatted : "")),
259
- Effect.catch(() => Effect.succeed("")),
260
- );
265
+ }).pipe(Effect.catch(() => Effect.succeed(null)));
261
266
 
262
- return failedStepLogs && failedStepLogs.length > 0 ? { ...detail, failedStepLogs } : detail;
267
+ if (failedStepLogs === null || !("entries" in failedStepLogs) || !failedStepLogs.entries) {
268
+ return detail;
269
+ }
270
+ const formatted = formatLogEntries(failedStepLogs.entries);
271
+ return formatted.length > 0
272
+ ? {
273
+ ...detail,
274
+ failedStepLogs: formatted,
275
+ diagnosis: diagnoseLogEntries(failedStepLogs.entries),
276
+ }
277
+ : detail;
263
278
  }),
264
279
  { concurrency: 5 },
265
280
  );
@@ -305,6 +320,7 @@ const buildFailedChecksReport = Effect.fn("pr.buildFailedChecksReport")(function
305
320
  : "Inspect the failed workflow run and failed job logs to get the first concrete error, then rerun only if the failure is understood.";
306
321
 
307
322
  return {
323
+ evidence,
308
324
  status: failedChecks.length > 0 ? "failed" : "no_failures",
309
325
  message,
310
326
  summary: {
@@ -329,11 +345,15 @@ export const viewPR = Effect.fn("pr.viewPR")(function* (prNumber: number | null)
329
345
  }
330
346
  args.push(
331
347
  "--json",
332
- "number,url,title,headRefName,baseRefName,state,isDraft,mergeable,body,author,reviewDecision,reviewRequests",
348
+ "number,url,title,headRefName,baseRefName,headRefOid,baseRefOid,state,isDraft,mergeable,body,author,reviewDecision,reviewRequests",
333
349
  );
334
350
 
335
- const info = yield* gh.runGhJson<PRViewInfo>(args);
336
- return info;
351
+ const info = yield* gh.runGhJson<PRViewInfo & { headRefOid?: string; baseRefOid?: string }>(args);
352
+ return {
353
+ ...info,
354
+ headSha: info.headRefOid ?? info.headSha ?? null,
355
+ baseSha: info.baseRefOid ?? info.baseSha ?? null,
356
+ };
337
357
  });
338
358
 
339
359
  export const detectPRStatus = Effect.fn("pr.detectPRStatus")(function* () {
@@ -901,6 +921,7 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
901
921
  watch: boolean,
902
922
  failFast: boolean,
903
923
  timeoutSeconds: number,
924
+ quiet = false,
904
925
  ) {
905
926
  const gh = yield* GitHubService;
906
927
 
@@ -926,7 +947,7 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
926
947
  );
927
948
 
928
949
  const results = yield* fetchCheckResults(pr);
929
- if (watchOutcome === null && results.some((c) => c.bucket === "pending")) {
950
+ if (!quiet && watchOutcome === null && results.some((c) => c.bucket === "pending")) {
930
951
  const pending = results.filter((c) => c.bucket === "pending").length;
931
952
  yield* Console.warn(
932
953
  `ℹ️ Watch timed out after ${timeoutSeconds}s; ${pending} check(s) still pending (snapshot returned). ` +
@@ -937,7 +958,7 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
937
958
  }
938
959
 
939
960
  const results = yield* fetchCheckResults(pr);
940
- if (results.some((c) => c.bucket === "pending")) {
961
+ if (!quiet && results.some((c) => c.bucket === "pending")) {
941
962
  yield* Console.warn(
942
963
  `ℹ️ Some checks are still running. Re-run to refresh — each call returns the latest snapshot:\n` +
943
964
  ` ${buildChecksCommand(pr, false)}`,
@@ -946,12 +967,49 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
946
967
  return results;
947
968
  });
948
969
 
970
+ export const collectWithStableState = <S, A, E1, R1, E2, R2>(
971
+ initial: S,
972
+ collect: (state: S) => Effect.Effect<A, E1, R1>,
973
+ refresh: (state: S) => Effect.Effect<S, E2, R2>,
974
+ unchanged: (before: S, after: S) => boolean,
975
+ ): Effect.Effect<{ state: S; value: A } | null, E1 | E2, R1 | R2> =>
976
+ Effect.gen(function* () {
977
+ let before = initial;
978
+ for (let attempt = 0; attempt < STABLE_SNAPSHOT_ATTEMPTS; attempt += 1) {
979
+ const value = yield* collect(before);
980
+ const after = yield* refresh(before);
981
+ if (unchanged(before, after)) return { state: after, value };
982
+ before = after;
983
+ }
984
+ return null;
985
+ });
986
+
949
987
  export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (
950
988
  pr: number | null,
951
989
  withLogs = false,
952
990
  ) {
953
- const checks = yield* fetchCheckResults(pr);
954
- return yield* buildFailedChecksReport(pr, checks, { withLogs });
991
+ const initial = yield* viewPR(pr);
992
+ const snapshot = yield* collectWithStableState(
993
+ initial,
994
+ (info) => fetchCheckResults(info.number),
995
+ (info) => viewPR(info.number),
996
+ (before, after) => after.headSha === before.headSha,
997
+ );
998
+ if (snapshot !== null) {
999
+ return yield* buildFailedChecksReport(snapshot.state.number, snapshot.value, {
1000
+ withLogs,
1001
+ evidence: { headSha: snapshot.state.headSha, baseSha: snapshot.state.baseSha },
1002
+ });
1003
+ }
1004
+ return yield* Effect.fail(
1005
+ new GitHubCommandError({
1006
+ command: "gh-tool pr checks-failed",
1007
+ exitCode: 1,
1008
+ stderr: "",
1009
+ message: "PR head changed repeatedly while collecting checks",
1010
+ retryable: true,
1011
+ }),
1012
+ );
955
1013
  });
956
1014
 
957
1015
  export const fetchChecksForCommand = Effect.fn("pr.fetchChecksForCommand")(function* (
@@ -959,12 +1017,13 @@ export const fetchChecksForCommand = Effect.fn("pr.fetchChecksForCommand")(funct
959
1017
  watch: boolean,
960
1018
  failFast: boolean,
961
1019
  timeoutSeconds: number,
1020
+ quiet = false,
962
1021
  ) {
963
1022
  if (!watch) {
964
- return yield* fetchChecks(pr, false, failFast, timeoutSeconds);
1023
+ return yield* fetchChecks(pr, false, failFast, timeoutSeconds, quiet);
965
1024
  }
966
1025
 
967
- const watchedChecks = yield* fetchChecks(pr, true, failFast, timeoutSeconds).pipe(
1026
+ const watchedChecks = yield* fetchChecks(pr, true, failFast, timeoutSeconds, quiet).pipe(
968
1027
  Effect.result,
969
1028
  Effect.flatMap((result) => {
970
1029
  if (Result.isFailure(result) && result.failure._tag !== "GitHubCommandError") {
@@ -987,84 +1046,477 @@ export const fetchChecksForCommand = Effect.fn("pr.fetchChecksForCommand")(funct
987
1046
  return yield* Effect.fail(watchedChecks.failure);
988
1047
  });
989
1048
 
1049
+ type WatchPR = { number: number; state: string; headRefOid?: string | null };
1050
+ type WatchRun = {
1051
+ databaseId: number;
1052
+ attempt?: number | null;
1053
+ headSha?: string | null;
1054
+ jobs?: Array<{ databaseId: number; name: string }>;
1055
+ };
1056
+
1057
+ const watchPRState = Effect.fn("pr.watchPRState")(function* (pr: number) {
1058
+ const gh = yield* GitHubService;
1059
+ return yield* gh.runGhJson<WatchPR>([
1060
+ "pr",
1061
+ "view",
1062
+ String(pr),
1063
+ "--json",
1064
+ "number,state,headRefOid",
1065
+ ]);
1066
+ });
1067
+
1068
+ export const watchPRs = Effect.fn("pr.watchPRs")(function* (
1069
+ prs: readonly number[],
1070
+ options: { intervalSeconds: number; timeoutSeconds: number; until?: "terminal" },
1071
+ emit: (event: Record<string, unknown>) => Effect.Effect<void>,
1072
+ ) {
1073
+ if ((options.until ?? "terminal") !== "terminal") {
1074
+ return yield* Effect.fail(
1075
+ new GitHubCommandError({
1076
+ command: "gh-tool pr watch",
1077
+ exitCode: 1,
1078
+ stderr: "",
1079
+ message: `Unsupported --until value: ${String(options.until)}`,
1080
+ }),
1081
+ );
1082
+ }
1083
+ const gh = yield* GitHubService;
1084
+ const repo = yield* gh.getRepoInfo();
1085
+ const currentIdentity = new Map<string, string>();
1086
+ const lastRevision = new Map<string, string>();
1087
+ const emptySnapshots = new Map<string, number>();
1088
+ const checksObserved = new Set<string>();
1089
+ const headByPR = new Map<number, string>();
1090
+ const terminal = new Set<number>();
1091
+ const started = Number(yield* Clock.currentTimeMillis);
1092
+ const deadline = started + options.timeoutSeconds * 1000;
1093
+ const beforeDeadline = Effect.gen(function* () {
1094
+ return Number(yield* Clock.currentTimeMillis) < deadline;
1095
+ });
1096
+
1097
+ const snapshot = (pr: number) =>
1098
+ Effect.gen(function* () {
1099
+ const requireDeadline = Effect.gen(function* () {
1100
+ if (!(yield* beforeDeadline)) return yield* Effect.fail("timeout" as const);
1101
+ });
1102
+ yield* requireDeadline;
1103
+ const initial = yield* watchPRState(pr);
1104
+ const stableResult = yield* collectWithStableState(
1105
+ initial,
1106
+ () => requireDeadline.pipe(Effect.andThen(fetchCheckResults(pr))),
1107
+ () => requireDeadline.pipe(Effect.andThen(watchPRState(pr))),
1108
+ (before, after) => before.headRefOid === after.headRefOid && before.state === after.state,
1109
+ ).pipe(Effect.result);
1110
+ if (Result.isFailure(stableResult)) {
1111
+ if (stableResult.failure === "timeout") return "timeout" as const;
1112
+ return yield* Effect.fail(stableResult.failure);
1113
+ }
1114
+ if (stableResult.success === null) return null;
1115
+ const { state: after, value: checks } = stableResult.success;
1116
+ const head = after.headRefOid ?? null;
1117
+ const runs = new Map<number, WatchRun | null>();
1118
+ yield* Effect.forEach(
1119
+ [...new Set(checks.map((check) => extractRunIdFromCheckLink(check.link)))].filter(
1120
+ (runId): runId is number => runId !== null,
1121
+ ),
1122
+ (runId) =>
1123
+ Effect.gen(function* () {
1124
+ if (!(yield* beforeDeadline)) return;
1125
+ const run = yield* gh
1126
+ .runGhJson<WatchRun>([
1127
+ "run",
1128
+ "view",
1129
+ String(runId),
1130
+ "--json",
1131
+ "databaseId,attempt,headSha,jobs",
1132
+ ])
1133
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
1134
+ runs.set(runId, run);
1135
+ }),
1136
+ { concurrency: 5 },
1137
+ );
1138
+ if (!(yield* beforeDeadline)) return "timeout" as const;
1139
+ return {
1140
+ pr: after,
1141
+ head,
1142
+ checks: checks
1143
+ .map((check) => ({
1144
+ check,
1145
+ run:
1146
+ extractRunIdFromCheckLink(check.link) === null
1147
+ ? null
1148
+ : (runs.get(extractRunIdFromCheckLink(check.link) ?? -1) ?? null),
1149
+ }))
1150
+ .filter(({ run }) => run === null || run.headSha === null || run.headSha === head),
1151
+ };
1152
+ });
1153
+
1154
+ let timedOut = false;
1155
+ yield* Effect.whileLoop({
1156
+ while: () => terminal.size < prs.length && !timedOut,
1157
+ body: () =>
1158
+ Effect.gen(function* () {
1159
+ for (const number of prs) {
1160
+ if (terminal.has(number)) continue;
1161
+ if (!(yield* beforeDeadline)) {
1162
+ timedOut = true;
1163
+ break;
1164
+ }
1165
+ const state = yield* snapshot(number);
1166
+ if (state === "timeout") {
1167
+ timedOut = true;
1168
+ break;
1169
+ }
1170
+ if (state === null) continue;
1171
+ const headKey = `${number}/${state.head ?? ""}`;
1172
+ const oldHeadKey = headByPR.get(number);
1173
+ if (oldHeadKey !== undefined && oldHeadKey !== headKey) {
1174
+ checksObserved.delete(oldHeadKey);
1175
+ emptySnapshots.delete(oldHeadKey);
1176
+ }
1177
+ headByPR.set(number, headKey);
1178
+ if (state.checks.length > 0) {
1179
+ checksObserved.add(headKey);
1180
+ emptySnapshots.set(headKey, 0);
1181
+ } else {
1182
+ emptySnapshots.set(headKey, (emptySnapshots.get(headKey) ?? 0) + 1);
1183
+ }
1184
+ for (const { check, run } of state.checks) {
1185
+ const matches = failedJobsMatchingCheck(check.name, run?.jobs ?? []);
1186
+ const jobId = matches.length === 1 ? (matches[0]?.databaseId ?? null) : null;
1187
+ const fallback = jobId === null ? `${check.name}|${check.link}` : String(jobId);
1188
+ const identity = [
1189
+ repo.owner + "/" + repo.name,
1190
+ number,
1191
+ state.head ?? "",
1192
+ run?.databaseId ?? "external",
1193
+ run?.attempt ?? "",
1194
+ fallback,
1195
+ ].join("/");
1196
+ const logical = `${number}/${check.name}`;
1197
+ const previousIdentity = currentIdentity.get(logical);
1198
+ const revision = `${check.state}/${check.bucket}`;
1199
+ if (lastRevision.get(identity) !== revision) {
1200
+ const event: Record<string, unknown> = {
1201
+ type: "check",
1202
+ repo: `${repo.owner}/${repo.name}`,
1203
+ pr: number,
1204
+ headSha: state.head,
1205
+ runId: run?.databaseId ?? null,
1206
+ attempt: run?.attempt ?? null,
1207
+ jobId,
1208
+ checkId: null,
1209
+ name: check.name,
1210
+ state: check.state,
1211
+ bucket: check.bucket,
1212
+ link: check.link,
1213
+ identity,
1214
+ };
1215
+ if (previousIdentity !== undefined && previousIdentity !== identity) {
1216
+ event.supersedes = previousIdentity;
1217
+ }
1218
+ if (!(yield* beforeDeadline)) {
1219
+ timedOut = true;
1220
+ break;
1221
+ }
1222
+ yield* emit(event);
1223
+ lastRevision.set(identity, revision);
1224
+ }
1225
+ currentIdentity.set(logical, identity);
1226
+ }
1227
+ if (timedOut) break;
1228
+ // Empty snapshots need three consecutive observations, including after checks were seen.
1229
+ const hasTerminalCoverage =
1230
+ state.checks.length > 0 || (emptySnapshots.get(headKey) ?? 0) >= 3;
1231
+ const checksTerminal =
1232
+ state.checks.length === 0 ||
1233
+ state.checks.every(({ check }) => check.bucket !== "pending");
1234
+ if (state.pr.state !== "OPEN" || (hasTerminalCoverage && checksTerminal)) {
1235
+ if (!(yield* beforeDeadline)) {
1236
+ timedOut = true;
1237
+ break;
1238
+ }
1239
+ const identity = `${repo.owner}/${repo.name}/${number}/${state.head ?? ""}/terminal/${state.pr.state}`;
1240
+ yield* emit({
1241
+ type: "pr_terminal",
1242
+ repo: `${repo.owner}/${repo.name}`,
1243
+ pr: number,
1244
+ headSha: state.head,
1245
+ state: state.pr.state,
1246
+ checksObserved: checksObserved.has(headKey),
1247
+ identity,
1248
+ });
1249
+ terminal.add(number);
1250
+ }
1251
+ }
1252
+ const now = Number(yield* Clock.currentTimeMillis);
1253
+ timedOut = timedOut || (terminal.size < prs.length && now >= deadline);
1254
+ if (!timedOut && terminal.size < prs.length) {
1255
+ yield* Effect.sleep(
1256
+ Duration.millis(Math.min(options.intervalSeconds * 1000, deadline - now)),
1257
+ );
1258
+ }
1259
+ }),
1260
+ step: () => undefined,
1261
+ });
1262
+ yield* emit({
1263
+ type: "watcher_terminal",
1264
+ repo: `${repo.owner}/${repo.name}`,
1265
+ status: terminal.size === prs.length ? "terminal" : "timeout",
1266
+ terminal: [...terminal].toSorted((a, b) => a - b),
1267
+ });
1268
+ });
1269
+
1270
+ type RerunDiscovery = RerunCheckAttempt & { status?: string };
1271
+
1272
+ const fetchAttemptJobs = Effect.fn("pr.fetchAttemptJobs")(function* (
1273
+ runId: string,
1274
+ attempt: number,
1275
+ repo: string,
1276
+ ) {
1277
+ const gh = yield* GitHubService;
1278
+ const jobs: Array<{ id: number; name: string }> = [];
1279
+ for (let page = 1; ; page += 1) {
1280
+ const response = yield* gh
1281
+ .runGhJson<{ jobs?: Array<{ id: number; name: string }> }>([
1282
+ "api",
1283
+ `repos/${repo}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=100&page=${page}`,
1284
+ ])
1285
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
1286
+ if (response?.jobs === undefined) return null;
1287
+ jobs.push(...response.jobs);
1288
+ if (response.jobs.length < 100) return jobs;
1289
+ }
1290
+ });
1291
+
1292
+ const readJobDiagnosis = Effect.fn("pr.readJobDiagnosis")(function* (
1293
+ runId: string,
1294
+ jobName: string,
1295
+ attempt: number,
1296
+ repo: string,
1297
+ ) {
1298
+ const gh = yield* GitHubService;
1299
+ const jobs = yield* fetchAttemptJobs(runId, attempt, repo);
1300
+ if (jobs === null) return null;
1301
+ const matches = jobs.filter((job) => job.name === jobName);
1302
+ if (matches.length !== 1) return null;
1303
+ const logs = yield* gh
1304
+ .runGh(["api", `repos/${repo}/actions/jobs/${matches[0]?.id}/logs`])
1305
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
1306
+ return logs === null ? null : diagnoseLogEntries(parseRawJobLogs(logs.stdout));
1307
+ });
1308
+
1309
+ const discoverRerun = Effect.fn("pr.discoverRerun")(function* (
1310
+ runId: string,
1311
+ currentAttempt: number | null,
1312
+ currentJobIds: readonly number[],
1313
+ deadline: number,
1314
+ ) {
1315
+ const gh = yield* GitHubService;
1316
+ let latest: RerunDiscovery | null = null;
1317
+ while (Number(yield* Clock.currentTimeMillis) <= deadline) {
1318
+ latest = yield* gh
1319
+ .runGhJson<RerunDiscovery>(["run", "view", runId, "--json", "databaseId,attempt,status,jobs"])
1320
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
1321
+ if (
1322
+ latest !== null &&
1323
+ ((latest.attempt ?? 0) > (currentAttempt ?? 0) ||
1324
+ latest.jobs.some((job) => !currentJobIds.includes(job.databaseId)))
1325
+ ) {
1326
+ return latest;
1327
+ }
1328
+ const remaining = deadline - Number(yield* Clock.currentTimeMillis);
1329
+ if (remaining <= 0) return null;
1330
+ yield* Effect.sleep(Duration.millis(Math.min(1000, remaining)));
1331
+ }
1332
+ return null;
1333
+ });
1334
+
990
1335
  export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
991
1336
  pr: number | null,
992
1337
  failedOnly: boolean,
1338
+ options: { watch?: boolean; timeoutSeconds?: number } = {},
993
1339
  ) {
994
1340
  const gh = yield* GitHubService;
995
-
996
1341
  const checks = yield* fetchCheckResults(pr);
997
-
998
1342
  const targetChecks = failedOnly ? checks.filter((check) => check.bucket === "fail") : checks;
999
-
1000
- // Extract unique GitHub Actions run IDs from links
1001
- const runIds = new Set<string>();
1002
1343
  const checksByRun = new Map<string, CheckResult[]>();
1003
1344
  for (const check of targetChecks) {
1004
- const match = check.link.match(GITHUB_ACTIONS_RUN_ID_RE);
1005
- if (match?.[1]) {
1006
- runIds.add(match[1]);
1007
- const existing = checksByRun.get(match[1]) ?? [];
1008
- existing.push(check);
1009
- checksByRun.set(match[1], existing);
1010
- }
1345
+ const runId = check.link.match(GITHUB_ACTIONS_RUN_ID_RE)?.[1];
1346
+ if (runId !== undefined) checksByRun.set(runId, [...(checksByRun.get(runId) ?? []), check]);
1011
1347
  }
1012
-
1013
- if (runIds.size === 0) {
1014
- return {
1348
+ if (checksByRun.size === 0) {
1349
+ const report: RerunChecksReport = {
1015
1350
  rerun: 0,
1016
1351
  message: failedOnly
1017
1352
  ? "No failed GitHub Actions runs found to rerun"
1018
1353
  : "No GitHub Actions runs found to rerun",
1019
1354
  };
1355
+ return report;
1020
1356
  }
1021
1357
 
1022
- const results: Array<{
1358
+ const repoInfo = yield* gh.getRepoInfo();
1359
+ const repo = `${repoInfo.owner}/${repoInfo.name}`;
1360
+ const candidates: Array<{
1023
1361
  runId: string;
1024
- success: boolean;
1362
+ run: RerunCheckAttempt | null;
1363
+ evidence: RerunRetryEvidence;
1025
1364
  }> = [];
1026
- for (const runId of runIds) {
1027
- const success = yield* Effect.gen(function* () {
1028
- if (!failedOnly) {
1029
- return yield* gh.runGh(["run", "rerun", runId]).pipe(
1030
- Effect.map(() => true),
1031
- Effect.catch(() => Effect.succeed(false)),
1032
- );
1365
+ for (const [runId, runChecks] of checksByRun) {
1366
+ const run = yield* gh
1367
+ .runGhJson<RerunCheckAttempt>(["run", "view", runId, "--json", "databaseId,attempt,jobs"])
1368
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
1369
+ let evidence: RerunRetryEvidence = { state: "eligible", diagnosis: diagnoseLogEntries([]) };
1370
+ if (failedOnly) {
1371
+ const jobIds = run === null ? null : resolveJobIdsForFailedChecks(runChecks, run.jobs);
1372
+ if (
1373
+ run === null ||
1374
+ run.attempt === null ||
1375
+ run.attempt === undefined ||
1376
+ jobIds === null ||
1377
+ jobIds.length === 0
1378
+ ) {
1379
+ evidence = { state: "unavailable", reason: "failed jobs or current attempt unavailable" };
1380
+ } else {
1381
+ const targetJobs = run.jobs.filter((job) => jobIds.includes(job.databaseId));
1382
+ for (const job of targetJobs) {
1383
+ const current = yield* readJobDiagnosis(runId, job.name, run.attempt, repo);
1384
+ if (current === null) {
1385
+ evidence = {
1386
+ state: "unavailable",
1387
+ reason: `retry evidence unavailable for ${job.name}`,
1388
+ };
1389
+ break;
1390
+ }
1391
+ evidence = { state: "eligible", diagnosis: current };
1392
+ const retryablePreTest =
1393
+ current.testsStarted === false &&
1394
+ ["infrastructure", "network", "timeout"].includes(current.category);
1395
+ if (!retryablePreTest) continue;
1396
+ for (let attempt = 1; attempt < run.attempt; attempt += 1) {
1397
+ const prior = yield* readJobDiagnosis(runId, job.name, attempt, repo);
1398
+ if (prior === null) {
1399
+ evidence = {
1400
+ state: "unavailable",
1401
+ reason: `retry evidence unavailable for ${job.name} attempt ${attempt}`,
1402
+ };
1403
+ break;
1404
+ }
1405
+ if (prior.fingerprint === current.fingerprint) {
1406
+ evidence = {
1407
+ state: "ineligible",
1408
+ diagnosis: current,
1409
+ reason: "matching pre-test failure already retried",
1410
+ };
1411
+ break;
1412
+ }
1413
+ }
1414
+ if (evidence.state !== "eligible") break;
1415
+ }
1033
1416
  }
1417
+ }
1418
+ candidates.push({ runId, run, evidence });
1419
+ }
1034
1420
 
1035
- const checksForRun = checksByRun.get(runId) ?? [];
1036
- const run = yield* gh
1037
- .runGhJson<WorkflowRunJobsForRerun>(["run", "view", runId, "--json", "databaseId,jobs"])
1038
- .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
1039
-
1040
- const jobIds = run === null ? null : resolveJobIdsForFailedChecks(checksForRun, run.jobs);
1041
- if (jobIds === null || jobIds.length === 0) {
1042
- return yield* gh.runGh(["run", "rerun", runId, "--failed"]).pipe(
1043
- Effect.map(() => true),
1044
- Effect.catch(() => Effect.succeed(false)),
1045
- );
1046
- }
1421
+ if (candidates.some((candidate) => candidate.evidence.state !== "eligible")) {
1422
+ const unavailable = candidates.some((candidate) => candidate.evidence.state === "unavailable");
1423
+ const status = unavailable ? "evidence_unavailable" : "escalation_required";
1424
+ const runs: RerunChecksRun[] = candidates.map((candidate) => ({
1425
+ runId: candidate.runId,
1426
+ success: false,
1427
+ currentAttempt: candidate.run?.attempt ?? null,
1428
+ currentJobIds: candidate.run?.jobs?.map((job) => job.databaseId) ?? null,
1429
+ status: candidate.evidence.state === "eligible" ? "blocked" : status,
1430
+ evidence: candidate.evidence,
1431
+ }));
1432
+ const report: RerunChecksReport = {
1433
+ status,
1434
+ rerun: 0,
1435
+ failed: runs.length,
1436
+ runs,
1437
+ message: unavailable
1438
+ ? "Required retry evidence unavailable; no runs rerun"
1439
+ : "Escalation required; no runs rerun",
1440
+ };
1441
+ return report;
1442
+ }
1047
1443
 
1048
- const rerunResults = yield* Effect.forEach(
1049
- jobIds,
1050
- (jobId) =>
1051
- gh.runGh(["run", "rerun", "--job", String(jobId)]).pipe(
1052
- Effect.map(() => true),
1053
- Effect.catch(() => Effect.succeed(false)),
1054
- ),
1055
- { concurrency: 1 },
1444
+ const watch = options.watch === true;
1445
+ const deadline = Number(yield* Clock.currentTimeMillis) + (options.timeoutSeconds ?? 60) * 1000;
1446
+ const results: RerunChecksRun[] = [];
1447
+ for (const candidate of candidates) {
1448
+ const success = yield* gh
1449
+ .runGh(["run", "rerun", candidate.runId, ...(failedOnly ? ["--failed"] : [])])
1450
+ .pipe(
1451
+ Effect.as(true),
1452
+ Effect.catch(() => Effect.succeed(false)),
1056
1453
  );
1057
-
1058
- return rerunResults.every(Boolean);
1454
+ results.push({
1455
+ runId: candidate.runId,
1456
+ success,
1457
+ status: success ? "rerun_started" : "failed",
1458
+ currentAttempt: candidate.run?.attempt ?? null,
1459
+ currentJobIds: candidate.run?.jobs?.map((job) => job.databaseId) ?? null,
1460
+ evidence: candidate.evidence,
1059
1461
  });
1060
-
1061
- results.push({ runId, success });
1062
1462
  }
1063
1463
 
1064
- return {
1065
- rerun: results.filter((r) => r.success).length,
1066
- failed: results.filter((r) => !r.success).length,
1464
+ if (watch) {
1465
+ const discoveries = yield* Effect.forEach(
1466
+ candidates,
1467
+ (candidate, index) =>
1468
+ results[index]?.success
1469
+ ? discoverRerun(
1470
+ candidate.runId,
1471
+ candidate.run?.attempt ?? null,
1472
+ candidate.run?.jobs?.map((job) => job.databaseId) ?? [],
1473
+ deadline,
1474
+ )
1475
+ : Effect.succeed(null),
1476
+ { concurrency: "unbounded" },
1477
+ );
1478
+ yield* Effect.forEach(
1479
+ candidates,
1480
+ (candidate, index) =>
1481
+ Effect.gen(function* () {
1482
+ const result = results[index];
1483
+ if (result === undefined || !result.success) return;
1484
+ const next = discoveries[index] ?? null;
1485
+ result.newAttempt = next?.attempt ?? null;
1486
+ result.newJobIds = next?.jobs.map((job) => job.databaseId) ?? null;
1487
+ if (next === null) {
1488
+ result.status = "discovery_timeout";
1489
+ result.latestAttempt = candidate.run;
1490
+ return;
1491
+ }
1492
+ let latest = next;
1493
+ while (latest.status !== "completed") {
1494
+ const remaining = deadline - Number(yield* Clock.currentTimeMillis);
1495
+ if (remaining <= 0) break;
1496
+ yield* Effect.sleep(Duration.millis(Math.min(1000, remaining)));
1497
+ if (Number(yield* Clock.currentTimeMillis) >= deadline) break;
1498
+ latest = yield* gh
1499
+ .runGhJson<RerunDiscovery>([
1500
+ "run",
1501
+ "view",
1502
+ candidate.runId,
1503
+ "--json",
1504
+ "databaseId,attempt,status,jobs",
1505
+ ])
1506
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(latest)));
1507
+ }
1508
+ result.latestAttempt = latest;
1509
+ result.status = latest.status === "completed" ? "completed" : "watch_timeout";
1510
+ }),
1511
+ { concurrency: "unbounded" },
1512
+ );
1513
+ }
1514
+ const report: RerunChecksReport = {
1515
+ status: results.some((result) => !result.success) ? "failed" : "rerun_started",
1516
+ rerun: results.filter((result) => result.success).length,
1517
+ failed: results.filter((result) => !result.success).length,
1067
1518
  runs: results,
1068
- message: `Rerun ${results.filter((r) => r.success).length}/${results.length} GitHub Actions runs`,
1519
+ message: `Rerun ${results.filter((result) => result.success).length}/${results.length} GitHub Actions runs`,
1069
1520
  };
1521
+ return report;
1070
1522
  });