@blogic-cz/agent-tools 0.8.3 → 0.8.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -228,14 +228,24 @@ export const prThreadsCommand = Command.make(
228
228
  Flag.withDescription("Only show unresolved threads"),
229
229
  Flag.withDefault(true),
230
230
  ),
231
+ visibleOpenOnly: Flag.boolean("visible-open-only").pipe(
232
+ Flag.withDescription(
233
+ "Show threads that still look open to humans: unresolved threads plus resolved threads with no reply",
234
+ ),
235
+ Flag.withDefault(false),
236
+ ),
231
237
  },
232
- ({ format, pr, unresolvedOnly }) =>
238
+ ({ format, pr, unresolvedOnly, visibleOpenOnly }) =>
233
239
  Effect.gen(function* () {
234
240
  const prNumber = Option.getOrNull(pr);
235
- const threads = yield* fetchThreads(prNumber, unresolvedOnly);
241
+ const threads = yield* fetchThreads(prNumber, unresolvedOnly, visibleOpenOnly);
236
242
  yield* logFormatted(threads, format);
237
243
  }),
238
- ).pipe(Command.withDescription("Fetch review threads for a PR (unresolved by default)"));
244
+ ).pipe(
245
+ Command.withDescription(
246
+ "Fetch review threads for a PR (unresolved by default, or use --visible-open-only for reply-aware human-visible open items)",
247
+ ),
248
+ );
239
249
 
240
250
  export const prCommentsCommand = Command.make(
241
251
  "comments",
@@ -443,17 +453,18 @@ export const prReviewTriageCommand = Command.make(
443
453
  ({ format, pr }) =>
444
454
  Effect.gen(function* () {
445
455
  const prNumber = Option.getOrNull(pr);
446
- const [info, threads, summary, checks] = yield* Effect.all([
456
+ const [info, unresolvedThreads, visibleOpenThreads, summary, checks] = yield* Effect.all([
447
457
  viewPR(prNumber),
448
458
  fetchThreads(prNumber, true),
459
+ fetchThreads(prNumber, false, true),
449
460
  fetchDiscussionSummary(prNumber),
450
461
  fetchChecks(prNumber, false, false, 0),
451
462
  ]);
452
- yield* logFormatted({ info, unresolvedThreads: threads, summary, checks }, format);
463
+ yield* logFormatted({ info, unresolvedThreads, visibleOpenThreads, summary, checks }, format);
453
464
  }),
454
465
  ).pipe(
455
466
  Command.withDescription(
456
- "Composite: PR info + unresolved threads + discussion summary + checks status in one call",
467
+ "Composite: PR info + unresolved threads + visible-open threads + discussion summary + checks status in one call",
457
468
  ),
458
469
  );
459
470
 
@@ -19,10 +19,10 @@ import { viewPR } from "./core";
19
19
  // ---------------------------------------------------------------------------
20
20
 
21
21
  const REVIEW_THREADS_QUERY = `
22
- query($owner: String!, $name: String!, $pr: Int!) {
22
+ query($owner: String!, $name: String!, $pr: Int!, $after: String) {
23
23
  repository(owner: $owner, name: $name) {
24
24
  pullRequest(number: $pr) {
25
- reviewThreads(first: 100) {
25
+ reviewThreads(first: 100, after: $after) {
26
26
  nodes {
27
27
  id
28
28
  isResolved
@@ -37,6 +37,10 @@ const REVIEW_THREADS_QUERY = `
37
37
  }
38
38
  }
39
39
  }
40
+ pageInfo {
41
+ hasNextPage
42
+ endCursor
43
+ }
40
44
  }
41
45
  }
42
46
  }
@@ -100,6 +104,10 @@ type ThreadsQueryResult = {
100
104
  pullRequest: {
101
105
  reviewThreads: {
102
106
  nodes: ThreadNode[];
107
+ pageInfo: {
108
+ hasNextPage: boolean;
109
+ endCursor: string | null;
110
+ };
103
111
  };
104
112
  };
105
113
  };
@@ -156,46 +164,111 @@ type ReviewCommentById = {
156
164
  pull_request_url: string;
157
165
  };
158
166
 
159
- // ---------------------------------------------------------------------------
160
- // Handlers
161
- // ---------------------------------------------------------------------------
167
+ const REST_PAGE_SIZE = 100;
162
168
 
163
- const mapRawIssueComment = (comment: RawIssueComment): IssueComment => ({
164
- id: comment.id as IssueCommentId,
165
- author: comment.user.login,
166
- body: comment.body,
167
- createdAt: comment.created_at as IsoTimestamp,
168
- url: comment.html_url as GitHubIssueCommentUrl,
169
- });
169
+ const parseJson = <T>(
170
+ stdout: string,
171
+ command: string,
172
+ parseFailurePrefix: string,
173
+ ): Effect.Effect<T, GitHubCommandError> =>
174
+ Effect.try({
175
+ try: () => JSON.parse(stdout) as T,
176
+ catch: (error) =>
177
+ new GitHubCommandError({
178
+ command,
179
+ exitCode: 0,
180
+ stderr: `${parseFailurePrefix}: ${error instanceof Error ? error.message : String(error)}`,
181
+ message: `${parseFailurePrefix}: ${error instanceof Error ? error.message : String(error)}`,
182
+ }),
183
+ });
170
184
 
171
- /**
172
- * Fetch review threads for a PR via GraphQL.
173
- * Filters to unresolved threads when unresolvedOnly is true.
174
- */
175
- export const fetchThreads = Effect.fn("pr.fetchThreads")(function* (
176
- pr: number | null,
177
- unresolvedOnly: boolean,
185
+ const fetchAllRestPages = Effect.fn("pr.fetchAllRestPages")(function* <T>(
186
+ endpoint: string,
187
+ command: string,
188
+ parseFailurePrefix: string,
178
189
  ) {
179
190
  const service = yield* GitHubService;
191
+
192
+ const results: T[] = [];
193
+ let page = 1;
194
+
195
+ while (true) {
196
+ const separator = endpoint.includes("?") ? "&" : "?";
197
+ const result = yield* service.runGh([
198
+ "api",
199
+ `${endpoint}${separator}per_page=${REST_PAGE_SIZE}&page=${page}`,
200
+ ]);
201
+
202
+ const rawPage = yield* parseJson<T[]>(result.stdout, command, parseFailurePrefix);
203
+ results.push(...rawPage);
204
+
205
+ if (rawPage.length < REST_PAGE_SIZE) {
206
+ return results;
207
+ }
208
+
209
+ page += 1;
210
+ }
211
+ });
212
+
213
+ const fetchAllThreadNodes = Effect.fn("pr.fetchAllThreadNodes")(function* (pr: number) {
214
+ const service = yield* GitHubService;
180
215
  const repoInfo = yield* service.getRepoInfo();
181
216
 
182
- const resolvedPr = pr ?? (yield* viewPR(null)).number;
217
+ const nodes: ThreadNode[] = [];
218
+ let after: string | null = null;
183
219
 
184
- const response = (yield* service.runGraphQL(REVIEW_THREADS_QUERY, {
185
- owner: repoInfo.owner,
186
- name: repoInfo.name,
187
- pr: resolvedPr,
188
- })) as ThreadsQueryResult;
220
+ while (true) {
221
+ const response = (yield* service.runGraphQL(REVIEW_THREADS_QUERY, {
222
+ owner: repoInfo.owner,
223
+ name: repoInfo.name,
224
+ pr,
225
+ after,
226
+ })) as ThreadsQueryResult;
189
227
 
190
- const threads = response.repository.pullRequest.reviewThreads.nodes;
228
+ const page = response.repository.pullRequest.reviewThreads;
229
+ nodes.push(...page.nodes);
191
230
 
192
- const mapped: ReviewThread[] = threads
231
+ if (!page.pageInfo.hasNextPage || page.pageInfo.endCursor === null) {
232
+ return nodes;
233
+ }
234
+
235
+ after = page.pageInfo.endCursor;
236
+ }
237
+ });
238
+
239
+ const enrichThreads = (threads: ThreadNode[], reviewComments: ReviewComment[]): ReviewThread[] => {
240
+ const repliesByRootCommentId = new Map<number, ReviewComment[]>();
241
+
242
+ for (const comment of reviewComments) {
243
+ if (comment.inReplyToId === null) {
244
+ continue;
245
+ }
246
+
247
+ const replies = repliesByRootCommentId.get(comment.inReplyToId) ?? [];
248
+ replies.push(comment);
249
+ repliesByRootCommentId.set(comment.inReplyToId, replies);
250
+ }
251
+
252
+ return threads
193
253
  .map((node) => {
194
254
  const comment = node.comments.nodes[0];
195
255
  if (!comment) {
196
256
  return null;
197
257
  }
198
258
 
259
+ const replies = repliesByRootCommentId.get(comment.databaseId) ?? [];
260
+ const lastReply = replies.reduce<ReviewComment | null>((latest, current) => {
261
+ if (latest === null) {
262
+ return current;
263
+ }
264
+
265
+ return new Date(current.createdAt).getTime() > new Date(latest.createdAt).getTime()
266
+ ? current
267
+ : latest;
268
+ }, null);
269
+ const hasReply = replies.length > 0;
270
+ const needsHumanReply = !hasReply;
271
+
199
272
  return {
200
273
  threadId: node.id,
201
274
  commentId: comment.databaseId,
@@ -203,11 +276,55 @@ export const fetchThreads = Effect.fn("pr.fetchThreads")(function* (
203
276
  line: comment.line,
204
277
  body: comment.body,
205
278
  isResolved: node.isResolved,
279
+ hasReply,
280
+ replyCount: replies.length,
281
+ needsHumanReply,
282
+ isVisibleOpen: !node.isResolved || needsHumanReply,
283
+ lastReplyAuthor: lastReply?.author ?? null,
284
+ lastReplyAt: lastReply?.createdAt ?? null,
206
285
  };
207
286
  })
208
287
  .filter((thread): thread is ReviewThread => thread !== null);
288
+ };
289
+
290
+ const fetchThreadState = Effect.fn("pr.fetchThreadState")(function* (pr: number) {
291
+ const [threads, reviewComments] = yield* Effect.all([
292
+ fetchAllThreadNodes(pr),
293
+ fetchComments(pr, null),
294
+ ]);
295
+
296
+ return enrichThreads(threads, reviewComments);
297
+ });
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Handlers
301
+ // ---------------------------------------------------------------------------
302
+
303
+ const mapRawIssueComment = (comment: RawIssueComment): IssueComment => ({
304
+ id: comment.id as IssueCommentId,
305
+ author: comment.user.login,
306
+ body: comment.body,
307
+ createdAt: comment.created_at as IsoTimestamp,
308
+ url: comment.html_url as GitHubIssueCommentUrl,
309
+ });
310
+
311
+ /**
312
+ * Fetch review threads for a PR via GraphQL.
313
+ * Filters to unresolved threads when unresolvedOnly is true.
314
+ */
315
+ export const fetchThreads = Effect.fn("pr.fetchThreads")(function* (
316
+ pr: number | null,
317
+ unresolvedOnly: boolean,
318
+ visibleOpenOnly = false,
319
+ ) {
320
+ const resolvedPr = pr ?? (yield* viewPR(null)).number;
321
+ const threads = yield* fetchThreadState(resolvedPr);
322
+
323
+ if (visibleOpenOnly) {
324
+ return threads.filter((thread) => thread.isVisibleOpen);
325
+ }
209
326
 
210
- return unresolvedOnly ? mapped.filter((t) => !t.isResolved) : mapped;
327
+ return unresolvedOnly ? threads.filter((thread) => !thread.isResolved) : threads;
211
328
  });
212
329
 
213
330
  /**
@@ -223,21 +340,11 @@ export const fetchComments = Effect.fn("pr.fetchComments")(function* (
223
340
 
224
341
  const resolvedPr = pr ?? (yield* viewPR(null)).number;
225
342
 
226
- const result = yield* service.runGh([
227
- "api",
343
+ const raw = yield* fetchAllRestPages<RawReviewComment>(
228
344
  `repos/${repoInfo.owner}/${repoInfo.name}/pulls/${resolvedPr}/comments`,
229
- ]);
230
-
231
- const raw = yield* Effect.try({
232
- try: () => JSON.parse(result.stdout) as RawReviewComment[],
233
- catch: (error) =>
234
- new GitHubCommandError({
235
- command: "gh-tool pr comments",
236
- exitCode: 0,
237
- stderr: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
238
- message: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
239
- }),
240
- });
345
+ "gh-tool pr comments",
346
+ "Failed to parse response",
347
+ );
241
348
 
242
349
  const comments: ReviewComment[] = raw.map((c) => ({
243
350
  id: c.id,
@@ -272,21 +379,11 @@ export const fetchIssueComments = Effect.fn("pr.fetchIssueComments")(function* (
272
379
 
273
380
  const resolvedPr = pr ?? (yield* viewPR(null)).number;
274
381
 
275
- const result = yield* service.runGh([
276
- "api",
382
+ const raw = yield* fetchAllRestPages<RawIssueComment>(
277
383
  `repos/${repoInfo.owner}/${repoInfo.name}/issues/${resolvedPr}/comments`,
278
- ]);
279
-
280
- const raw = yield* Effect.try({
281
- try: () => JSON.parse(result.stdout) as RawIssueComment[],
282
- catch: (error) =>
283
- new GitHubCommandError({
284
- command: "gh-tool pr issue-comments",
285
- exitCode: 0,
286
- stderr: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
287
- message: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
288
- }),
289
- });
384
+ "gh-tool pr issue-comments",
385
+ "Failed to parse response",
386
+ );
290
387
 
291
388
  let comments = raw.map(mapRawIssueComment);
292
389
 
@@ -388,12 +485,31 @@ export const fetchDiscussionSummary = Effect.fn("pr.fetchDiscussionSummary")(fun
388
485
  : current,
389
486
  );
390
487
 
488
+ const repliedThreadCommentIds = new Set(
489
+ reviewComments
490
+ .filter((comment) => comment.inReplyToId !== null)
491
+ .map((comment) => comment.inReplyToId),
492
+ );
493
+
494
+ const unrepliedReviewThreadsCount = threads.filter(
495
+ (thread) => !repliedThreadCommentIds.has(thread.commentId),
496
+ ).length;
497
+
391
498
  return {
392
499
  issueCommentsCount: issueComments.length,
393
500
  latestIssueComment,
394
501
  reviewCommentsCount: reviewComments.length,
395
502
  reviewThreadsCount: threads.length,
503
+ visibleOpenReviewThreadsCount: threads.filter((thread) => thread.isVisibleOpen).length,
504
+ repliedReviewThreadsCount: threads.length - unrepliedReviewThreadsCount,
505
+ unrepliedReviewThreadsCount,
506
+ resolvedUnrepliedReviewThreadsCount: threads.filter(
507
+ (thread) => thread.isResolved && !repliedThreadCommentIds.has(thread.commentId),
508
+ ).length,
396
509
  unresolvedReviewThreadsCount: threads.filter((thread) => !thread.isResolved).length,
510
+ unresolvedUnrepliedReviewThreadsCount: threads.filter(
511
+ (thread) => !thread.isResolved && !repliedThreadCommentIds.has(thread.commentId),
512
+ ).length,
397
513
  };
398
514
  });
399
515
 
@@ -22,7 +22,7 @@ export class GitHubService extends ServiceMap.Service<
22
22
  readonly runGhJson: <T>(args: string[]) => Effect.Effect<T, GhError>;
23
23
  readonly runGraphQL: (
24
24
  query: string,
25
- variables: Record<string, string | number>,
25
+ variables: Record<string, string | number | null>,
26
26
  ) => Effect.Effect<unknown, GhError>;
27
27
  readonly getRepoInfo: () => Effect.Effect<RepoInfo, GhError>;
28
28
  }
@@ -135,11 +135,15 @@ export class GitHubService extends ServiceMap.Service<
135
135
 
136
136
  const runGraphQL = Effect.fn("GitHubService.runGraphQL")(function* (
137
137
  query: string,
138
- variables: Record<string, string | number>,
138
+ variables: Record<string, string | number | null>,
139
139
  ) {
140
140
  const args = ["api", "graphql", "-f", `query=${query}`];
141
141
 
142
142
  for (const [key, value] of Object.entries(variables)) {
143
+ if (value === null) {
144
+ continue;
145
+ }
146
+
143
147
  if (typeof value === "number") {
144
148
  args.push("-F", `${key}=${value}`);
145
149
  } else {
@@ -20,6 +20,12 @@ export type ReviewThread = {
20
20
  line: number;
21
21
  body: string;
22
22
  isResolved: boolean;
23
+ hasReply: boolean;
24
+ replyCount: number;
25
+ needsHumanReply: boolean;
26
+ isVisibleOpen: boolean;
27
+ lastReplyAuthor: string | null;
28
+ lastReplyAt: string | null;
23
29
  };
24
30
 
25
31
  export type ReviewComment = {