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