@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 +1 -1
- package/src/gh-tool/pr/commands.ts +17 -6
- package/src/gh-tool/pr/review.ts +172 -56
- package/src/gh-tool/service.ts +6 -2
- package/src/gh-tool/types.ts +6 -0
package/package.json
CHANGED
|
@@ -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(
|
|
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,
|
|
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
|
|
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
|
|
package/src/gh-tool/pr/review.ts
CHANGED
|
@@ -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
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
|
217
|
+
const nodes: ThreadNode[] = [];
|
|
218
|
+
let after: string | null = null;
|
|
183
219
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
228
|
+
const page = response.repository.pullRequest.reviewThreads;
|
|
229
|
+
nodes.push(...page.nodes);
|
|
191
230
|
|
|
192
|
-
|
|
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 ?
|
|
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
|
|
227
|
-
"api",
|
|
343
|
+
const raw = yield* fetchAllRestPages<RawReviewComment>(
|
|
228
344
|
`repos/${repoInfo.owner}/${repoInfo.name}/pulls/${resolvedPr}/comments`,
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
276
|
-
"api",
|
|
382
|
+
const raw = yield* fetchAllRestPages<RawIssueComment>(
|
|
277
383
|
`repos/${repoInfo.owner}/${repoInfo.name}/issues/${resolvedPr}/comments`,
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
package/src/gh-tool/service.ts
CHANGED
|
@@ -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 {
|
package/src/gh-tool/types.ts
CHANGED
|
@@ -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 = {
|