@blogic-cz/agent-tools 0.1.0
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/LICENSE +21 -0
- package/README.md +236 -0
- package/package.json +70 -0
- package/schemas/agent-tools.schema.json +319 -0
- package/src/az-tool/build.ts +295 -0
- package/src/az-tool/config.ts +33 -0
- package/src/az-tool/errors.ts +26 -0
- package/src/az-tool/extract-option-value.ts +12 -0
- package/src/az-tool/index.ts +181 -0
- package/src/az-tool/security.ts +130 -0
- package/src/az-tool/service.ts +292 -0
- package/src/az-tool/types.ts +67 -0
- package/src/config/index.ts +12 -0
- package/src/config/loader.ts +170 -0
- package/src/config/types.ts +82 -0
- package/src/credential-guard/claude-hook.ts +28 -0
- package/src/credential-guard/index.ts +435 -0
- package/src/db-tool/config-service.ts +38 -0
- package/src/db-tool/errors.ts +40 -0
- package/src/db-tool/index.ts +91 -0
- package/src/db-tool/schema.ts +69 -0
- package/src/db-tool/security.ts +116 -0
- package/src/db-tool/service.ts +605 -0
- package/src/db-tool/types.ts +33 -0
- package/src/gh-tool/config.ts +7 -0
- package/src/gh-tool/errors.ts +47 -0
- package/src/gh-tool/index.ts +140 -0
- package/src/gh-tool/issue.ts +361 -0
- package/src/gh-tool/pr/commands.ts +432 -0
- package/src/gh-tool/pr/core.ts +497 -0
- package/src/gh-tool/pr/helpers.ts +84 -0
- package/src/gh-tool/pr/index.ts +19 -0
- package/src/gh-tool/pr/review.ts +571 -0
- package/src/gh-tool/repo.ts +147 -0
- package/src/gh-tool/service.ts +192 -0
- package/src/gh-tool/types.ts +97 -0
- package/src/gh-tool/workflow.ts +542 -0
- package/src/index.ts +1 -0
- package/src/k8s-tool/errors.ts +21 -0
- package/src/k8s-tool/index.ts +151 -0
- package/src/k8s-tool/service.ts +227 -0
- package/src/k8s-tool/types.ts +9 -0
- package/src/logs-tool/errors.ts +29 -0
- package/src/logs-tool/index.ts +176 -0
- package/src/logs-tool/service.ts +323 -0
- package/src/logs-tool/types.ts +40 -0
- package/src/session-tool/config.ts +55 -0
- package/src/session-tool/errors.ts +38 -0
- package/src/session-tool/index.ts +270 -0
- package/src/session-tool/service.ts +210 -0
- package/src/session-tool/types.ts +28 -0
- package/src/shared/bun.ts +59 -0
- package/src/shared/cli.ts +38 -0
- package/src/shared/error-renderer.ts +42 -0
- package/src/shared/exec.ts +62 -0
- package/src/shared/format.ts +27 -0
- package/src/shared/index.ts +16 -0
- package/src/shared/throttle.ts +35 -0
- package/src/shared/types.ts +25 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { Effect } from "effect";
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
GitHubIssueCommentUrl,
|
|
5
|
+
IssueComment,
|
|
6
|
+
IssueCommentId,
|
|
7
|
+
IsoTimestamp,
|
|
8
|
+
ReviewComment,
|
|
9
|
+
ReviewThread,
|
|
10
|
+
} from "../types";
|
|
11
|
+
|
|
12
|
+
import { GitHubCommandError } from "../errors";
|
|
13
|
+
import { GitHubService } from "../service";
|
|
14
|
+
|
|
15
|
+
import { viewPR } from "./core";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// GraphQL queries & mutations
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
const REVIEW_THREADS_QUERY = `
|
|
22
|
+
query($owner: String!, $name: String!, $pr: Int!) {
|
|
23
|
+
repository(owner: $owner, name: $name) {
|
|
24
|
+
pullRequest(number: $pr) {
|
|
25
|
+
reviewThreads(first: 100) {
|
|
26
|
+
nodes {
|
|
27
|
+
id
|
|
28
|
+
isResolved
|
|
29
|
+
comments(first: 1) {
|
|
30
|
+
nodes {
|
|
31
|
+
id
|
|
32
|
+
databaseId
|
|
33
|
+
path
|
|
34
|
+
line
|
|
35
|
+
body
|
|
36
|
+
author { login }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
`;
|
|
45
|
+
|
|
46
|
+
const RESOLVE_THREAD_MUTATION = `
|
|
47
|
+
mutation($threadId: ID!) {
|
|
48
|
+
resolveReviewThread(input: {threadId: $threadId}) {
|
|
49
|
+
thread { id isResolved }
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
const PENDING_REVIEWS_QUERY = `
|
|
55
|
+
query($owner: String!, $name: String!, $pr: Int!) {
|
|
56
|
+
viewer { login }
|
|
57
|
+
repository(owner: $owner, name: $name) {
|
|
58
|
+
pullRequest(number: $pr) {
|
|
59
|
+
reviews(last: 100, states: [PENDING]) {
|
|
60
|
+
nodes {
|
|
61
|
+
id
|
|
62
|
+
state
|
|
63
|
+
author { login }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
const SUBMIT_REVIEW_MUTATION = `
|
|
72
|
+
mutation($reviewId: ID!, $event: PullRequestReviewEvent!, $body: String) {
|
|
73
|
+
submitPullRequestReview(input: { pullRequestReviewId: $reviewId, event: $event, body: $body }) {
|
|
74
|
+
pullRequestReview { id state }
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Internal types
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
type ThreadNode = {
|
|
84
|
+
id: string;
|
|
85
|
+
isResolved: boolean;
|
|
86
|
+
comments: {
|
|
87
|
+
nodes: Array<{
|
|
88
|
+
id: string;
|
|
89
|
+
databaseId: number;
|
|
90
|
+
path: string;
|
|
91
|
+
line: number;
|
|
92
|
+
body: string;
|
|
93
|
+
author: { login: string };
|
|
94
|
+
}>;
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
type ThreadsQueryResult = {
|
|
99
|
+
repository: {
|
|
100
|
+
pullRequest: {
|
|
101
|
+
reviewThreads: {
|
|
102
|
+
nodes: ThreadNode[];
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
type ResolveThreadResult = {
|
|
109
|
+
resolveReviewThread: {
|
|
110
|
+
thread: { id: string; isResolved: boolean };
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
type PendingReviewsQueryResult = {
|
|
115
|
+
viewer: { login: string };
|
|
116
|
+
repository: {
|
|
117
|
+
pullRequest: {
|
|
118
|
+
reviews: {
|
|
119
|
+
nodes: Array<{
|
|
120
|
+
id: string;
|
|
121
|
+
state: string;
|
|
122
|
+
author: { login: string };
|
|
123
|
+
}>;
|
|
124
|
+
};
|
|
125
|
+
};
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
type SubmitReviewResult = {
|
|
130
|
+
submitPullRequestReview: {
|
|
131
|
+
pullRequestReview: { id: string; state: string };
|
|
132
|
+
};
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
type RawReviewComment = {
|
|
136
|
+
id: number;
|
|
137
|
+
in_reply_to_id: number | null;
|
|
138
|
+
user: { login: string };
|
|
139
|
+
body: string;
|
|
140
|
+
path: string;
|
|
141
|
+
line: number;
|
|
142
|
+
created_at: string;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
type RawIssueComment = {
|
|
146
|
+
id: number;
|
|
147
|
+
user: { login: string };
|
|
148
|
+
body: string;
|
|
149
|
+
created_at: string;
|
|
150
|
+
html_url: string;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
type ReviewCommentById = {
|
|
154
|
+
id: number;
|
|
155
|
+
in_reply_to_id: number | null;
|
|
156
|
+
pull_request_url: string;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Handlers
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
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
|
+
});
|
|
170
|
+
|
|
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,
|
|
178
|
+
) {
|
|
179
|
+
const service = yield* GitHubService;
|
|
180
|
+
const repoInfo = yield* service.getRepoInfo();
|
|
181
|
+
|
|
182
|
+
const resolvedPr = pr ?? (yield* viewPR(null)).number;
|
|
183
|
+
|
|
184
|
+
const response = (yield* service.runGraphQL(REVIEW_THREADS_QUERY, {
|
|
185
|
+
owner: repoInfo.owner,
|
|
186
|
+
name: repoInfo.name,
|
|
187
|
+
pr: resolvedPr,
|
|
188
|
+
})) as ThreadsQueryResult;
|
|
189
|
+
|
|
190
|
+
const threads = response.repository.pullRequest.reviewThreads.nodes;
|
|
191
|
+
|
|
192
|
+
const mapped: ReviewThread[] = threads
|
|
193
|
+
.filter((node) => node.comments.nodes.length > 0)
|
|
194
|
+
.map((node) => {
|
|
195
|
+
const comment = node.comments.nodes[0]!;
|
|
196
|
+
return {
|
|
197
|
+
threadId: node.id,
|
|
198
|
+
commentId: comment.databaseId,
|
|
199
|
+
path: comment.path,
|
|
200
|
+
line: comment.line,
|
|
201
|
+
body: comment.body,
|
|
202
|
+
isResolved: node.isResolved,
|
|
203
|
+
};
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
return unresolvedOnly ? mapped.filter((t) => !t.isResolved) : mapped;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Fetch review comments for a PR via REST API.
|
|
211
|
+
* Optionally filters to comments created at or after `since` timestamp.
|
|
212
|
+
*/
|
|
213
|
+
export const fetchComments = Effect.fn("pr.fetchComments")(function* (
|
|
214
|
+
pr: number | null,
|
|
215
|
+
since: string | null,
|
|
216
|
+
) {
|
|
217
|
+
const service = yield* GitHubService;
|
|
218
|
+
const repoInfo = yield* service.getRepoInfo();
|
|
219
|
+
|
|
220
|
+
const resolvedPr = pr ?? (yield* viewPR(null)).number;
|
|
221
|
+
|
|
222
|
+
const result = yield* service.runGh([
|
|
223
|
+
"api",
|
|
224
|
+
`repos/${repoInfo.owner}/${repoInfo.name}/pulls/${resolvedPr}/comments`,
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
const raw = yield* Effect.try({
|
|
228
|
+
try: () => JSON.parse(result.stdout) as RawReviewComment[],
|
|
229
|
+
catch: (error) =>
|
|
230
|
+
new GitHubCommandError({
|
|
231
|
+
command: "gh-tool pr comments",
|
|
232
|
+
exitCode: 0,
|
|
233
|
+
stderr: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
|
|
234
|
+
message: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
|
|
235
|
+
}),
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const comments: ReviewComment[] = raw.map((c) => ({
|
|
239
|
+
id: c.id,
|
|
240
|
+
inReplyToId: c.in_reply_to_id,
|
|
241
|
+
author: c.user.login,
|
|
242
|
+
body: c.body,
|
|
243
|
+
path: c.path,
|
|
244
|
+
line: c.line,
|
|
245
|
+
createdAt: c.created_at,
|
|
246
|
+
}));
|
|
247
|
+
|
|
248
|
+
if (since !== null) {
|
|
249
|
+
const sinceMs = new Date(since).getTime();
|
|
250
|
+
return comments.filter((c) => new Date(c.createdAt).getTime() >= sinceMs);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return comments;
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Fetch general PR discussion comments (issue comments) via REST API.
|
|
258
|
+
* Supports optional filtering by timestamp, author, and body substring.
|
|
259
|
+
*/
|
|
260
|
+
export const fetchIssueComments = Effect.fn("pr.fetchIssueComments")(function* (
|
|
261
|
+
pr: number | null,
|
|
262
|
+
since: string | null,
|
|
263
|
+
author: string | null,
|
|
264
|
+
bodyContains: string | null,
|
|
265
|
+
) {
|
|
266
|
+
const service = yield* GitHubService;
|
|
267
|
+
const repoInfo = yield* service.getRepoInfo();
|
|
268
|
+
|
|
269
|
+
const resolvedPr = pr ?? (yield* viewPR(null)).number;
|
|
270
|
+
|
|
271
|
+
const result = yield* service.runGh([
|
|
272
|
+
"api",
|
|
273
|
+
`repos/${repoInfo.owner}/${repoInfo.name}/issues/${resolvedPr}/comments`,
|
|
274
|
+
]);
|
|
275
|
+
|
|
276
|
+
const raw = yield* Effect.try({
|
|
277
|
+
try: () => JSON.parse(result.stdout) as RawIssueComment[],
|
|
278
|
+
catch: (error) =>
|
|
279
|
+
new GitHubCommandError({
|
|
280
|
+
command: "gh-tool pr issue-comments",
|
|
281
|
+
exitCode: 0,
|
|
282
|
+
stderr: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
|
|
283
|
+
message: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
|
|
284
|
+
}),
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
let comments = raw.map(mapRawIssueComment);
|
|
288
|
+
|
|
289
|
+
if (since !== null) {
|
|
290
|
+
const sinceMs = new Date(since).getTime();
|
|
291
|
+
comments = comments.filter((comment) => new Date(comment.createdAt).getTime() >= sinceMs);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (author !== null) {
|
|
295
|
+
const authorFilter = author.toLowerCase();
|
|
296
|
+
comments = comments.filter((comment) => comment.author.toLowerCase().includes(authorFilter));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (bodyContains !== null) {
|
|
300
|
+
const bodyFilter = bodyContains.toLowerCase();
|
|
301
|
+
comments = comments.filter((comment) => comment.body.toLowerCase().includes(bodyFilter));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return comments;
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
export const fetchLatestIssueComment = Effect.fn("pr.fetchLatestIssueComment")(function* (
|
|
308
|
+
pr: number | null,
|
|
309
|
+
author: string | null,
|
|
310
|
+
bodyContains: string | null,
|
|
311
|
+
) {
|
|
312
|
+
const comments = yield* fetchIssueComments(pr, null, author, bodyContains);
|
|
313
|
+
|
|
314
|
+
if (comments.length === 0) {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const latest = comments.reduce((current, next) =>
|
|
319
|
+
new Date(next.createdAt).getTime() > new Date(current.createdAt).getTime() ? next : current,
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
return latest;
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
export const postIssueComment = Effect.fn("pr.postIssueComment")(function* (
|
|
326
|
+
pr: number | null,
|
|
327
|
+
body: string,
|
|
328
|
+
) {
|
|
329
|
+
const service = yield* GitHubService;
|
|
330
|
+
const repoInfo = yield* service.getRepoInfo();
|
|
331
|
+
|
|
332
|
+
const resolvedPr = pr ?? (yield* viewPR(null)).number;
|
|
333
|
+
|
|
334
|
+
const trimmedBody = body.trim();
|
|
335
|
+
if (trimmedBody.length === 0) {
|
|
336
|
+
return yield* Effect.fail(
|
|
337
|
+
new GitHubCommandError({
|
|
338
|
+
command: "gh-tool pr comment",
|
|
339
|
+
exitCode: 0,
|
|
340
|
+
stderr: "Comment body cannot be empty",
|
|
341
|
+
message: "Comment body cannot be empty",
|
|
342
|
+
}),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const result = yield* service.runGh([
|
|
347
|
+
"api",
|
|
348
|
+
"-X",
|
|
349
|
+
"POST",
|
|
350
|
+
`repos/${repoInfo.owner}/${repoInfo.name}/issues/${resolvedPr}/comments`,
|
|
351
|
+
"-f",
|
|
352
|
+
`body=${trimmedBody}`,
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
const rawComment = yield* Effect.try({
|
|
356
|
+
try: () => JSON.parse(result.stdout) as RawIssueComment,
|
|
357
|
+
catch: (error) =>
|
|
358
|
+
new GitHubCommandError({
|
|
359
|
+
command: "gh-tool pr comment",
|
|
360
|
+
exitCode: 0,
|
|
361
|
+
stderr: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
|
|
362
|
+
message: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
|
|
363
|
+
}),
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
return mapRawIssueComment(rawComment);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
export const fetchDiscussionSummary = Effect.fn("pr.fetchDiscussionSummary")(function* (
|
|
370
|
+
pr: number | null,
|
|
371
|
+
) {
|
|
372
|
+
const [issueComments, reviewComments, threads] = yield* Effect.all([
|
|
373
|
+
fetchIssueComments(pr, null, null, null),
|
|
374
|
+
fetchComments(pr, null),
|
|
375
|
+
fetchThreads(pr, false),
|
|
376
|
+
]);
|
|
377
|
+
|
|
378
|
+
const latestIssueComment =
|
|
379
|
+
issueComments.length === 0
|
|
380
|
+
? null
|
|
381
|
+
: issueComments.reduce((current, next) =>
|
|
382
|
+
new Date(next.createdAt).getTime() > new Date(current.createdAt).getTime()
|
|
383
|
+
? next
|
|
384
|
+
: current,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
issueCommentsCount: issueComments.length,
|
|
389
|
+
latestIssueComment,
|
|
390
|
+
reviewCommentsCount: reviewComments.length,
|
|
391
|
+
reviewThreadsCount: threads.length,
|
|
392
|
+
unresolvedReviewThreadsCount: threads.filter((thread) => !thread.isResolved).length,
|
|
393
|
+
};
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
const fetchReviewCommentById = Effect.fn("pr.fetchReviewCommentById")(function* (
|
|
397
|
+
commentId: number,
|
|
398
|
+
) {
|
|
399
|
+
const service = yield* GitHubService;
|
|
400
|
+
|
|
401
|
+
const comment = yield* service.runGhJson<ReviewCommentById>([
|
|
402
|
+
"api",
|
|
403
|
+
`repos/{owner}/{repo}/pulls/comments/${commentId}`,
|
|
404
|
+
]);
|
|
405
|
+
|
|
406
|
+
return comment;
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Reply to an inline review comment via REST API.
|
|
411
|
+
*/
|
|
412
|
+
export const replyToComment = Effect.fn("pr.replyToComment")(function* (
|
|
413
|
+
pr: number | null,
|
|
414
|
+
commentId: number,
|
|
415
|
+
body: string,
|
|
416
|
+
) {
|
|
417
|
+
const service = yield* GitHubService;
|
|
418
|
+
const repoInfo = yield* service.getRepoInfo();
|
|
419
|
+
|
|
420
|
+
const resolvedPr = pr ?? (yield* viewPR(null)).number;
|
|
421
|
+
|
|
422
|
+
const trimmedBody = body.trim();
|
|
423
|
+
if (trimmedBody.length === 0) {
|
|
424
|
+
return yield* Effect.fail(
|
|
425
|
+
new GitHubCommandError({
|
|
426
|
+
command: "gh-tool pr reply",
|
|
427
|
+
exitCode: 0,
|
|
428
|
+
stderr: "Reply body cannot be empty",
|
|
429
|
+
message: "Reply body cannot be empty",
|
|
430
|
+
}),
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const targetComment = yield* fetchReviewCommentById(commentId);
|
|
435
|
+
const rootCommentId = targetComment.in_reply_to_id ?? targetComment.id;
|
|
436
|
+
|
|
437
|
+
if (!targetComment.pull_request_url.endsWith(`/pulls/${resolvedPr}`)) {
|
|
438
|
+
return yield* Effect.fail(
|
|
439
|
+
new GitHubCommandError({
|
|
440
|
+
command: "gh-tool pr reply",
|
|
441
|
+
exitCode: 0,
|
|
442
|
+
stderr: `Comment ${commentId} does not belong to PR #${resolvedPr}`,
|
|
443
|
+
message: `Comment ${commentId} does not belong to PR #${resolvedPr}`,
|
|
444
|
+
}),
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const result = yield* service
|
|
449
|
+
.runGh([
|
|
450
|
+
"api",
|
|
451
|
+
"-X",
|
|
452
|
+
"POST",
|
|
453
|
+
`repos/${repoInfo.owner}/${repoInfo.name}/pulls/${resolvedPr}/comments/${rootCommentId}/replies`,
|
|
454
|
+
"-f",
|
|
455
|
+
`body=${trimmedBody}`,
|
|
456
|
+
])
|
|
457
|
+
.pipe(
|
|
458
|
+
Effect.catchTag("GitHubCommandError", (error) => {
|
|
459
|
+
if (error.stderr.includes("can only have one pending review per pull request")) {
|
|
460
|
+
return Effect.fail(
|
|
461
|
+
new GitHubCommandError({
|
|
462
|
+
command: error.command,
|
|
463
|
+
exitCode: error.exitCode,
|
|
464
|
+
stderr:
|
|
465
|
+
"Cannot reply while you have a pending review on this PR. Submit or dismiss your pending review in GitHub, then run the command again.",
|
|
466
|
+
message:
|
|
467
|
+
"Cannot reply while you have a pending review on this PR. Submit or dismiss your pending review in GitHub, then run the command again.",
|
|
468
|
+
}),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (error.stderr.includes("Validation Failed")) {
|
|
473
|
+
return Effect.fail(
|
|
474
|
+
new GitHubCommandError({
|
|
475
|
+
command: error.command,
|
|
476
|
+
exitCode: error.exitCode,
|
|
477
|
+
stderr:
|
|
478
|
+
"Reply failed with GitHub validation error. Common causes: (1) you have a pending review on this PR, (2) comment ID is from a different PR, or (3) comment is not a top-level thread comment. Submit/dismiss pending reviews and retry.",
|
|
479
|
+
message:
|
|
480
|
+
"Reply failed with GitHub validation error. Common causes: (1) you have a pending review on this PR, (2) comment ID is from a different PR, or (3) comment is not a top-level thread comment. Submit/dismiss pending reviews and retry.",
|
|
481
|
+
}),
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return Effect.fail(error);
|
|
486
|
+
}),
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
const parsed = yield* Effect.try({
|
|
490
|
+
try: () =>
|
|
491
|
+
JSON.parse(result.stdout) as {
|
|
492
|
+
id: number;
|
|
493
|
+
},
|
|
494
|
+
catch: (error) =>
|
|
495
|
+
new GitHubCommandError({
|
|
496
|
+
command: "gh-tool pr reply",
|
|
497
|
+
exitCode: 0,
|
|
498
|
+
stderr: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
|
|
499
|
+
message: `Failed to parse response: ${error instanceof Error ? error.message : String(error)}`,
|
|
500
|
+
}),
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
return { success: true as const, commentId: parsed.id };
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Resolve a review thread via GraphQL mutation.
|
|
508
|
+
*/
|
|
509
|
+
export const resolveThread = Effect.fn("pr.resolveThread")(function* (threadId: string) {
|
|
510
|
+
const service = yield* GitHubService;
|
|
511
|
+
|
|
512
|
+
const response = (yield* service.runGraphQL(RESOLVE_THREAD_MUTATION, {
|
|
513
|
+
threadId,
|
|
514
|
+
})) as ResolveThreadResult;
|
|
515
|
+
|
|
516
|
+
return {
|
|
517
|
+
resolved: response.resolveReviewThread.thread.isResolved,
|
|
518
|
+
threadId: response.resolveReviewThread.thread.id,
|
|
519
|
+
};
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
export const submitPendingReview = Effect.fn("pr.submitPendingReview")(function* (
|
|
523
|
+
pr: number | null,
|
|
524
|
+
reviewId: string | null,
|
|
525
|
+
body: string | null,
|
|
526
|
+
) {
|
|
527
|
+
const service = yield* GitHubService;
|
|
528
|
+
const repoInfo = yield* service.getRepoInfo();
|
|
529
|
+
|
|
530
|
+
const resolvedPr = pr ?? (yield* viewPR(null)).number;
|
|
531
|
+
|
|
532
|
+
let targetReviewId = reviewId;
|
|
533
|
+
|
|
534
|
+
if (targetReviewId === null) {
|
|
535
|
+
const pending = (yield* service.runGraphQL(PENDING_REVIEWS_QUERY, {
|
|
536
|
+
owner: repoInfo.owner,
|
|
537
|
+
name: repoInfo.name,
|
|
538
|
+
pr: resolvedPr,
|
|
539
|
+
})) as PendingReviewsQueryResult;
|
|
540
|
+
|
|
541
|
+
const viewerLogin = pending.viewer.login;
|
|
542
|
+
const pendingReviews = pending.repository.pullRequest.reviews.nodes;
|
|
543
|
+
|
|
544
|
+
const ownPendingReview = pendingReviews.find((review) => review.author.login === viewerLogin);
|
|
545
|
+
|
|
546
|
+
if (!ownPendingReview) {
|
|
547
|
+
return yield* Effect.fail(
|
|
548
|
+
new GitHubCommandError({
|
|
549
|
+
command: "gh-tool pr submit-review",
|
|
550
|
+
exitCode: 0,
|
|
551
|
+
stderr: "No pending review found for current user on this PR",
|
|
552
|
+
message: "No pending review found for current user on this PR",
|
|
553
|
+
}),
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
targetReviewId = ownPendingReview.id;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const result = (yield* service.runGraphQL(SUBMIT_REVIEW_MUTATION, {
|
|
561
|
+
reviewId: targetReviewId,
|
|
562
|
+
event: "COMMENT",
|
|
563
|
+
body: body ?? "",
|
|
564
|
+
})) as SubmitReviewResult;
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
submitted: true as const,
|
|
568
|
+
reviewId: result.submitPullRequestReview.pullRequestReview.id,
|
|
569
|
+
state: result.submitPullRequestReview.pullRequestReview.state,
|
|
570
|
+
};
|
|
571
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { Command, Flag } from "effect/unstable/cli";
|
|
2
|
+
import { Effect, Option } from "effect";
|
|
3
|
+
|
|
4
|
+
import { formatOption, logFormatted } from "../shared";
|
|
5
|
+
import { GitHubCommandError } from "./errors";
|
|
6
|
+
import { GitHubService } from "./service";
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Types
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
type OrgRepo = {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string | null;
|
|
15
|
+
visibility: string;
|
|
16
|
+
updatedAt: string;
|
|
17
|
+
url: string;
|
|
18
|
+
isArchived: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type CodeSearchResponse = {
|
|
22
|
+
items: Array<{
|
|
23
|
+
repository: {
|
|
24
|
+
full_name: string;
|
|
25
|
+
};
|
|
26
|
+
path: string;
|
|
27
|
+
html_url: string;
|
|
28
|
+
}>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Internal handlers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const listOrgRepos = Effect.fn("repo.listOrgRepos")(function* (opts: {
|
|
36
|
+
org: string;
|
|
37
|
+
limit: number;
|
|
38
|
+
visibility: string | null;
|
|
39
|
+
}) {
|
|
40
|
+
const gh = yield* GitHubService;
|
|
41
|
+
|
|
42
|
+
const args = [
|
|
43
|
+
"repo",
|
|
44
|
+
"list",
|
|
45
|
+
opts.org,
|
|
46
|
+
"--json",
|
|
47
|
+
"name,description,visibility,updatedAt,url,isArchived",
|
|
48
|
+
"--limit",
|
|
49
|
+
String(opts.limit),
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
if (opts.visibility !== null) {
|
|
53
|
+
args.push("--visibility", opts.visibility);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return yield* gh.runGhJson<OrgRepo[]>(args);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const searchOrgCode = Effect.fn("repo.searchOrgCode")(function* (opts: {
|
|
60
|
+
org: string;
|
|
61
|
+
query: string;
|
|
62
|
+
limit: number;
|
|
63
|
+
}) {
|
|
64
|
+
const gh = yield* GitHubService;
|
|
65
|
+
|
|
66
|
+
const trimmedQuery = opts.query.trim();
|
|
67
|
+
if (trimmedQuery.length === 0) {
|
|
68
|
+
return yield* new GitHubCommandError({
|
|
69
|
+
message: "Search query cannot be empty",
|
|
70
|
+
command: "repo search-code",
|
|
71
|
+
exitCode: 1,
|
|
72
|
+
stderr: "Search query cannot be empty",
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const searchQuery = encodeURIComponent(`${trimmedQuery} org:${opts.org}`);
|
|
77
|
+
const args = ["api", `/search/code?q=${searchQuery}&per_page=${opts.limit}`];
|
|
78
|
+
|
|
79
|
+
const response = yield* gh.runGhJson<CodeSearchResponse>(args);
|
|
80
|
+
|
|
81
|
+
return response.items.map((item) => ({
|
|
82
|
+
repo: item.repository.full_name,
|
|
83
|
+
path: item.path,
|
|
84
|
+
url: item.html_url,
|
|
85
|
+
}));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// CLI Commands
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
export const repoInfoCommand = Command.make("info", { format: formatOption }, ({ format }) =>
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
const gh = yield* GitHubService;
|
|
95
|
+
const info = yield* gh.getRepoInfo();
|
|
96
|
+
yield* logFormatted(info, format);
|
|
97
|
+
}),
|
|
98
|
+
).pipe(Command.withDescription("Show repository information (owner, name, default branch, URL)"));
|
|
99
|
+
|
|
100
|
+
export const repoListCommand = Command.make(
|
|
101
|
+
"list",
|
|
102
|
+
{
|
|
103
|
+
format: formatOption,
|
|
104
|
+
limit: Flag.integer("limit").pipe(
|
|
105
|
+
Flag.withDescription("Maximum number of repositories to return"),
|
|
106
|
+
Flag.withDefault(30),
|
|
107
|
+
),
|
|
108
|
+
org: Flag.string("org").pipe(Flag.withDescription("GitHub organization slug")),
|
|
109
|
+
visibility: Flag.choice("visibility", ["public", "private", "all"]).pipe(
|
|
110
|
+
Flag.withDescription("Filter by repository visibility"),
|
|
111
|
+
Flag.optional,
|
|
112
|
+
),
|
|
113
|
+
},
|
|
114
|
+
({ format, limit, org, visibility }) =>
|
|
115
|
+
Effect.gen(function* () {
|
|
116
|
+
const repos = yield* listOrgRepos({
|
|
117
|
+
limit,
|
|
118
|
+
org,
|
|
119
|
+
visibility: Option.getOrNull(visibility),
|
|
120
|
+
});
|
|
121
|
+
yield* logFormatted(repos, format);
|
|
122
|
+
}),
|
|
123
|
+
).pipe(
|
|
124
|
+
Command.withDescription("List repositories in a GitHub organization (filter by --visibility)"),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
export const repoSearchCodeCommand = Command.make(
|
|
128
|
+
"search-code",
|
|
129
|
+
{
|
|
130
|
+
format: formatOption,
|
|
131
|
+
limit: Flag.integer("limit").pipe(
|
|
132
|
+
Flag.withDescription("Maximum number of results to return"),
|
|
133
|
+
Flag.withDefault(30),
|
|
134
|
+
),
|
|
135
|
+
org: Flag.string("org").pipe(Flag.withDescription("GitHub organization slug")),
|
|
136
|
+
query: Flag.string("query").pipe(Flag.withDescription("Code search query")),
|
|
137
|
+
},
|
|
138
|
+
({ format, limit, org, query }) =>
|
|
139
|
+
Effect.gen(function* () {
|
|
140
|
+
const results = yield* searchOrgCode({
|
|
141
|
+
limit,
|
|
142
|
+
org,
|
|
143
|
+
query,
|
|
144
|
+
});
|
|
145
|
+
yield* logFormatted(results, format);
|
|
146
|
+
}),
|
|
147
|
+
).pipe(Command.withDescription("Search code across organization repositories"));
|