@blogic-cz/agent-tools 0.14.57 → 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 +186 -12
- package/src/gh-tool/types.ts +93 -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,
|
|
@@ -368,6 +426,7 @@ const enrichThreads = (threads: ThreadNode[], reviewComments: ReviewComment[]):
|
|
|
368
426
|
isVisibleOpen: !node.isResolved || needsHumanReply,
|
|
369
427
|
lastReplyAuthor: lastReply?.author ?? null,
|
|
370
428
|
lastReplyAt: lastReply?.createdAt ?? null,
|
|
429
|
+
duplicateThreadIds: [] as string[],
|
|
371
430
|
};
|
|
372
431
|
})
|
|
373
432
|
.filter((thread): thread is ReviewThread => thread !== null);
|
|
@@ -383,21 +442,30 @@ const enrichThreads = (threads: ThreadNode[], reviewComments: ReviewComment[]):
|
|
|
383
442
|
continue;
|
|
384
443
|
}
|
|
385
444
|
const existing = deduped[existingIndex];
|
|
386
|
-
if (existing
|
|
445
|
+
if (existing === undefined) {
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if (existing.isResolved && !thread.isResolved) {
|
|
449
|
+
thread.duplicateThreadIds = [existing.threadId, ...existing.duplicateThreadIds];
|
|
387
450
|
deduped[existingIndex] = thread;
|
|
451
|
+
} else {
|
|
452
|
+
existing.duplicateThreadIds.push(thread.threadId);
|
|
388
453
|
}
|
|
389
454
|
}
|
|
390
455
|
|
|
391
456
|
return deduped;
|
|
392
457
|
};
|
|
393
458
|
|
|
394
|
-
const fetchThreadState = Effect.fn("pr.fetchThreadState")(function* (
|
|
459
|
+
const fetchThreadState = Effect.fn("pr.fetchThreadState")(function* (
|
|
460
|
+
pr: number,
|
|
461
|
+
currentHeadSha: string | null = null,
|
|
462
|
+
) {
|
|
395
463
|
const [threads, reviewComments] = yield* Effect.all([
|
|
396
464
|
fetchAllThreadNodes(pr),
|
|
397
465
|
fetchComments(pr, null),
|
|
398
466
|
]);
|
|
399
467
|
|
|
400
|
-
return enrichThreads(threads, reviewComments);
|
|
468
|
+
return enrichThreads(threads, reviewComments, currentHeadSha);
|
|
401
469
|
});
|
|
402
470
|
|
|
403
471
|
// ---------------------------------------------------------------------------
|
|
@@ -406,6 +474,8 @@ const fetchThreadState = Effect.fn("pr.fetchThreadState")(function* (pr: number)
|
|
|
406
474
|
|
|
407
475
|
const mapRawIssueComment = (comment: RawIssueComment): IssueComment => ({
|
|
408
476
|
id: comment.id as IssueCommentId,
|
|
477
|
+
commitSha: null,
|
|
478
|
+
feedbackOrigin: "unknown",
|
|
409
479
|
author: comment.user.login,
|
|
410
480
|
body: comment.body,
|
|
411
481
|
createdAt: comment.created_at as IsoTimestamp,
|
|
@@ -420,9 +490,10 @@ export const fetchThreads = Effect.fn("pr.fetchThreads")(function* (
|
|
|
420
490
|
pr: number | null,
|
|
421
491
|
unresolvedOnly: boolean,
|
|
422
492
|
visibleOpenOnly = false,
|
|
493
|
+
currentHeadSha: string | null = null,
|
|
423
494
|
) {
|
|
424
495
|
const resolvedPr = pr ?? (yield* viewPR(null)).number;
|
|
425
|
-
const threads = yield* fetchThreadState(resolvedPr);
|
|
496
|
+
const threads = yield* fetchThreadState(resolvedPr, currentHeadSha);
|
|
426
497
|
|
|
427
498
|
if (visibleOpenOnly) {
|
|
428
499
|
return threads.filter((thread) => thread.isVisibleOpen);
|
|
@@ -438,6 +509,7 @@ export const fetchThreads = Effect.fn("pr.fetchThreads")(function* (
|
|
|
438
509
|
export const fetchComments = Effect.fn("pr.fetchComments")(function* (
|
|
439
510
|
pr: number | null,
|
|
440
511
|
since: string | null,
|
|
512
|
+
currentHeadSha: string | null = null,
|
|
441
513
|
) {
|
|
442
514
|
const service = yield* GitHubService;
|
|
443
515
|
const repoInfo = yield* service.getRepoInfo();
|
|
@@ -452,6 +524,8 @@ export const fetchComments = Effect.fn("pr.fetchComments")(function* (
|
|
|
452
524
|
|
|
453
525
|
const comments: ReviewComment[] = raw.map((c) => ({
|
|
454
526
|
id: c.id,
|
|
527
|
+
commitSha: c.commit_id ?? null,
|
|
528
|
+
feedbackOrigin: feedbackOrigin(c.commit_id ?? null, currentHeadSha),
|
|
455
529
|
inReplyToId: c.in_reply_to_id,
|
|
456
530
|
author: c.user.login,
|
|
457
531
|
body: c.body,
|
|
@@ -519,6 +593,7 @@ export const fetchReviews = Effect.fn("pr.fetchReviews")(function* (
|
|
|
519
593
|
author: string | null,
|
|
520
594
|
bodyContains: string | null,
|
|
521
595
|
state: string | null,
|
|
596
|
+
currentHeadSha: string | null = null,
|
|
522
597
|
) {
|
|
523
598
|
const service = yield* GitHubService;
|
|
524
599
|
const repoInfo = yield* service.getRepoInfo();
|
|
@@ -534,6 +609,8 @@ export const fetchReviews = Effect.fn("pr.fetchReviews")(function* (
|
|
|
534
609
|
let reviews: PullRequestReview[] = raw
|
|
535
610
|
.map((review) => ({
|
|
536
611
|
id: review.id,
|
|
612
|
+
commitSha: review.commit_id ?? null,
|
|
613
|
+
feedbackOrigin: feedbackOrigin(review.commit_id ?? null, currentHeadSha),
|
|
537
614
|
author: review.user?.login ?? "unknown",
|
|
538
615
|
state: review.state,
|
|
539
616
|
body: review.body ?? "",
|
|
@@ -565,13 +642,15 @@ export const fetchReviews = Effect.fn("pr.fetchReviews")(function* (
|
|
|
565
642
|
* all review threads with resolution state, inline review comments, and issue
|
|
566
643
|
* (discussion) comments. Collapses the four separate fetches agents otherwise stitch.
|
|
567
644
|
*/
|
|
568
|
-
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
|
+
) {
|
|
569
649
|
const resolvedPr = pr ?? (yield* viewPR(null)).number;
|
|
570
|
-
|
|
571
650
|
const [reviews, threads, inlineComments, issueComments] = yield* Effect.all([
|
|
572
|
-
fetchReviews(resolvedPr, null, null, null),
|
|
573
|
-
fetchThreads(resolvedPr, false),
|
|
574
|
-
fetchComments(resolvedPr, null),
|
|
651
|
+
fetchReviews(resolvedPr, null, null, null, currentHeadSha),
|
|
652
|
+
fetchThreads(resolvedPr, false, false, currentHeadSha),
|
|
653
|
+
fetchComments(resolvedPr, null, currentHeadSha),
|
|
575
654
|
fetchIssueComments(resolvedPr, null, null, null),
|
|
576
655
|
]);
|
|
577
656
|
|
|
@@ -796,6 +875,101 @@ export const replyToComment = Effect.fn("pr.replyToComment")(function* (
|
|
|
796
875
|
return { success: true as const, commentId: parsed.id };
|
|
797
876
|
});
|
|
798
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
|
+
|
|
799
973
|
/**
|
|
800
974
|
* Resolve a review thread via GraphQL mutation.
|
|
801
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;
|
|
@@ -37,10 +44,14 @@ export type ReviewThread = {
|
|
|
37
44
|
isVisibleOpen: boolean;
|
|
38
45
|
lastReplyAuthor: string | null;
|
|
39
46
|
lastReplyAt: string | null;
|
|
47
|
+
/** Thread ids of exact duplicates collapsed into this representative (encounter order). */
|
|
48
|
+
duplicateThreadIds: string[];
|
|
40
49
|
};
|
|
41
50
|
|
|
42
51
|
export type ReviewComment = {
|
|
43
52
|
id: number;
|
|
53
|
+
commitSha: string | null;
|
|
54
|
+
feedbackOrigin: FeedbackOrigin;
|
|
44
55
|
inReplyToId: number | null;
|
|
45
56
|
author: string;
|
|
46
57
|
body: string;
|
|
@@ -60,6 +71,8 @@ export type GitHubIssueCommentUrl = typeof GitHubIssueCommentUrl.Type;
|
|
|
60
71
|
|
|
61
72
|
export type IssueComment = {
|
|
62
73
|
id: IssueCommentId;
|
|
74
|
+
commitSha: null;
|
|
75
|
+
feedbackOrigin: "unknown";
|
|
63
76
|
author: string;
|
|
64
77
|
body: string;
|
|
65
78
|
createdAt: IsoTimestamp;
|
|
@@ -68,6 +81,8 @@ export type IssueComment = {
|
|
|
68
81
|
|
|
69
82
|
export type PullRequestReview = {
|
|
70
83
|
id: number;
|
|
84
|
+
commitSha: string | null;
|
|
85
|
+
feedbackOrigin: FeedbackOrigin;
|
|
71
86
|
author: string;
|
|
72
87
|
state: string;
|
|
73
88
|
body: string;
|
|
@@ -84,6 +99,8 @@ export type CheckResult = {
|
|
|
84
99
|
|
|
85
100
|
export type FailedCheckJob = {
|
|
86
101
|
databaseId: number;
|
|
102
|
+
jobId: number;
|
|
103
|
+
checkId: number | null;
|
|
87
104
|
name: string;
|
|
88
105
|
status: string;
|
|
89
106
|
conclusion: string | null;
|
|
@@ -93,6 +110,7 @@ export type FailedCheckJob = {
|
|
|
93
110
|
|
|
94
111
|
export type FailedCheckRunContext = {
|
|
95
112
|
runId: number;
|
|
113
|
+
attempt: number | null;
|
|
96
114
|
url: string | null;
|
|
97
115
|
workflowName: string | null;
|
|
98
116
|
status: string;
|
|
@@ -104,9 +122,83 @@ export type FailedCheckDetail = CheckResult & {
|
|
|
104
122
|
runId: number | null;
|
|
105
123
|
run: FailedCheckRunContext | null;
|
|
106
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[];
|
|
107
198
|
};
|
|
108
199
|
|
|
109
200
|
export type FailedChecksReport = {
|
|
201
|
+
evidence: { headSha: string | null; baseSha: string | null } | null;
|
|
110
202
|
status: "failed" | "no_failures";
|
|
111
203
|
message: string;
|
|
112
204
|
summary: {
|
|
@@ -123,6 +215,7 @@ export type FailedChecksReport = {
|
|
|
123
215
|
|
|
124
216
|
export type WorkflowRunDetail = {
|
|
125
217
|
databaseId: number;
|
|
218
|
+
attempt: number | null;
|
|
126
219
|
url: string;
|
|
127
220
|
workflowName: string | null;
|
|
128
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
|
});
|