@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.
- 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 +199 -39
- package/src/gh-tool/pr/core.ts +557 -89
- 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/index.ts
CHANGED
package/src/gh-tool/pr/review.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
PullRequestReview,
|
|
9
9
|
ReviewComment,
|
|
10
10
|
ReviewThread,
|
|
11
|
+
FeedbackOrigin,
|
|
11
12
|
} from "#gh/types";
|
|
12
13
|
|
|
13
14
|
import { GitHubCommandError } from "#gh/errors";
|
|
@@ -27,7 +28,7 @@ const REVIEW_THREADS_QUERY = `
|
|
|
27
28
|
nodes {
|
|
28
29
|
id
|
|
29
30
|
isResolved
|
|
30
|
-
comments(first:
|
|
31
|
+
comments(first: 100) {
|
|
31
32
|
nodes {
|
|
32
33
|
id
|
|
33
34
|
databaseId
|
|
@@ -35,7 +36,9 @@ const REVIEW_THREADS_QUERY = `
|
|
|
35
36
|
line
|
|
36
37
|
body
|
|
37
38
|
author { login }
|
|
39
|
+
commit { oid }
|
|
38
40
|
}
|
|
41
|
+
pageInfo { hasNextPage endCursor }
|
|
39
42
|
}
|
|
40
43
|
}
|
|
41
44
|
pageInfo {
|
|
@@ -48,6 +51,27 @@ const REVIEW_THREADS_QUERY = `
|
|
|
48
51
|
}
|
|
49
52
|
`;
|
|
50
53
|
|
|
54
|
+
const REVIEW_THREAD_COMMENTS_QUERY = `
|
|
55
|
+
query($threadId: ID!, $after: String) {
|
|
56
|
+
node(id: $threadId) {
|
|
57
|
+
... on PullRequestReviewThread {
|
|
58
|
+
comments(first: 100, after: $after) {
|
|
59
|
+
nodes {
|
|
60
|
+
id
|
|
61
|
+
databaseId
|
|
62
|
+
path
|
|
63
|
+
line
|
|
64
|
+
body
|
|
65
|
+
author { login }
|
|
66
|
+
commit { oid }
|
|
67
|
+
}
|
|
68
|
+
pageInfo { hasNextPage endCursor }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
`;
|
|
74
|
+
|
|
51
75
|
const RESOLVE_THREAD_MUTATION = `
|
|
52
76
|
mutation($threadId: ID!) {
|
|
53
77
|
resolveReviewThread(input: {threadId: $threadId}) {
|
|
@@ -143,10 +167,16 @@ type ThreadNode = {
|
|
|
143
167
|
line: number;
|
|
144
168
|
body: string;
|
|
145
169
|
author: { login: string };
|
|
170
|
+
commit: { oid: string } | null;
|
|
146
171
|
}>;
|
|
172
|
+
pageInfo?: { hasNextPage: boolean; endCursor: string | null };
|
|
147
173
|
};
|
|
148
174
|
};
|
|
149
175
|
|
|
176
|
+
type ThreadCommentsQueryResult = {
|
|
177
|
+
node: { comments: ThreadNode["comments"] } | null;
|
|
178
|
+
};
|
|
179
|
+
|
|
150
180
|
type ThreadsQueryResult = {
|
|
151
181
|
repository: {
|
|
152
182
|
pullRequest: {
|
|
@@ -225,6 +255,7 @@ type RawReviewComment = {
|
|
|
225
255
|
path: string;
|
|
226
256
|
line: number;
|
|
227
257
|
created_at: string;
|
|
258
|
+
commit_id?: string | null;
|
|
228
259
|
};
|
|
229
260
|
|
|
230
261
|
type RawIssueComment = {
|
|
@@ -242,6 +273,7 @@ type RawPullRequestReview = {
|
|
|
242
273
|
body: string | null;
|
|
243
274
|
submitted_at: string | null;
|
|
244
275
|
html_url: string;
|
|
276
|
+
commit_id?: string | null;
|
|
245
277
|
};
|
|
246
278
|
|
|
247
279
|
type ReviewCommentById = {
|
|
@@ -252,6 +284,13 @@ type ReviewCommentById = {
|
|
|
252
284
|
|
|
253
285
|
const REST_PAGE_SIZE = 100;
|
|
254
286
|
|
|
287
|
+
const feedbackOrigin = (commitSha: string | null, currentHeadSha: string | null): FeedbackOrigin =>
|
|
288
|
+
commitSha === null || currentHeadSha === null
|
|
289
|
+
? "unknown"
|
|
290
|
+
: commitSha === currentHeadSha
|
|
291
|
+
? "current_head"
|
|
292
|
+
: "pre_existing";
|
|
293
|
+
|
|
255
294
|
const parseJson = <T>(
|
|
256
295
|
stdout: string,
|
|
257
296
|
command: string,
|
|
@@ -312,7 +351,20 @@ const fetchAllThreadNodes = Effect.fn("pr.fetchAllThreadNodes")(function* (pr: n
|
|
|
312
351
|
})) as ThreadsQueryResult;
|
|
313
352
|
|
|
314
353
|
const page = response.repository.pullRequest.reviewThreads;
|
|
315
|
-
|
|
354
|
+
for (const node of page.nodes) {
|
|
355
|
+
let commentsAfter = node.comments.pageInfo?.endCursor ?? null;
|
|
356
|
+
while (node.comments.pageInfo?.hasNextPage && commentsAfter !== null) {
|
|
357
|
+
const commentsResponse = (yield* service.runGraphQL(REVIEW_THREAD_COMMENTS_QUERY, {
|
|
358
|
+
threadId: node.id,
|
|
359
|
+
after: commentsAfter,
|
|
360
|
+
})) as ThreadCommentsQueryResult;
|
|
361
|
+
if (commentsResponse.node === null) break;
|
|
362
|
+
node.comments.nodes.push(...commentsResponse.node.comments.nodes);
|
|
363
|
+
node.comments.pageInfo = commentsResponse.node.comments.pageInfo;
|
|
364
|
+
commentsAfter = commentsResponse.node.comments.pageInfo?.endCursor ?? null;
|
|
365
|
+
}
|
|
366
|
+
nodes.push(node);
|
|
367
|
+
}
|
|
316
368
|
|
|
317
369
|
if (!page.pageInfo.hasNextPage || page.pageInfo.endCursor === null) {
|
|
318
370
|
return nodes;
|
|
@@ -322,7 +374,11 @@ const fetchAllThreadNodes = Effect.fn("pr.fetchAllThreadNodes")(function* (pr: n
|
|
|
322
374
|
}
|
|
323
375
|
});
|
|
324
376
|
|
|
325
|
-
const enrichThreads = (
|
|
377
|
+
const enrichThreads = (
|
|
378
|
+
threads: ThreadNode[],
|
|
379
|
+
reviewComments: ReviewComment[],
|
|
380
|
+
currentHeadSha: string | null,
|
|
381
|
+
): ReviewThread[] => {
|
|
326
382
|
const repliesByRootCommentId = new Map<number, ReviewComment[]>();
|
|
327
383
|
|
|
328
384
|
for (const comment of reviewComments) {
|
|
@@ -358,6 +414,8 @@ const enrichThreads = (threads: ThreadNode[], reviewComments: ReviewComment[]):
|
|
|
358
414
|
return {
|
|
359
415
|
threadId: node.id,
|
|
360
416
|
commentId: comment.databaseId,
|
|
417
|
+
commitSha: comment.commit?.oid ?? null,
|
|
418
|
+
feedbackOrigin: feedbackOrigin(comment.commit?.oid ?? null, currentHeadSha),
|
|
361
419
|
path: comment.path,
|
|
362
420
|
line: comment.line,
|
|
363
421
|
body: comment.body,
|
|
@@ -398,13 +456,16 @@ const enrichThreads = (threads: ThreadNode[], reviewComments: ReviewComment[]):
|
|
|
398
456
|
return deduped;
|
|
399
457
|
};
|
|
400
458
|
|
|
401
|
-
const fetchThreadState = Effect.fn("pr.fetchThreadState")(function* (
|
|
459
|
+
const fetchThreadState = Effect.fn("pr.fetchThreadState")(function* (
|
|
460
|
+
pr: number,
|
|
461
|
+
currentHeadSha: string | null = null,
|
|
462
|
+
) {
|
|
402
463
|
const [threads, reviewComments] = yield* Effect.all([
|
|
403
464
|
fetchAllThreadNodes(pr),
|
|
404
465
|
fetchComments(pr, null),
|
|
405
466
|
]);
|
|
406
467
|
|
|
407
|
-
return enrichThreads(threads, reviewComments);
|
|
468
|
+
return enrichThreads(threads, reviewComments, currentHeadSha);
|
|
408
469
|
});
|
|
409
470
|
|
|
410
471
|
// ---------------------------------------------------------------------------
|
|
@@ -413,6 +474,8 @@ const fetchThreadState = Effect.fn("pr.fetchThreadState")(function* (pr: number)
|
|
|
413
474
|
|
|
414
475
|
const mapRawIssueComment = (comment: RawIssueComment): IssueComment => ({
|
|
415
476
|
id: comment.id as IssueCommentId,
|
|
477
|
+
commitSha: null,
|
|
478
|
+
feedbackOrigin: "unknown",
|
|
416
479
|
author: comment.user.login,
|
|
417
480
|
body: comment.body,
|
|
418
481
|
createdAt: comment.created_at as IsoTimestamp,
|
|
@@ -427,9 +490,10 @@ export const fetchThreads = Effect.fn("pr.fetchThreads")(function* (
|
|
|
427
490
|
pr: number | null,
|
|
428
491
|
unresolvedOnly: boolean,
|
|
429
492
|
visibleOpenOnly = false,
|
|
493
|
+
currentHeadSha: string | null = null,
|
|
430
494
|
) {
|
|
431
495
|
const resolvedPr = pr ?? (yield* viewPR(null)).number;
|
|
432
|
-
const threads = yield* fetchThreadState(resolvedPr);
|
|
496
|
+
const threads = yield* fetchThreadState(resolvedPr, currentHeadSha);
|
|
433
497
|
|
|
434
498
|
if (visibleOpenOnly) {
|
|
435
499
|
return threads.filter((thread) => thread.isVisibleOpen);
|
|
@@ -445,6 +509,7 @@ export const fetchThreads = Effect.fn("pr.fetchThreads")(function* (
|
|
|
445
509
|
export const fetchComments = Effect.fn("pr.fetchComments")(function* (
|
|
446
510
|
pr: number | null,
|
|
447
511
|
since: string | null,
|
|
512
|
+
currentHeadSha: string | null = null,
|
|
448
513
|
) {
|
|
449
514
|
const service = yield* GitHubService;
|
|
450
515
|
const repoInfo = yield* service.getRepoInfo();
|
|
@@ -459,6 +524,8 @@ export const fetchComments = Effect.fn("pr.fetchComments")(function* (
|
|
|
459
524
|
|
|
460
525
|
const comments: ReviewComment[] = raw.map((c) => ({
|
|
461
526
|
id: c.id,
|
|
527
|
+
commitSha: c.commit_id ?? null,
|
|
528
|
+
feedbackOrigin: feedbackOrigin(c.commit_id ?? null, currentHeadSha),
|
|
462
529
|
inReplyToId: c.in_reply_to_id,
|
|
463
530
|
author: c.user.login,
|
|
464
531
|
body: c.body,
|
|
@@ -526,6 +593,7 @@ export const fetchReviews = Effect.fn("pr.fetchReviews")(function* (
|
|
|
526
593
|
author: string | null,
|
|
527
594
|
bodyContains: string | null,
|
|
528
595
|
state: string | null,
|
|
596
|
+
currentHeadSha: string | null = null,
|
|
529
597
|
) {
|
|
530
598
|
const service = yield* GitHubService;
|
|
531
599
|
const repoInfo = yield* service.getRepoInfo();
|
|
@@ -541,6 +609,8 @@ export const fetchReviews = Effect.fn("pr.fetchReviews")(function* (
|
|
|
541
609
|
let reviews: PullRequestReview[] = raw
|
|
542
610
|
.map((review) => ({
|
|
543
611
|
id: review.id,
|
|
612
|
+
commitSha: review.commit_id ?? null,
|
|
613
|
+
feedbackOrigin: feedbackOrigin(review.commit_id ?? null, currentHeadSha),
|
|
544
614
|
author: review.user?.login ?? "unknown",
|
|
545
615
|
state: review.state,
|
|
546
616
|
body: review.body ?? "",
|
|
@@ -572,13 +642,15 @@ export const fetchReviews = Effect.fn("pr.fetchReviews")(function* (
|
|
|
572
642
|
* all review threads with resolution state, inline review comments, and issue
|
|
573
643
|
* (discussion) comments. Collapses the four separate fetches agents otherwise stitch.
|
|
574
644
|
*/
|
|
575
|
-
export const fetchFeedback = Effect.fn("pr.fetchFeedback")(function* (
|
|
645
|
+
export const fetchFeedback = Effect.fn("pr.fetchFeedback")(function* (
|
|
646
|
+
pr: number | null,
|
|
647
|
+
currentHeadSha: string | null = null,
|
|
648
|
+
) {
|
|
576
649
|
const resolvedPr = pr ?? (yield* viewPR(null)).number;
|
|
577
|
-
|
|
578
650
|
const [reviews, threads, inlineComments, issueComments] = yield* Effect.all([
|
|
579
|
-
fetchReviews(resolvedPr, null, null, null),
|
|
580
|
-
fetchThreads(resolvedPr, false),
|
|
581
|
-
fetchComments(resolvedPr, null),
|
|
651
|
+
fetchReviews(resolvedPr, null, null, null, currentHeadSha),
|
|
652
|
+
fetchThreads(resolvedPr, false, false, currentHeadSha),
|
|
653
|
+
fetchComments(resolvedPr, null, currentHeadSha),
|
|
582
654
|
fetchIssueComments(resolvedPr, null, null, null),
|
|
583
655
|
]);
|
|
584
656
|
|
|
@@ -803,6 +875,101 @@ export const replyToComment = Effect.fn("pr.replyToComment")(function* (
|
|
|
803
875
|
return { success: true as const, commentId: parsed.id };
|
|
804
876
|
});
|
|
805
877
|
|
|
878
|
+
/**
|
|
879
|
+
* Reply to a review comment, infer and validate its PR and thread, then resolve that thread.
|
|
880
|
+
*/
|
|
881
|
+
export const replyAndResolveComment = Effect.fn("pr.replyAndResolveComment")(function* (
|
|
882
|
+
pr: number | null,
|
|
883
|
+
commentId: number,
|
|
884
|
+
threadId: string | null,
|
|
885
|
+
body: string,
|
|
886
|
+
) {
|
|
887
|
+
const target = yield* fetchReviewCommentById(commentId).pipe(
|
|
888
|
+
Effect.catchTags({
|
|
889
|
+
GitHubNotFoundError: () =>
|
|
890
|
+
Effect.fail(
|
|
891
|
+
new GitHubCommandError({
|
|
892
|
+
command: "gh-tool pr reply-and-resolve",
|
|
893
|
+
exitCode: 1,
|
|
894
|
+
stderr: `Review comment ${commentId} was not found; it may be deleted`,
|
|
895
|
+
message: `Review comment ${commentId} was not found; it may be deleted`,
|
|
896
|
+
}),
|
|
897
|
+
),
|
|
898
|
+
GitHubCommandError: () =>
|
|
899
|
+
Effect.fail(
|
|
900
|
+
new GitHubCommandError({
|
|
901
|
+
command: "gh-tool pr reply-and-resolve",
|
|
902
|
+
exitCode: 1,
|
|
903
|
+
stderr: `Could not load review comment ${commentId}`,
|
|
904
|
+
message: `Could not load review comment ${commentId}`,
|
|
905
|
+
}),
|
|
906
|
+
),
|
|
907
|
+
}),
|
|
908
|
+
);
|
|
909
|
+
const inferredPr = Number(target.pull_request_url.match(/\/pulls\/(\d+)\/?$/)?.[1]);
|
|
910
|
+
if (!Number.isInteger(inferredPr) || inferredPr < 1) {
|
|
911
|
+
return yield* Effect.fail(
|
|
912
|
+
new GitHubCommandError({
|
|
913
|
+
command: "gh-tool pr reply-and-resolve",
|
|
914
|
+
exitCode: 1,
|
|
915
|
+
stderr: `Comment ${commentId} has no usable pull request URL`,
|
|
916
|
+
message: `Comment ${commentId} has no usable pull request URL`,
|
|
917
|
+
}),
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
if (pr !== null && pr !== inferredPr) {
|
|
921
|
+
return yield* Effect.fail(
|
|
922
|
+
new GitHubCommandError({
|
|
923
|
+
command: "gh-tool pr reply-and-resolve",
|
|
924
|
+
exitCode: 1,
|
|
925
|
+
stderr: `Comment ${commentId} belongs to PR #${inferredPr}, not explicit PR #${pr}`,
|
|
926
|
+
message: `Comment ${commentId} belongs to PR #${inferredPr}, not explicit PR #${pr}`,
|
|
927
|
+
}),
|
|
928
|
+
);
|
|
929
|
+
}
|
|
930
|
+
const rootCommentId = target.in_reply_to_id ?? target.id;
|
|
931
|
+
const matches = (yield* fetchAllThreadNodes(inferredPr)).filter((thread) =>
|
|
932
|
+
thread.comments.nodes.some(
|
|
933
|
+
(comment) => comment.databaseId === target.id || comment.databaseId === rootCommentId,
|
|
934
|
+
),
|
|
935
|
+
);
|
|
936
|
+
if (matches.length === 0) {
|
|
937
|
+
return yield* Effect.fail(
|
|
938
|
+
new GitHubCommandError({
|
|
939
|
+
command: "gh-tool pr reply-and-resolve",
|
|
940
|
+
exitCode: 1,
|
|
941
|
+
stderr: `Comment ${commentId} has no review thread; it may be deleted`,
|
|
942
|
+
message: `Comment ${commentId} has no review thread; it may be deleted`,
|
|
943
|
+
}),
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
if (matches.length > 1) {
|
|
947
|
+
return yield* Effect.fail(
|
|
948
|
+
new GitHubCommandError({
|
|
949
|
+
command: "gh-tool pr reply-and-resolve",
|
|
950
|
+
exitCode: 1,
|
|
951
|
+
stderr: `Comment ${commentId} matches multiple review threads`,
|
|
952
|
+
message: `Comment ${commentId} matches multiple review threads`,
|
|
953
|
+
}),
|
|
954
|
+
);
|
|
955
|
+
}
|
|
956
|
+
const inferredThreadId = matches[0]?.id;
|
|
957
|
+
if (inferredThreadId === undefined) return yield* Effect.die("matched thread missing");
|
|
958
|
+
if (threadId !== null && threadId !== inferredThreadId) {
|
|
959
|
+
return yield* Effect.fail(
|
|
960
|
+
new GitHubCommandError({
|
|
961
|
+
command: "gh-tool pr reply-and-resolve",
|
|
962
|
+
exitCode: 1,
|
|
963
|
+
stderr: `Comment ${commentId} belongs to thread ${inferredThreadId}, not explicit thread ${threadId}`,
|
|
964
|
+
message: `Comment ${commentId} belongs to thread ${inferredThreadId}, not explicit thread ${threadId}`,
|
|
965
|
+
}),
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
const reply = yield* replyToComment(inferredPr, commentId, body);
|
|
969
|
+
const resolve = yield* resolveThread(inferredThreadId);
|
|
970
|
+
return { reply, resolve, pr: inferredPr, threadId: inferredThreadId };
|
|
971
|
+
});
|
|
972
|
+
|
|
806
973
|
/**
|
|
807
974
|
* Resolve a review thread via GraphQL mutation.
|
|
808
975
|
*/
|
package/src/gh-tool/types.ts
CHANGED
|
@@ -18,14 +18,21 @@ export type ReviewRequest =
|
|
|
18
18
|
| { __typename: "Team"; name: string; slug: string };
|
|
19
19
|
|
|
20
20
|
export type PRViewInfo = PRInfo & {
|
|
21
|
+
headSha: string | null;
|
|
22
|
+
baseSha: string | null;
|
|
21
23
|
body: string;
|
|
22
24
|
author: { login: string; is_bot: boolean };
|
|
23
25
|
reviewDecision: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | "";
|
|
24
26
|
reviewRequests: ReviewRequest[];
|
|
25
27
|
};
|
|
26
28
|
|
|
29
|
+
export const FeedbackOrigin = Schema.Literals(["current_head", "pre_existing", "unknown"]);
|
|
30
|
+
export type FeedbackOrigin = typeof FeedbackOrigin.Type;
|
|
31
|
+
|
|
27
32
|
export type ReviewThread = {
|
|
28
33
|
threadId: string;
|
|
34
|
+
commitSha: string | null;
|
|
35
|
+
feedbackOrigin: FeedbackOrigin;
|
|
29
36
|
commentId: number;
|
|
30
37
|
path: string;
|
|
31
38
|
line: number;
|
|
@@ -43,6 +50,8 @@ export type ReviewThread = {
|
|
|
43
50
|
|
|
44
51
|
export type ReviewComment = {
|
|
45
52
|
id: number;
|
|
53
|
+
commitSha: string | null;
|
|
54
|
+
feedbackOrigin: FeedbackOrigin;
|
|
46
55
|
inReplyToId: number | null;
|
|
47
56
|
author: string;
|
|
48
57
|
body: string;
|
|
@@ -62,6 +71,8 @@ export type GitHubIssueCommentUrl = typeof GitHubIssueCommentUrl.Type;
|
|
|
62
71
|
|
|
63
72
|
export type IssueComment = {
|
|
64
73
|
id: IssueCommentId;
|
|
74
|
+
commitSha: null;
|
|
75
|
+
feedbackOrigin: "unknown";
|
|
65
76
|
author: string;
|
|
66
77
|
body: string;
|
|
67
78
|
createdAt: IsoTimestamp;
|
|
@@ -70,6 +81,8 @@ export type IssueComment = {
|
|
|
70
81
|
|
|
71
82
|
export type PullRequestReview = {
|
|
72
83
|
id: number;
|
|
84
|
+
commitSha: string | null;
|
|
85
|
+
feedbackOrigin: FeedbackOrigin;
|
|
73
86
|
author: string;
|
|
74
87
|
state: string;
|
|
75
88
|
body: string;
|
|
@@ -86,6 +99,8 @@ export type CheckResult = {
|
|
|
86
99
|
|
|
87
100
|
export type FailedCheckJob = {
|
|
88
101
|
databaseId: number;
|
|
102
|
+
jobId: number;
|
|
103
|
+
checkId: number | null;
|
|
89
104
|
name: string;
|
|
90
105
|
status: string;
|
|
91
106
|
conclusion: string | null;
|
|
@@ -95,6 +110,7 @@ export type FailedCheckJob = {
|
|
|
95
110
|
|
|
96
111
|
export type FailedCheckRunContext = {
|
|
97
112
|
runId: number;
|
|
113
|
+
attempt: number | null;
|
|
98
114
|
url: string | null;
|
|
99
115
|
workflowName: string | null;
|
|
100
116
|
status: string;
|
|
@@ -106,9 +122,83 @@ export type FailedCheckDetail = CheckResult & {
|
|
|
106
122
|
runId: number | null;
|
|
107
123
|
run: FailedCheckRunContext | null;
|
|
108
124
|
failedStepLogs?: string;
|
|
125
|
+
diagnosis?: LogDiagnosis;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const LogDiagnosisCategory = Schema.Literals([
|
|
129
|
+
"infrastructure",
|
|
130
|
+
"network",
|
|
131
|
+
"timeout",
|
|
132
|
+
"test_failure",
|
|
133
|
+
"build_failure",
|
|
134
|
+
"lint_failure",
|
|
135
|
+
"unknown",
|
|
136
|
+
]);
|
|
137
|
+
export type LogDiagnosisCategory = typeof LogDiagnosisCategory.Type;
|
|
138
|
+
|
|
139
|
+
export type LogDiagnosis = {
|
|
140
|
+
category: LogDiagnosisCategory;
|
|
141
|
+
fingerprint: string;
|
|
142
|
+
testsStarted: boolean | null;
|
|
143
|
+
firstRelevantError: string | null;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export const RerunRetryEvidenceState = Schema.Literals(["eligible", "ineligible", "unavailable"]);
|
|
147
|
+
export type RerunRetryEvidenceState = typeof RerunRetryEvidenceState.Type;
|
|
148
|
+
|
|
149
|
+
export type RerunRetryEvidence =
|
|
150
|
+
| { state: "eligible"; diagnosis: LogDiagnosis }
|
|
151
|
+
| { state: "ineligible"; diagnosis: LogDiagnosis; reason: string }
|
|
152
|
+
| { state: "unavailable"; reason: string };
|
|
153
|
+
|
|
154
|
+
export const RerunCheckStatus = Schema.Literals([
|
|
155
|
+
"blocked",
|
|
156
|
+
"evidence_unavailable",
|
|
157
|
+
"escalation_required",
|
|
158
|
+
"rerun_started",
|
|
159
|
+
"failed",
|
|
160
|
+
"discovery_timeout",
|
|
161
|
+
"completed",
|
|
162
|
+
"watch_timeout",
|
|
163
|
+
]);
|
|
164
|
+
export type RerunCheckStatus = typeof RerunCheckStatus.Type;
|
|
165
|
+
|
|
166
|
+
export type RerunCheckJob = {
|
|
167
|
+
databaseId: number;
|
|
168
|
+
name: string;
|
|
169
|
+
status: string;
|
|
170
|
+
conclusion: string | null;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export type RerunCheckAttempt = {
|
|
174
|
+
databaseId: number;
|
|
175
|
+
attempt?: number | null;
|
|
176
|
+
jobs: RerunCheckJob[];
|
|
177
|
+
status?: string;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export type RerunChecksRun = {
|
|
181
|
+
runId: string;
|
|
182
|
+
success: boolean;
|
|
183
|
+
currentAttempt: number | null;
|
|
184
|
+
currentJobIds: number[] | null;
|
|
185
|
+
evidence: RerunRetryEvidence;
|
|
186
|
+
status: RerunCheckStatus;
|
|
187
|
+
newAttempt?: number | null;
|
|
188
|
+
newJobIds?: number[] | null;
|
|
189
|
+
latestAttempt?: RerunCheckAttempt | null;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
export type RerunChecksReport = {
|
|
193
|
+
rerun: number;
|
|
194
|
+
failed?: number;
|
|
195
|
+
message: string;
|
|
196
|
+
status?: "evidence_unavailable" | "escalation_required" | "failed" | "rerun_started";
|
|
197
|
+
runs?: RerunChecksRun[];
|
|
109
198
|
};
|
|
110
199
|
|
|
111
200
|
export type FailedChecksReport = {
|
|
201
|
+
evidence: { headSha: string | null; baseSha: string | null } | null;
|
|
112
202
|
status: "failed" | "no_failures";
|
|
113
203
|
message: string;
|
|
114
204
|
summary: {
|
|
@@ -125,6 +215,7 @@ export type FailedChecksReport = {
|
|
|
125
215
|
|
|
126
216
|
export type WorkflowRunDetail = {
|
|
127
217
|
databaseId: number;
|
|
218
|
+
attempt: number | null;
|
|
128
219
|
url: string;
|
|
129
220
|
workflowName: string | null;
|
|
130
221
|
status: string;
|
package/src/gh-tool/workflow.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { formatOption, logFormatted } from "#shared";
|
|
|
5
5
|
import { CI_CHECK_WATCH_TIMEOUT_MS } from "#gh/config";
|
|
6
6
|
import { GitHubCommandError, GitHubNotFoundError } from "./errors";
|
|
7
7
|
import { GitHubService } from "./service";
|
|
8
|
-
import type { CheckRunAnnotation, JobAnnotations } from "./types";
|
|
8
|
+
import type { CheckRunAnnotation, JobAnnotations, LogDiagnosis } from "./types";
|
|
9
9
|
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
// Types
|
|
@@ -45,7 +45,7 @@ type WorkflowRunDetail = WorkflowRun & {
|
|
|
45
45
|
jobs: WorkflowJob[];
|
|
46
46
|
};
|
|
47
47
|
|
|
48
|
-
type LogEntry = {
|
|
48
|
+
export type LogEntry = {
|
|
49
49
|
step: string;
|
|
50
50
|
message: string;
|
|
51
51
|
};
|
|
@@ -438,6 +438,60 @@ export function formatLogEntries(entries: LogEntry[]): string {
|
|
|
438
438
|
// Job-level log handlers
|
|
439
439
|
// ---------------------------------------------------------------------------
|
|
440
440
|
|
|
441
|
+
const normalizeFingerprint = (message: string) =>
|
|
442
|
+
message
|
|
443
|
+
.toLowerCase()
|
|
444
|
+
.replace(/https?:\/\/\S+/g, "<url>")
|
|
445
|
+
.replace(/(?:0x)?[0-9a-f]{8,}/gi, "<id>")
|
|
446
|
+
.replace(/\b\d+\b/g, "<n>")
|
|
447
|
+
.replace(/\s+/g, " ")
|
|
448
|
+
.trim()
|
|
449
|
+
.slice(0, 240);
|
|
450
|
+
|
|
451
|
+
export const diagnoseLogEntries = (entries: readonly LogEntry[]): LogDiagnosis => {
|
|
452
|
+
const lines = entries.map((entry) => entry.message);
|
|
453
|
+
const text = lines.join("\n");
|
|
454
|
+
const find = (pattern: RegExp) => lines.find((line) => pattern.test(line)) ?? null;
|
|
455
|
+
const testsStarted =
|
|
456
|
+
/\b(vitest|jest|pytest|go test|cargo test|test suites?|tests? (?:run|failed))\b/i.test(text)
|
|
457
|
+
? true
|
|
458
|
+
: /\b(install|checkout|restore|setup)\b/i.test(text)
|
|
459
|
+
? false
|
|
460
|
+
: null;
|
|
461
|
+
const matched = [
|
|
462
|
+
[
|
|
463
|
+
"infrastructure",
|
|
464
|
+
/no space left on device|disk full|corrupt.*nuget|nuget.*corrupt|address already in use|socket.*bind/i,
|
|
465
|
+
],
|
|
466
|
+
[
|
|
467
|
+
"network",
|
|
468
|
+
/econnreset|enotfound|network.*(?:error|timeout)|connection (?:reset|refused)|could not resolve host/i,
|
|
469
|
+
],
|
|
470
|
+
["timeout", /timed? ?out|deadline exceeded/i],
|
|
471
|
+
["lint_failure", /\b(?:eslint|oxlint|lint)\b.*(?:error|failed)|lint failed/i],
|
|
472
|
+
["build_failure", /\b(?:build|compile|typescript)\b.*(?:error|failed)|compilation failed/i],
|
|
473
|
+
["test_failure", /\b(?:test|tests|vitest|jest|pytest)\b.*(?:fail|error)|\d+ failing/i],
|
|
474
|
+
] as const;
|
|
475
|
+
for (const [category, pattern] of matched) {
|
|
476
|
+
const error = find(pattern);
|
|
477
|
+
if (error !== null) {
|
|
478
|
+
return {
|
|
479
|
+
category,
|
|
480
|
+
fingerprint: `${category}:${normalizeFingerprint(error)}`,
|
|
481
|
+
testsStarted,
|
|
482
|
+
firstRelevantError: error,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const firstRelevantError = find(/\b(error|failed|fatal|exception)\b/i);
|
|
487
|
+
return {
|
|
488
|
+
category: "unknown",
|
|
489
|
+
fingerprint: `unknown:${normalizeFingerprint(firstRelevantError ?? lines[0] ?? "no-log-output")}`,
|
|
490
|
+
testsStarted,
|
|
491
|
+
firstRelevantError,
|
|
492
|
+
};
|
|
493
|
+
};
|
|
494
|
+
|
|
441
495
|
const resolveJobId = Effect.fn("workflow.resolveJobId")(function* (
|
|
442
496
|
runId: number,
|
|
443
497
|
jobName: string,
|
|
@@ -500,6 +554,7 @@ export const fetchJobLogs = Effect.fn("workflow.fetchJobLogs")(function* (opts:
|
|
|
500
554
|
jobId?: number | null;
|
|
501
555
|
failedStepNames?: readonly string[] | null;
|
|
502
556
|
failedStepsOnly: boolean;
|
|
557
|
+
diagnose?: boolean;
|
|
503
558
|
format: string;
|
|
504
559
|
repo: string | null;
|
|
505
560
|
}) {
|
|
@@ -549,13 +604,12 @@ export const fetchJobLogs = Effect.fn("workflow.fetchJobLogs")(function* (opts:
|
|
|
549
604
|
}
|
|
550
605
|
}
|
|
551
606
|
|
|
607
|
+
if (opts.diagnose) {
|
|
608
|
+
return { runId: opts.runId, job: opts.job, jobId, diagnosis: diagnoseLogEntries(entries) };
|
|
609
|
+
}
|
|
610
|
+
|
|
552
611
|
if (opts.format === "json") {
|
|
553
|
-
return {
|
|
554
|
-
runId: opts.runId,
|
|
555
|
-
job: opts.job,
|
|
556
|
-
jobId,
|
|
557
|
-
entries,
|
|
558
|
-
};
|
|
612
|
+
return { runId: opts.runId, job: opts.job, jobId, entries };
|
|
559
613
|
}
|
|
560
614
|
|
|
561
615
|
return {
|
|
@@ -770,6 +824,10 @@ export const workflowWatchCommand = Command.make(
|
|
|
770
824
|
export const workflowJobLogsCommand = Command.make(
|
|
771
825
|
"job-logs",
|
|
772
826
|
{
|
|
827
|
+
diagnose: Flag.boolean("diagnose").pipe(
|
|
828
|
+
Flag.withDescription("Return concise failure diagnosis metadata without log entries"),
|
|
829
|
+
Flag.withDefault(false),
|
|
830
|
+
),
|
|
773
831
|
failedStepsOnly: Flag.boolean("failed-steps-only").pipe(
|
|
774
832
|
Flag.withDescription("Only show logs from failed steps (default: false)"),
|
|
775
833
|
Flag.withDefault(false),
|
|
@@ -781,13 +839,14 @@ export const workflowJobLogsCommand = Command.make(
|
|
|
781
839
|
repo: repoOption,
|
|
782
840
|
run: Flag.integer("run").pipe(Flag.withDescription("Workflow run ID")),
|
|
783
841
|
},
|
|
784
|
-
({ failedStepsOnly, format, job, repo, run }) =>
|
|
842
|
+
({ diagnose, failedStepsOnly, format, job, repo, run }) =>
|
|
785
843
|
Effect.gen(function* () {
|
|
786
844
|
const resolvedRepo = yield* resolveRepoArg(repo);
|
|
787
845
|
const result = yield* fetchJobLogs({
|
|
788
846
|
runId: run,
|
|
789
847
|
job,
|
|
790
848
|
failedStepsOnly,
|
|
849
|
+
diagnose,
|
|
791
850
|
format,
|
|
792
851
|
repo: resolvedRepo,
|
|
793
852
|
});
|