@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.
@@ -25,4 +25,5 @@ export {
25
25
  prReviewTriageCommand,
26
26
  prReviewTriageBatchCommand,
27
27
  prViewCommand,
28
+ prWatchCommand,
28
29
  } from "./commands";
@@ -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: 1) {
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
- nodes.push(...page.nodes);
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 = (threads: ThreadNode[], reviewComments: ReviewComment[]): ReviewThread[] => {
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 !== undefined && existing.isResolved && !thread.isResolved) {
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* (pr: number) {
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* (pr: number | null) {
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
  */
@@ -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;
@@ -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
  });