@blogic-cz/agent-tools 0.14.58 → 0.14.60

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* () {
@@ -713,7 +733,7 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
713
733
  yield* Console.log(
714
734
  `DRY RUN: Would merge PR #${info.number} "${info.title}" via ${opts.strategy.toUpperCase()}. ` +
715
735
  `Branch \`${info.headRefName}\` → \`${info.baseRefName}\`. ` +
716
- (opts.deleteBranch ? `Branch \`${info.headRefName}\` will be deleted. ` : "") +
736
+ (opts.deleteBranch ? `Remote branch \`${info.headRefName}\` will be deleted. ` : "") +
717
737
  dependentNote +
718
738
  mergeableNote,
719
739
  );
@@ -732,10 +752,9 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
732
752
  let willDeleteBranch = opts.deleteBranch;
733
753
  let branchDeleteSkipped = false;
734
754
  const retargetedChildren: number[] = [];
755
+ const repo = opts.deleteBranch ? yield* gh.getRepoInfo() : null;
735
756
 
736
- if (opts.deleteBranch && dependentOpenPrs.length > 0) {
737
- const repo = yield* gh.getRepoInfo();
738
-
757
+ if (opts.deleteBranch && dependentOpenPrs.length > 0 && repo) {
739
758
  for (const child of dependentOpenPrs) {
740
759
  const retargeted = yield* gh
741
760
  .runGh([
@@ -763,10 +782,6 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
763
782
 
764
783
  const mergeArgs = ["pr", "merge", String(opts.pr), `--${opts.strategy}`];
765
784
 
766
- if (willDeleteBranch) {
767
- mergeArgs.push("--delete-branch");
768
- }
769
-
770
785
  const mergeResult = yield* gh.runGh(mergeArgs).pipe(
771
786
  Effect.catchTag("GitHubCommandError", (error) => {
772
787
  const stderr = error.stderr.toLowerCase();
@@ -817,6 +832,27 @@ export const mergePR = Effect.fn("pr.mergePR")(function* (opts: {
817
832
 
818
833
  const shaMatch = mergeResult.stdout.match(/([0-9a-f]{7,40})/);
819
834
 
835
+ // `gh pr merge --delete-branch` aborts its remote delete when the head branch is checked out in a
836
+ // worktree; deleting the remote ref explicitly is worktree-independent. Local cleanup is separate.
837
+ if (willDeleteBranch && repo && info.headRefName) {
838
+ const remoteDeleted = yield* gh
839
+ .runGh([
840
+ "api",
841
+ "--method",
842
+ "DELETE",
843
+ `repos/${repo.owner}/${repo.name}/git/refs/heads/${info.headRefName}`,
844
+ ])
845
+ .pipe(
846
+ Effect.as(true),
847
+ Effect.orElseSucceed(() => false),
848
+ );
849
+
850
+ if (!remoteDeleted) {
851
+ willDeleteBranch = false;
852
+ branchDeleteSkipped = true;
853
+ }
854
+ }
855
+
820
856
  const result: MergeResult = {
821
857
  merged: true,
822
858
  strategy: opts.strategy,
@@ -901,6 +937,7 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
901
937
  watch: boolean,
902
938
  failFast: boolean,
903
939
  timeoutSeconds: number,
940
+ quiet = false,
904
941
  ) {
905
942
  const gh = yield* GitHubService;
906
943
 
@@ -926,7 +963,7 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
926
963
  );
927
964
 
928
965
  const results = yield* fetchCheckResults(pr);
929
- if (watchOutcome === null && results.some((c) => c.bucket === "pending")) {
966
+ if (!quiet && watchOutcome === null && results.some((c) => c.bucket === "pending")) {
930
967
  const pending = results.filter((c) => c.bucket === "pending").length;
931
968
  yield* Console.warn(
932
969
  `ℹ️ Watch timed out after ${timeoutSeconds}s; ${pending} check(s) still pending (snapshot returned). ` +
@@ -937,7 +974,7 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
937
974
  }
938
975
 
939
976
  const results = yield* fetchCheckResults(pr);
940
- if (results.some((c) => c.bucket === "pending")) {
977
+ if (!quiet && results.some((c) => c.bucket === "pending")) {
941
978
  yield* Console.warn(
942
979
  `ℹ️ Some checks are still running. Re-run to refresh — each call returns the latest snapshot:\n` +
943
980
  ` ${buildChecksCommand(pr, false)}`,
@@ -946,12 +983,49 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
946
983
  return results;
947
984
  });
948
985
 
986
+ export const collectWithStableState = <S, A, E1, R1, E2, R2>(
987
+ initial: S,
988
+ collect: (state: S) => Effect.Effect<A, E1, R1>,
989
+ refresh: (state: S) => Effect.Effect<S, E2, R2>,
990
+ unchanged: (before: S, after: S) => boolean,
991
+ ): Effect.Effect<{ state: S; value: A } | null, E1 | E2, R1 | R2> =>
992
+ Effect.gen(function* () {
993
+ let before = initial;
994
+ for (let attempt = 0; attempt < STABLE_SNAPSHOT_ATTEMPTS; attempt += 1) {
995
+ const value = yield* collect(before);
996
+ const after = yield* refresh(before);
997
+ if (unchanged(before, after)) return { state: after, value };
998
+ before = after;
999
+ }
1000
+ return null;
1001
+ });
1002
+
949
1003
  export const fetchFailedChecks = Effect.fn("pr.fetchFailedChecks")(function* (
950
1004
  pr: number | null,
951
1005
  withLogs = false,
952
1006
  ) {
953
- const checks = yield* fetchCheckResults(pr);
954
- return yield* buildFailedChecksReport(pr, checks, { withLogs });
1007
+ const initial = yield* viewPR(pr);
1008
+ const snapshot = yield* collectWithStableState(
1009
+ initial,
1010
+ (info) => fetchCheckResults(info.number),
1011
+ (info) => viewPR(info.number),
1012
+ (before, after) => after.headSha === before.headSha,
1013
+ );
1014
+ if (snapshot !== null) {
1015
+ return yield* buildFailedChecksReport(snapshot.state.number, snapshot.value, {
1016
+ withLogs,
1017
+ evidence: { headSha: snapshot.state.headSha, baseSha: snapshot.state.baseSha },
1018
+ });
1019
+ }
1020
+ return yield* Effect.fail(
1021
+ new GitHubCommandError({
1022
+ command: "gh-tool pr checks-failed",
1023
+ exitCode: 1,
1024
+ stderr: "",
1025
+ message: "PR head changed repeatedly while collecting checks",
1026
+ retryable: true,
1027
+ }),
1028
+ );
955
1029
  });
956
1030
 
957
1031
  export const fetchChecksForCommand = Effect.fn("pr.fetchChecksForCommand")(function* (
@@ -959,12 +1033,13 @@ export const fetchChecksForCommand = Effect.fn("pr.fetchChecksForCommand")(funct
959
1033
  watch: boolean,
960
1034
  failFast: boolean,
961
1035
  timeoutSeconds: number,
1036
+ quiet = false,
962
1037
  ) {
963
1038
  if (!watch) {
964
- return yield* fetchChecks(pr, false, failFast, timeoutSeconds);
1039
+ return yield* fetchChecks(pr, false, failFast, timeoutSeconds, quiet);
965
1040
  }
966
1041
 
967
- const watchedChecks = yield* fetchChecks(pr, true, failFast, timeoutSeconds).pipe(
1042
+ const watchedChecks = yield* fetchChecks(pr, true, failFast, timeoutSeconds, quiet).pipe(
968
1043
  Effect.result,
969
1044
  Effect.flatMap((result) => {
970
1045
  if (Result.isFailure(result) && result.failure._tag !== "GitHubCommandError") {
@@ -987,84 +1062,477 @@ export const fetchChecksForCommand = Effect.fn("pr.fetchChecksForCommand")(funct
987
1062
  return yield* Effect.fail(watchedChecks.failure);
988
1063
  });
989
1064
 
1065
+ type WatchPR = { number: number; state: string; headRefOid?: string | null };
1066
+ type WatchRun = {
1067
+ databaseId: number;
1068
+ attempt?: number | null;
1069
+ headSha?: string | null;
1070
+ jobs?: Array<{ databaseId: number; name: string }>;
1071
+ };
1072
+
1073
+ const watchPRState = Effect.fn("pr.watchPRState")(function* (pr: number) {
1074
+ const gh = yield* GitHubService;
1075
+ return yield* gh.runGhJson<WatchPR>([
1076
+ "pr",
1077
+ "view",
1078
+ String(pr),
1079
+ "--json",
1080
+ "number,state,headRefOid",
1081
+ ]);
1082
+ });
1083
+
1084
+ export const watchPRs = Effect.fn("pr.watchPRs")(function* (
1085
+ prs: readonly number[],
1086
+ options: { intervalSeconds: number; timeoutSeconds: number; until?: "terminal" },
1087
+ emit: (event: Record<string, unknown>) => Effect.Effect<void>,
1088
+ ) {
1089
+ if ((options.until ?? "terminal") !== "terminal") {
1090
+ return yield* Effect.fail(
1091
+ new GitHubCommandError({
1092
+ command: "gh-tool pr watch",
1093
+ exitCode: 1,
1094
+ stderr: "",
1095
+ message: `Unsupported --until value: ${String(options.until)}`,
1096
+ }),
1097
+ );
1098
+ }
1099
+ const gh = yield* GitHubService;
1100
+ const repo = yield* gh.getRepoInfo();
1101
+ const currentIdentity = new Map<string, string>();
1102
+ const lastRevision = new Map<string, string>();
1103
+ const emptySnapshots = new Map<string, number>();
1104
+ const checksObserved = new Set<string>();
1105
+ const headByPR = new Map<number, string>();
1106
+ const terminal = new Set<number>();
1107
+ const started = Number(yield* Clock.currentTimeMillis);
1108
+ const deadline = started + options.timeoutSeconds * 1000;
1109
+ const beforeDeadline = Effect.gen(function* () {
1110
+ return Number(yield* Clock.currentTimeMillis) < deadline;
1111
+ });
1112
+
1113
+ const snapshot = (pr: number) =>
1114
+ Effect.gen(function* () {
1115
+ const requireDeadline = Effect.gen(function* () {
1116
+ if (!(yield* beforeDeadline)) return yield* Effect.fail("timeout" as const);
1117
+ });
1118
+ yield* requireDeadline;
1119
+ const initial = yield* watchPRState(pr);
1120
+ const stableResult = yield* collectWithStableState(
1121
+ initial,
1122
+ () => requireDeadline.pipe(Effect.andThen(fetchCheckResults(pr))),
1123
+ () => requireDeadline.pipe(Effect.andThen(watchPRState(pr))),
1124
+ (before, after) => before.headRefOid === after.headRefOid && before.state === after.state,
1125
+ ).pipe(Effect.result);
1126
+ if (Result.isFailure(stableResult)) {
1127
+ if (stableResult.failure === "timeout") return "timeout" as const;
1128
+ return yield* Effect.fail(stableResult.failure);
1129
+ }
1130
+ if (stableResult.success === null) return null;
1131
+ const { state: after, value: checks } = stableResult.success;
1132
+ const head = after.headRefOid ?? null;
1133
+ const runs = new Map<number, WatchRun | null>();
1134
+ yield* Effect.forEach(
1135
+ [...new Set(checks.map((check) => extractRunIdFromCheckLink(check.link)))].filter(
1136
+ (runId): runId is number => runId !== null,
1137
+ ),
1138
+ (runId) =>
1139
+ Effect.gen(function* () {
1140
+ if (!(yield* beforeDeadline)) return;
1141
+ const run = yield* gh
1142
+ .runGhJson<WatchRun>([
1143
+ "run",
1144
+ "view",
1145
+ String(runId),
1146
+ "--json",
1147
+ "databaseId,attempt,headSha,jobs",
1148
+ ])
1149
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
1150
+ runs.set(runId, run);
1151
+ }),
1152
+ { concurrency: 5 },
1153
+ );
1154
+ if (!(yield* beforeDeadline)) return "timeout" as const;
1155
+ return {
1156
+ pr: after,
1157
+ head,
1158
+ checks: checks
1159
+ .map((check) => ({
1160
+ check,
1161
+ run:
1162
+ extractRunIdFromCheckLink(check.link) === null
1163
+ ? null
1164
+ : (runs.get(extractRunIdFromCheckLink(check.link) ?? -1) ?? null),
1165
+ }))
1166
+ .filter(({ run }) => run === null || run.headSha === null || run.headSha === head),
1167
+ };
1168
+ });
1169
+
1170
+ let timedOut = false;
1171
+ yield* Effect.whileLoop({
1172
+ while: () => terminal.size < prs.length && !timedOut,
1173
+ body: () =>
1174
+ Effect.gen(function* () {
1175
+ for (const number of prs) {
1176
+ if (terminal.has(number)) continue;
1177
+ if (!(yield* beforeDeadline)) {
1178
+ timedOut = true;
1179
+ break;
1180
+ }
1181
+ const state = yield* snapshot(number);
1182
+ if (state === "timeout") {
1183
+ timedOut = true;
1184
+ break;
1185
+ }
1186
+ if (state === null) continue;
1187
+ const headKey = `${number}/${state.head ?? ""}`;
1188
+ const oldHeadKey = headByPR.get(number);
1189
+ if (oldHeadKey !== undefined && oldHeadKey !== headKey) {
1190
+ checksObserved.delete(oldHeadKey);
1191
+ emptySnapshots.delete(oldHeadKey);
1192
+ }
1193
+ headByPR.set(number, headKey);
1194
+ if (state.checks.length > 0) {
1195
+ checksObserved.add(headKey);
1196
+ emptySnapshots.set(headKey, 0);
1197
+ } else {
1198
+ emptySnapshots.set(headKey, (emptySnapshots.get(headKey) ?? 0) + 1);
1199
+ }
1200
+ for (const { check, run } of state.checks) {
1201
+ const matches = failedJobsMatchingCheck(check.name, run?.jobs ?? []);
1202
+ const jobId = matches.length === 1 ? (matches[0]?.databaseId ?? null) : null;
1203
+ const fallback = jobId === null ? `${check.name}|${check.link}` : String(jobId);
1204
+ const identity = [
1205
+ repo.owner + "/" + repo.name,
1206
+ number,
1207
+ state.head ?? "",
1208
+ run?.databaseId ?? "external",
1209
+ run?.attempt ?? "",
1210
+ fallback,
1211
+ ].join("/");
1212
+ const logical = `${number}/${check.name}`;
1213
+ const previousIdentity = currentIdentity.get(logical);
1214
+ const revision = `${check.state}/${check.bucket}`;
1215
+ if (lastRevision.get(identity) !== revision) {
1216
+ const event: Record<string, unknown> = {
1217
+ type: "check",
1218
+ repo: `${repo.owner}/${repo.name}`,
1219
+ pr: number,
1220
+ headSha: state.head,
1221
+ runId: run?.databaseId ?? null,
1222
+ attempt: run?.attempt ?? null,
1223
+ jobId,
1224
+ checkId: null,
1225
+ name: check.name,
1226
+ state: check.state,
1227
+ bucket: check.bucket,
1228
+ link: check.link,
1229
+ identity,
1230
+ };
1231
+ if (previousIdentity !== undefined && previousIdentity !== identity) {
1232
+ event.supersedes = previousIdentity;
1233
+ }
1234
+ if (!(yield* beforeDeadline)) {
1235
+ timedOut = true;
1236
+ break;
1237
+ }
1238
+ yield* emit(event);
1239
+ lastRevision.set(identity, revision);
1240
+ }
1241
+ currentIdentity.set(logical, identity);
1242
+ }
1243
+ if (timedOut) break;
1244
+ // Empty snapshots need three consecutive observations, including after checks were seen.
1245
+ const hasTerminalCoverage =
1246
+ state.checks.length > 0 || (emptySnapshots.get(headKey) ?? 0) >= 3;
1247
+ const checksTerminal =
1248
+ state.checks.length === 0 ||
1249
+ state.checks.every(({ check }) => check.bucket !== "pending");
1250
+ if (state.pr.state !== "OPEN" || (hasTerminalCoverage && checksTerminal)) {
1251
+ if (!(yield* beforeDeadline)) {
1252
+ timedOut = true;
1253
+ break;
1254
+ }
1255
+ const identity = `${repo.owner}/${repo.name}/${number}/${state.head ?? ""}/terminal/${state.pr.state}`;
1256
+ yield* emit({
1257
+ type: "pr_terminal",
1258
+ repo: `${repo.owner}/${repo.name}`,
1259
+ pr: number,
1260
+ headSha: state.head,
1261
+ state: state.pr.state,
1262
+ checksObserved: checksObserved.has(headKey),
1263
+ identity,
1264
+ });
1265
+ terminal.add(number);
1266
+ }
1267
+ }
1268
+ const now = Number(yield* Clock.currentTimeMillis);
1269
+ timedOut = timedOut || (terminal.size < prs.length && now >= deadline);
1270
+ if (!timedOut && terminal.size < prs.length) {
1271
+ yield* Effect.sleep(
1272
+ Duration.millis(Math.min(options.intervalSeconds * 1000, deadline - now)),
1273
+ );
1274
+ }
1275
+ }),
1276
+ step: () => undefined,
1277
+ });
1278
+ yield* emit({
1279
+ type: "watcher_terminal",
1280
+ repo: `${repo.owner}/${repo.name}`,
1281
+ status: terminal.size === prs.length ? "terminal" : "timeout",
1282
+ terminal: [...terminal].toSorted((a, b) => a - b),
1283
+ });
1284
+ });
1285
+
1286
+ type RerunDiscovery = RerunCheckAttempt & { status?: string };
1287
+
1288
+ const fetchAttemptJobs = Effect.fn("pr.fetchAttemptJobs")(function* (
1289
+ runId: string,
1290
+ attempt: number,
1291
+ repo: string,
1292
+ ) {
1293
+ const gh = yield* GitHubService;
1294
+ const jobs: Array<{ id: number; name: string }> = [];
1295
+ for (let page = 1; ; page += 1) {
1296
+ const response = yield* gh
1297
+ .runGhJson<{ jobs?: Array<{ id: number; name: string }> }>([
1298
+ "api",
1299
+ `repos/${repo}/actions/runs/${runId}/attempts/${attempt}/jobs?per_page=100&page=${page}`,
1300
+ ])
1301
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
1302
+ if (response?.jobs === undefined) return null;
1303
+ jobs.push(...response.jobs);
1304
+ if (response.jobs.length < 100) return jobs;
1305
+ }
1306
+ });
1307
+
1308
+ const readJobDiagnosis = Effect.fn("pr.readJobDiagnosis")(function* (
1309
+ runId: string,
1310
+ jobName: string,
1311
+ attempt: number,
1312
+ repo: string,
1313
+ ) {
1314
+ const gh = yield* GitHubService;
1315
+ const jobs = yield* fetchAttemptJobs(runId, attempt, repo);
1316
+ if (jobs === null) return null;
1317
+ const matches = jobs.filter((job) => job.name === jobName);
1318
+ if (matches.length !== 1) return null;
1319
+ const logs = yield* gh
1320
+ .runGh(["api", `repos/${repo}/actions/jobs/${matches[0]?.id}/logs`])
1321
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
1322
+ return logs === null ? null : diagnoseLogEntries(parseRawJobLogs(logs.stdout));
1323
+ });
1324
+
1325
+ const discoverRerun = Effect.fn("pr.discoverRerun")(function* (
1326
+ runId: string,
1327
+ currentAttempt: number | null,
1328
+ currentJobIds: readonly number[],
1329
+ deadline: number,
1330
+ ) {
1331
+ const gh = yield* GitHubService;
1332
+ let latest: RerunDiscovery | null = null;
1333
+ while (Number(yield* Clock.currentTimeMillis) <= deadline) {
1334
+ latest = yield* gh
1335
+ .runGhJson<RerunDiscovery>(["run", "view", runId, "--json", "databaseId,attempt,status,jobs"])
1336
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
1337
+ if (
1338
+ latest !== null &&
1339
+ ((latest.attempt ?? 0) > (currentAttempt ?? 0) ||
1340
+ latest.jobs.some((job) => !currentJobIds.includes(job.databaseId)))
1341
+ ) {
1342
+ return latest;
1343
+ }
1344
+ const remaining = deadline - Number(yield* Clock.currentTimeMillis);
1345
+ if (remaining <= 0) return null;
1346
+ yield* Effect.sleep(Duration.millis(Math.min(1000, remaining)));
1347
+ }
1348
+ return null;
1349
+ });
1350
+
990
1351
  export const rerunChecks = Effect.fn("pr.rerunChecks")(function* (
991
1352
  pr: number | null,
992
1353
  failedOnly: boolean,
1354
+ options: { watch?: boolean; timeoutSeconds?: number } = {},
993
1355
  ) {
994
1356
  const gh = yield* GitHubService;
995
-
996
1357
  const checks = yield* fetchCheckResults(pr);
997
-
998
1358
  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
1359
  const checksByRun = new Map<string, CheckResult[]>();
1003
1360
  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
- }
1361
+ const runId = check.link.match(GITHUB_ACTIONS_RUN_ID_RE)?.[1];
1362
+ if (runId !== undefined) checksByRun.set(runId, [...(checksByRun.get(runId) ?? []), check]);
1011
1363
  }
1012
-
1013
- if (runIds.size === 0) {
1014
- return {
1364
+ if (checksByRun.size === 0) {
1365
+ const report: RerunChecksReport = {
1015
1366
  rerun: 0,
1016
1367
  message: failedOnly
1017
1368
  ? "No failed GitHub Actions runs found to rerun"
1018
1369
  : "No GitHub Actions runs found to rerun",
1019
1370
  };
1371
+ return report;
1020
1372
  }
1021
1373
 
1022
- const results: Array<{
1374
+ const repoInfo = yield* gh.getRepoInfo();
1375
+ const repo = `${repoInfo.owner}/${repoInfo.name}`;
1376
+ const candidates: Array<{
1023
1377
  runId: string;
1024
- success: boolean;
1378
+ run: RerunCheckAttempt | null;
1379
+ evidence: RerunRetryEvidence;
1025
1380
  }> = [];
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
- );
1381
+ for (const [runId, runChecks] of checksByRun) {
1382
+ const run = yield* gh
1383
+ .runGhJson<RerunCheckAttempt>(["run", "view", runId, "--json", "databaseId,attempt,jobs"])
1384
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(null)));
1385
+ let evidence: RerunRetryEvidence = { state: "eligible", diagnosis: diagnoseLogEntries([]) };
1386
+ if (failedOnly) {
1387
+ const jobIds = run === null ? null : resolveJobIdsForFailedChecks(runChecks, run.jobs);
1388
+ if (
1389
+ run === null ||
1390
+ run.attempt === null ||
1391
+ run.attempt === undefined ||
1392
+ jobIds === null ||
1393
+ jobIds.length === 0
1394
+ ) {
1395
+ evidence = { state: "unavailable", reason: "failed jobs or current attempt unavailable" };
1396
+ } else {
1397
+ const targetJobs = run.jobs.filter((job) => jobIds.includes(job.databaseId));
1398
+ for (const job of targetJobs) {
1399
+ const current = yield* readJobDiagnosis(runId, job.name, run.attempt, repo);
1400
+ if (current === null) {
1401
+ evidence = {
1402
+ state: "unavailable",
1403
+ reason: `retry evidence unavailable for ${job.name}`,
1404
+ };
1405
+ break;
1406
+ }
1407
+ evidence = { state: "eligible", diagnosis: current };
1408
+ const retryablePreTest =
1409
+ current.testsStarted === false &&
1410
+ ["infrastructure", "network", "timeout"].includes(current.category);
1411
+ if (!retryablePreTest) continue;
1412
+ for (let attempt = 1; attempt < run.attempt; attempt += 1) {
1413
+ const prior = yield* readJobDiagnosis(runId, job.name, attempt, repo);
1414
+ if (prior === null) {
1415
+ evidence = {
1416
+ state: "unavailable",
1417
+ reason: `retry evidence unavailable for ${job.name} attempt ${attempt}`,
1418
+ };
1419
+ break;
1420
+ }
1421
+ if (prior.fingerprint === current.fingerprint) {
1422
+ evidence = {
1423
+ state: "ineligible",
1424
+ diagnosis: current,
1425
+ reason: "matching pre-test failure already retried",
1426
+ };
1427
+ break;
1428
+ }
1429
+ }
1430
+ if (evidence.state !== "eligible") break;
1431
+ }
1033
1432
  }
1433
+ }
1434
+ candidates.push({ runId, run, evidence });
1435
+ }
1034
1436
 
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
- }
1437
+ if (candidates.some((candidate) => candidate.evidence.state !== "eligible")) {
1438
+ const unavailable = candidates.some((candidate) => candidate.evidence.state === "unavailable");
1439
+ const status = unavailable ? "evidence_unavailable" : "escalation_required";
1440
+ const runs: RerunChecksRun[] = candidates.map((candidate) => ({
1441
+ runId: candidate.runId,
1442
+ success: false,
1443
+ currentAttempt: candidate.run?.attempt ?? null,
1444
+ currentJobIds: candidate.run?.jobs?.map((job) => job.databaseId) ?? null,
1445
+ status: candidate.evidence.state === "eligible" ? "blocked" : status,
1446
+ evidence: candidate.evidence,
1447
+ }));
1448
+ const report: RerunChecksReport = {
1449
+ status,
1450
+ rerun: 0,
1451
+ failed: runs.length,
1452
+ runs,
1453
+ message: unavailable
1454
+ ? "Required retry evidence unavailable; no runs rerun"
1455
+ : "Escalation required; no runs rerun",
1456
+ };
1457
+ return report;
1458
+ }
1047
1459
 
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 },
1460
+ const watch = options.watch === true;
1461
+ const deadline = Number(yield* Clock.currentTimeMillis) + (options.timeoutSeconds ?? 60) * 1000;
1462
+ const results: RerunChecksRun[] = [];
1463
+ for (const candidate of candidates) {
1464
+ const success = yield* gh
1465
+ .runGh(["run", "rerun", candidate.runId, ...(failedOnly ? ["--failed"] : [])])
1466
+ .pipe(
1467
+ Effect.as(true),
1468
+ Effect.catch(() => Effect.succeed(false)),
1056
1469
  );
1057
-
1058
- return rerunResults.every(Boolean);
1470
+ results.push({
1471
+ runId: candidate.runId,
1472
+ success,
1473
+ status: success ? "rerun_started" : "failed",
1474
+ currentAttempt: candidate.run?.attempt ?? null,
1475
+ currentJobIds: candidate.run?.jobs?.map((job) => job.databaseId) ?? null,
1476
+ evidence: candidate.evidence,
1059
1477
  });
1060
-
1061
- results.push({ runId, success });
1062
1478
  }
1063
1479
 
1064
- return {
1065
- rerun: results.filter((r) => r.success).length,
1066
- failed: results.filter((r) => !r.success).length,
1480
+ if (watch) {
1481
+ const discoveries = yield* Effect.forEach(
1482
+ candidates,
1483
+ (candidate, index) =>
1484
+ results[index]?.success
1485
+ ? discoverRerun(
1486
+ candidate.runId,
1487
+ candidate.run?.attempt ?? null,
1488
+ candidate.run?.jobs?.map((job) => job.databaseId) ?? [],
1489
+ deadline,
1490
+ )
1491
+ : Effect.succeed(null),
1492
+ { concurrency: "unbounded" },
1493
+ );
1494
+ yield* Effect.forEach(
1495
+ candidates,
1496
+ (candidate, index) =>
1497
+ Effect.gen(function* () {
1498
+ const result = results[index];
1499
+ if (result === undefined || !result.success) return;
1500
+ const next = discoveries[index] ?? null;
1501
+ result.newAttempt = next?.attempt ?? null;
1502
+ result.newJobIds = next?.jobs.map((job) => job.databaseId) ?? null;
1503
+ if (next === null) {
1504
+ result.status = "discovery_timeout";
1505
+ result.latestAttempt = candidate.run;
1506
+ return;
1507
+ }
1508
+ let latest = next;
1509
+ while (latest.status !== "completed") {
1510
+ const remaining = deadline - Number(yield* Clock.currentTimeMillis);
1511
+ if (remaining <= 0) break;
1512
+ yield* Effect.sleep(Duration.millis(Math.min(1000, remaining)));
1513
+ if (Number(yield* Clock.currentTimeMillis) >= deadline) break;
1514
+ latest = yield* gh
1515
+ .runGhJson<RerunDiscovery>([
1516
+ "run",
1517
+ "view",
1518
+ candidate.runId,
1519
+ "--json",
1520
+ "databaseId,attempt,status,jobs",
1521
+ ])
1522
+ .pipe(Effect.catchTag("GitHubCommandError", () => Effect.succeed(latest)));
1523
+ }
1524
+ result.latestAttempt = latest;
1525
+ result.status = latest.status === "completed" ? "completed" : "watch_timeout";
1526
+ }),
1527
+ { concurrency: "unbounded" },
1528
+ );
1529
+ }
1530
+ const report: RerunChecksReport = {
1531
+ status: results.some((result) => !result.success) ? "failed" : "rerun_started",
1532
+ rerun: results.filter((result) => result.success).length,
1533
+ failed: results.filter((result) => !result.success).length,
1067
1534
  runs: results,
1068
- message: `Rerun ${results.filter((r) => r.success).length}/${results.length} GitHub Actions runs`,
1535
+ message: `Rerun ${results.filter((result) => result.success).length}/${results.length} GitHub Actions runs`,
1069
1536
  };
1537
+ return report;
1070
1538
  });