@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.
- package/README.md +19 -0
- package/package.json +1 -1
- package/src/gh-tool/index.ts +2 -0
- package/src/gh-tool/issue/core.ts +2 -0
- package/src/gh-tool/pr/commands.ts +196 -38
- package/src/gh-tool/pr/core.ts +533 -81
- package/src/gh-tool/pr/index.ts +1 -0
- package/src/gh-tool/pr/review.ts +178 -11
- package/src/gh-tool/types.ts +91 -0
- package/src/gh-tool/workflow.ts +68 -9
package/src/gh-tool/pr/core.ts
CHANGED
|
@@ -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:
|
|
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: {
|
|
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: "
|
|
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
|
-
|
|
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
|
|
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
|
|
954
|
-
|
|
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
|
|
1005
|
-
if (
|
|
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
|
-
|
|
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
|
|
1358
|
+
const repoInfo = yield* gh.getRepoInfo();
|
|
1359
|
+
const repo = `${repoInfo.owner}/${repoInfo.name}`;
|
|
1360
|
+
const candidates: Array<{
|
|
1023
1361
|
runId: string;
|
|
1024
|
-
|
|
1362
|
+
run: RerunCheckAttempt | null;
|
|
1363
|
+
evidence: RerunRetryEvidence;
|
|
1025
1364
|
}> = [];
|
|
1026
|
-
for (const runId of
|
|
1027
|
-
const
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
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
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
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((
|
|
1519
|
+
message: `Rerun ${results.filter((result) => result.success).length}/${results.length} GitHub Actions runs`,
|
|
1069
1520
|
};
|
|
1521
|
+
return report;
|
|
1070
1522
|
});
|