bellwether 0.0.1

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.
Files changed (77) hide show
  1. package/.claude-plugin/plugin.json +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +120 -0
  4. package/SKILL.md +92 -0
  5. package/dist/bin.d.ts +3 -0
  6. package/dist/bin.d.ts.map +1 -0
  7. package/dist/bin.js +17 -0
  8. package/dist/bin.js.map +1 -0
  9. package/dist/cli.d.ts +13 -0
  10. package/dist/cli.d.ts.map +1 -0
  11. package/dist/cli.js +36 -0
  12. package/dist/cli.js.map +1 -0
  13. package/dist/commands/check.d.ts +191 -0
  14. package/dist/commands/check.d.ts.map +1 -0
  15. package/dist/commands/check.js +186 -0
  16. package/dist/commands/check.js.map +1 -0
  17. package/dist/commands/ci.d.ts +8 -0
  18. package/dist/commands/ci.d.ts.map +1 -0
  19. package/dist/commands/ci.js +28 -0
  20. package/dist/commands/ci.js.map +1 -0
  21. package/dist/commands/hook-add.d.ts +2 -0
  22. package/dist/commands/hook-add.d.ts.map +1 -0
  23. package/dist/commands/hook-add.js +97 -0
  24. package/dist/commands/hook-add.js.map +1 -0
  25. package/dist/commands/hook-check.d.ts +2 -0
  26. package/dist/commands/hook-check.d.ts.map +1 -0
  27. package/dist/commands/hook-check.js +29 -0
  28. package/dist/commands/hook-check.js.map +1 -0
  29. package/dist/commands/reviews.d.ts +74 -0
  30. package/dist/commands/reviews.d.ts.map +1 -0
  31. package/dist/commands/reviews.js +133 -0
  32. package/dist/commands/reviews.js.map +1 -0
  33. package/dist/context.d.ts +13 -0
  34. package/dist/context.d.ts.map +1 -0
  35. package/dist/context.js +53 -0
  36. package/dist/context.js.map +1 -0
  37. package/dist/github/auth.d.ts +9 -0
  38. package/dist/github/auth.d.ts.map +1 -0
  39. package/dist/github/auth.js +49 -0
  40. package/dist/github/auth.js.map +1 -0
  41. package/dist/github/checks.d.ts +19 -0
  42. package/dist/github/checks.d.ts.map +1 -0
  43. package/dist/github/checks.js +112 -0
  44. package/dist/github/checks.js.map +1 -0
  45. package/dist/github/comments.d.ts +86 -0
  46. package/dist/github/comments.d.ts.map +1 -0
  47. package/dist/github/comments.js +309 -0
  48. package/dist/github/comments.js.map +1 -0
  49. package/dist/github/fetch.d.ts +21 -0
  50. package/dist/github/fetch.d.ts.map +1 -0
  51. package/dist/github/fetch.js +177 -0
  52. package/dist/github/fetch.js.map +1 -0
  53. package/dist/github/index.d.ts +6 -0
  54. package/dist/github/index.d.ts.map +1 -0
  55. package/dist/github/index.js +6 -0
  56. package/dist/github/index.js.map +1 -0
  57. package/dist/github/repo.d.ts +27 -0
  58. package/dist/github/repo.d.ts.map +1 -0
  59. package/dist/github/repo.js +72 -0
  60. package/dist/github/repo.js.map +1 -0
  61. package/hooks/hooks.json +29 -0
  62. package/package.json +65 -0
  63. package/skills/bellwether/SKILL.md +92 -0
  64. package/src/bin.ts +15 -0
  65. package/src/cli.ts +39 -0
  66. package/src/commands/check.ts +251 -0
  67. package/src/commands/ci.ts +44 -0
  68. package/src/commands/hook-add.ts +139 -0
  69. package/src/commands/hook-check.ts +35 -0
  70. package/src/commands/reviews.ts +225 -0
  71. package/src/context.ts +86 -0
  72. package/src/github/auth.ts +40 -0
  73. package/src/github/checks.ts +187 -0
  74. package/src/github/comments.ts +522 -0
  75. package/src/github/fetch.ts +233 -0
  76. package/src/github/index.ts +35 -0
  77. package/src/github/repo.ts +146 -0
@@ -0,0 +1,187 @@
1
+ import { ghFetch, type ProxyFetch } from "./fetch.js";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Types
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface FailingCheck {
8
+ name: string;
9
+ conclusion: string;
10
+ html_url: string;
11
+ log: string;
12
+ }
13
+
14
+ interface RawCheckRun {
15
+ id: number;
16
+ name: string;
17
+ status: string;
18
+ conclusion: string | null;
19
+ html_url: string;
20
+ }
21
+
22
+ export interface CIStatus {
23
+ sha: string;
24
+ total: number;
25
+ passing: number;
26
+ failing: number;
27
+ pending: number;
28
+ passed: string[];
29
+ in_progress: string[];
30
+ failures: FailingCheck[];
31
+ }
32
+
33
+ // ---------------------------------------------------------------------------
34
+ // Job log fetching + RTK-style filtering
35
+ // ---------------------------------------------------------------------------
36
+
37
+ const MAX_LINES = 60;
38
+ // eslint-disable-next-line no-control-regex
39
+ const ANSI_RE = /\u001b\[[0-9;]*[a-zA-Z]/g;
40
+ const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z\s?/;
41
+ const GROUP_MARKERS = /^##\[(group|endgroup|command|section)\]/;
42
+ const NOISE_RE =
43
+ /^(##\[debug\]|##\[notice\]|##\[save-state\]|##\[add-matcher\]|##\[remove-matcher\]|##\[set-output\]|##\[set-env\]|##\[add-path\]|##\[warning\]Couldn't find any|Downloading |Download action repository|Complete job name:|shell: \/|error: script ".*" exited with code|\$ )/;
44
+ // RTK "failure focus": strip passing test/check lines, keep only failures
45
+ const PASSING_LINE_RE = /^( *✓ | *✔ | *PASS | *√ | *ok \d| *\. |\s*\d+ passing)/;
46
+
47
+ function parseJobId(htmlUrl: string): string | null {
48
+ const m = htmlUrl.match(/\/job\/(\d+)/);
49
+ return m?.[1] ?? null;
50
+ }
51
+
52
+ function filterLog(raw: string): string {
53
+ const lines = raw.split("\n");
54
+
55
+ // Find the failed step: look for ##[error]Process completed with exit code
56
+ // and walk backwards to find its ##[group] start
57
+ let failStart = -1;
58
+ let failEnd = -1;
59
+ for (let i = lines.length - 1; i >= 0; i--) {
60
+ const line = lines[i]!;
61
+ if (failEnd === -1 && line.includes("##[error]Process completed with exit code")) {
62
+ failEnd = i;
63
+ }
64
+ if (failEnd !== -1 && line.includes("##[group]")) {
65
+ failStart = i;
66
+ break;
67
+ }
68
+ }
69
+
70
+ // If we found a failed step, extract just that; otherwise use entire log
71
+ const stepLines = failStart !== -1 && failEnd !== -1 ? lines.slice(failStart, failEnd) : lines;
72
+
73
+ const cleaned = stepLines
74
+ .map((l) => l.replace(ANSI_RE, "")) // strip ANSI
75
+ .map((l) => l.replace(TIMESTAMP_RE, "")) // strip timestamps
76
+ .filter((l) => !GROUP_MARKERS.test(l)) // strip ##[group] markers
77
+ .filter((l) => !NOISE_RE.test(l)) // strip debug/notice noise
78
+ .filter((l) => !PASSING_LINE_RE.test(l)) // strip passing test lines (failure focus)
79
+ .map((l) => l.replace(/^##\[error\]/, "")) // strip ##[error] prefix, keep content
80
+ .map((l) => l.replace(/^##\[warning\]/, "")) // strip ##[warning] prefix, keep content
81
+ .filter((l) => l.trim() !== "") // strip blank lines
82
+ .map((l) => (l.length > 200 ? l.slice(0, 200) + "…" : l)); // truncate long lines
83
+
84
+ // Tail: keep last N lines (error summaries are at the end)
85
+ const tail = cleaned.length > MAX_LINES ? cleaned.slice(-MAX_LINES) : cleaned;
86
+
87
+ return tail.join("\n");
88
+ }
89
+
90
+ async function fetchJobLog(
91
+ owner: string,
92
+ repo: string,
93
+ jobId: string,
94
+ token: string,
95
+ proxyFetch: ProxyFetch,
96
+ ): Promise<string> {
97
+ try {
98
+ const res = await ghFetch(
99
+ `https://api.github.com/repos/${owner}/${repo}/actions/jobs/${jobId}/logs`,
100
+ token,
101
+ proxyFetch,
102
+ );
103
+ if (!res.ok) {
104
+ return `[failed to fetch logs: ${res.status}]`;
105
+ }
106
+ return filterLog(await res.text());
107
+ } catch {
108
+ return "[failed to fetch logs]";
109
+ }
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Fetch CI status
114
+ // ---------------------------------------------------------------------------
115
+
116
+ export async function fetchCIStatus(
117
+ owner: string,
118
+ repo: string,
119
+ prNumber: number,
120
+ token: string,
121
+ proxyFetch: ProxyFetch,
122
+ headSha?: string,
123
+ ): Promise<CIStatus> {
124
+ let sha = headSha;
125
+ if (!sha) {
126
+ const prResponse = await ghFetch(
127
+ `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`,
128
+ token,
129
+ proxyFetch,
130
+ );
131
+ if (!prResponse.ok) {
132
+ throw new Error(`Failed to fetch PR: ${prResponse.status}`);
133
+ }
134
+ const pr = (await prResponse.json()) as { head: { sha: string } };
135
+ sha = pr.head.sha;
136
+ }
137
+
138
+ // Fetch check runs for that SHA
139
+ const checksResponse = await ghFetch(
140
+ `https://api.github.com/repos/${owner}/${repo}/commits/${sha}/check-runs?per_page=100`,
141
+ token,
142
+ proxyFetch,
143
+ );
144
+ if (!checksResponse.ok) {
145
+ throw new Error(`Failed to fetch checks: ${checksResponse.status}`);
146
+ }
147
+ const checksData = (await checksResponse.json()) as { check_runs: RawCheckRun[] };
148
+ const rawChecks = checksData.check_runs;
149
+
150
+ const isPassing = (c: RawCheckRun) =>
151
+ c.conclusion === "success" || c.conclusion === "skipped" || c.conclusion === "neutral";
152
+ const isFailing = (c: RawCheckRun) =>
153
+ c.conclusion === "failure" ||
154
+ c.conclusion === "timed_out" ||
155
+ c.conclusion === "action_required";
156
+
157
+ const passed = rawChecks.filter(isPassing).map((c) => c.name);
158
+ const in_progress = rawChecks.filter((c) => c.status !== "completed").map((c) => c.name);
159
+ const failingChecks = rawChecks.filter(isFailing);
160
+
161
+ // Fetch job logs for failing checks in parallel
162
+ const failures: FailingCheck[] = await Promise.all(
163
+ failingChecks.map(async (c) => {
164
+ const jobId = parseJobId(c.html_url);
165
+ const log = jobId
166
+ ? await fetchJobLog(owner, repo, jobId, token, proxyFetch)
167
+ : "[could not parse job ID from URL]";
168
+ return {
169
+ name: c.name,
170
+ conclusion: c.conclusion ?? "unknown",
171
+ html_url: c.html_url,
172
+ log,
173
+ };
174
+ }),
175
+ );
176
+
177
+ return {
178
+ sha,
179
+ total: rawChecks.length,
180
+ passing: passed.length,
181
+ failing: failingChecks.length,
182
+ pending: in_progress.length,
183
+ passed,
184
+ in_progress,
185
+ failures,
186
+ };
187
+ }
@@ -0,0 +1,522 @@
1
+ import { fetchAllPages, ghFetch, type ProxyFetch } from "./fetch.js";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Types
5
+ // ---------------------------------------------------------------------------
6
+
7
+ export interface ProcessedComment {
8
+ id: number;
9
+ type: "review_comment" | "issue_comment" | "review";
10
+ user: string;
11
+ isBot: boolean;
12
+ path: string | null;
13
+ line: number | null;
14
+ diffHunk: string | null;
15
+ body: string;
16
+ state?: string;
17
+ createdAt: string;
18
+ updatedAt: string;
19
+ url: string;
20
+ replies: Reply[];
21
+ hasHumanReply: boolean;
22
+ hasAnyReply: boolean;
23
+ isResolved: boolean;
24
+ }
25
+
26
+ export interface Reply {
27
+ id: number;
28
+ user: string;
29
+ body: string;
30
+ createdAt: string;
31
+ isBot: boolean;
32
+ }
33
+
34
+ export interface FilterOptions {
35
+ botsOnly?: boolean;
36
+ humansOnly?: boolean;
37
+ filter?: "unresolved" | "unanswered" | null;
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Meta-comment filters
42
+ // ---------------------------------------------------------------------------
43
+
44
+ type MetaFilter = (user: string, body: string) => boolean;
45
+
46
+ const DEFAULT_META_FILTERS: MetaFilter[] = [
47
+ (_user, body) => body.startsWith("> Re: comment "),
48
+ (user, body) => (user === "vercel[bot]" || user === "vercel") && body.startsWith("[vc]:"),
49
+ (user, body) => (user === "supabase[bot]" || user === "supabase") && body.startsWith("[supa]:"),
50
+ (user, body) =>
51
+ (user === "cursor[bot]" || user === "cursor") &&
52
+ body.startsWith("Cursor Bugbot has reviewed your changes"),
53
+ (user, body) =>
54
+ (user === "copilot-pull-request-reviewer[bot]" || user === "copilot-pull-request-reviewer") &&
55
+ body.includes("Pull request overview"),
56
+ (user, body) =>
57
+ (user === "coderabbitai[bot]" || user === "coderabbitai") &&
58
+ body.includes("<!-- This is an auto-generated comment: summarize by coderabbit.ai -->"),
59
+ (user, body) =>
60
+ (user === "sourcery-ai[bot]" || user === "sourcery-ai") &&
61
+ body.includes("<!-- Generated by sourcery-ai[bot]:"),
62
+ (user, body) =>
63
+ (user === "codacy-production[bot]" || user === "codacy-production") &&
64
+ (body.includes("Codacy's Analysis Summary") || body.includes("Coverage summary from Codacy")),
65
+ (user, body) =>
66
+ (user === "sonarcloud[bot]" ||
67
+ user === "sonarcloud" ||
68
+ user === "sonarqubecloud[bot]" ||
69
+ user === "sonarqubecloud" ||
70
+ user === "sonarqube-cloud-us[bot]" ||
71
+ user === "sonarqube-cloud-us") &&
72
+ body.includes("Quality Gate"),
73
+ ];
74
+
75
+ function isMetaComment(user: string, body: string): boolean {
76
+ if (!body) {
77
+ return false;
78
+ }
79
+ return DEFAULT_META_FILTERS.some((filter) => filter(user, body));
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Bot detection
84
+ // ---------------------------------------------------------------------------
85
+
86
+ const KNOWN_BOT_LOGINS = new Set([
87
+ "cursor",
88
+ "vercel",
89
+ "supabase",
90
+ "chatgpt-codex-connector",
91
+ "github-actions",
92
+ "Copilot",
93
+ "copilot-pull-request-reviewer",
94
+ "coderabbitai",
95
+ "sourcery-ai",
96
+ "codacy-production",
97
+ "sonarcloud",
98
+ "sonarqubecloud",
99
+ "sonarqube-cloud-us",
100
+ ]);
101
+
102
+ function isBot(username: string | undefined): boolean {
103
+ if (!username) {
104
+ return false;
105
+ }
106
+ return username.endsWith("[bot]") || KNOWN_BOT_LOGINS.has(username) || username.includes("bot");
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Body cleanup
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function cleanBody(body: string | null | undefined): string {
114
+ if (!body) {
115
+ return "";
116
+ }
117
+ let cleaned = body;
118
+ // Remove HTML comments (single and multi-line)
119
+ cleaned = cleaned.replaceAll(/<!--[\s\S]*?-->/g, "");
120
+ // Remove <details> blocks containing "Additional Locations"
121
+ cleaned = cleaned.replaceAll(
122
+ /<details>\s*<summary>\s*Additional Locations[\s\S]*?<\/details>/gi,
123
+ "",
124
+ );
125
+ // Remove <p> blocks containing cursor.com links
126
+ cleaned = cleaned.replaceAll(/<p>\s*<a [^>]*cursor\.com[\s\S]*?<\/p>/gi, "");
127
+ // Collapse runs of 3+ newlines into 2
128
+ cleaned = cleaned.replaceAll(/\n{3,}/g, "\n\n");
129
+ return cleaned.trim();
130
+ }
131
+
132
+ // ---------------------------------------------------------------------------
133
+ // Fetch & process
134
+ // ---------------------------------------------------------------------------
135
+
136
+ interface RawGitHubUser {
137
+ login: string;
138
+ }
139
+
140
+ interface RawReviewComment {
141
+ id: number;
142
+ user?: RawGitHubUser;
143
+ body: string;
144
+ path: string;
145
+ line: number | null;
146
+ original_line: number | null;
147
+ diff_hunk: string | null;
148
+ created_at: string;
149
+ updated_at: string;
150
+ html_url: string;
151
+ in_reply_to_id?: number;
152
+ }
153
+
154
+ interface RawIssueComment {
155
+ id: number;
156
+ user?: RawGitHubUser;
157
+ body: string;
158
+ created_at: string;
159
+ updated_at: string;
160
+ html_url: string;
161
+ }
162
+
163
+ interface RawReview {
164
+ id: number;
165
+ user?: RawGitHubUser;
166
+ body: string | null;
167
+ state: string;
168
+ submitted_at: string;
169
+ html_url: string;
170
+ }
171
+
172
+ export interface RawCommentData {
173
+ reviewComments: RawReviewComment[];
174
+ issueComments: RawIssueComment[];
175
+ reviews: RawReview[];
176
+ }
177
+
178
+ export async function fetchPRComments(
179
+ owner: string,
180
+ repo: string,
181
+ prNumber: number,
182
+ token: string,
183
+ proxyFetch: ProxyFetch,
184
+ ): Promise<RawCommentData> {
185
+ const baseUrl = `https://api.github.com/repos/${owner}/${repo}`;
186
+
187
+ const [reviewComments, issueComments, reviews] = await Promise.all([
188
+ fetchAllPages<RawReviewComment>(
189
+ `${baseUrl}/pulls/${prNumber}/comments?per_page=100`,
190
+ token,
191
+ proxyFetch,
192
+ ),
193
+ fetchAllPages<RawIssueComment>(
194
+ `${baseUrl}/issues/${prNumber}/comments?per_page=100`,
195
+ token,
196
+ proxyFetch,
197
+ ),
198
+ fetchAllPages<RawReview>(
199
+ `${baseUrl}/pulls/${prNumber}/reviews?per_page=100`,
200
+ token,
201
+ proxyFetch,
202
+ ),
203
+ ]);
204
+
205
+ return { reviewComments, issueComments, reviews };
206
+ }
207
+
208
+ export function processComments(data: RawCommentData): ProcessedComment[] {
209
+ const { reviewComments, issueComments, reviews } = data;
210
+
211
+ // Build reply map
212
+ const repliesMap = new Map<number, Reply[]>();
213
+ for (const comment of reviewComments) {
214
+ if (comment.in_reply_to_id) {
215
+ if (!repliesMap.has(comment.in_reply_to_id)) {
216
+ repliesMap.set(comment.in_reply_to_id, []);
217
+ }
218
+ repliesMap.get(comment.in_reply_to_id)!.push({
219
+ id: comment.id,
220
+ user: comment.user?.login ?? "unknown",
221
+ body: cleanBody(comment.body),
222
+ createdAt: comment.created_at,
223
+ isBot: isBot(comment.user?.login),
224
+ });
225
+ }
226
+ }
227
+
228
+ const processed: ProcessedComment[] = [];
229
+
230
+ // Review comments (inline code comments)
231
+ for (const comment of reviewComments) {
232
+ if (comment.in_reply_to_id) {
233
+ continue;
234
+ }
235
+ if (isMetaComment(comment.user?.login ?? "", comment.body)) {
236
+ continue;
237
+ }
238
+
239
+ const replies = repliesMap.get(comment.id) ?? [];
240
+ const hasHumanReply = replies.some((r) => !r.isBot);
241
+ const hasAnyReply = replies.length > 0;
242
+
243
+ processed.push({
244
+ id: comment.id,
245
+ type: "review_comment",
246
+ user: comment.user?.login ?? "unknown",
247
+ isBot: isBot(comment.user?.login),
248
+ path: comment.path,
249
+ // oxlint-disable-next-line typescript/prefer-nullish-coalescing -- 0 is "no line", intentional falsy check
250
+ line: comment.line || comment.original_line,
251
+ diffHunk: comment.diff_hunk ?? null,
252
+ body: cleanBody(comment.body),
253
+ createdAt: comment.created_at,
254
+ updatedAt: comment.updated_at,
255
+ url: comment.html_url,
256
+ replies,
257
+ hasHumanReply,
258
+ hasAnyReply,
259
+ isResolved: false,
260
+ });
261
+ }
262
+
263
+ // Issue comments (general PR comments)
264
+ for (const comment of issueComments) {
265
+ if (isMetaComment(comment.user?.login ?? "", comment.body)) {
266
+ continue;
267
+ }
268
+
269
+ processed.push({
270
+ id: comment.id,
271
+ type: "issue_comment",
272
+ user: comment.user?.login ?? "unknown",
273
+ isBot: isBot(comment.user?.login),
274
+ path: null,
275
+ line: null,
276
+ diffHunk: null,
277
+ body: cleanBody(comment.body),
278
+ createdAt: comment.created_at,
279
+ updatedAt: comment.updated_at,
280
+ url: comment.html_url,
281
+ replies: [],
282
+ hasHumanReply: false,
283
+ hasAnyReply: false,
284
+ isResolved: false,
285
+ });
286
+ }
287
+
288
+ // Reviews (only human, with body)
289
+ for (const review of reviews) {
290
+ if (isBot(review.user?.login)) {
291
+ continue;
292
+ }
293
+ if (isMetaComment(review.user?.login ?? "", review.body ?? "")) {
294
+ continue;
295
+ }
296
+ if (!review.body?.trim()) {
297
+ continue;
298
+ }
299
+
300
+ processed.push({
301
+ id: review.id,
302
+ type: "review",
303
+ user: review.user?.login ?? "unknown",
304
+ isBot: false,
305
+ path: null,
306
+ line: null,
307
+ diffHunk: null,
308
+ body: cleanBody(review.body),
309
+ state: review.state,
310
+ createdAt: review.submitted_at,
311
+ updatedAt: review.submitted_at,
312
+ url: review.html_url,
313
+ replies: [],
314
+ hasHumanReply: false,
315
+ hasAnyReply: false,
316
+ isResolved: review.state === "APPROVED" || review.state === "DISMISSED",
317
+ });
318
+ }
319
+
320
+ processed.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
321
+
322
+ return processed;
323
+ }
324
+
325
+ export function filterComments(
326
+ comments: ProcessedComment[],
327
+ options: FilterOptions,
328
+ ): ProcessedComment[] {
329
+ let filtered = comments;
330
+
331
+ if (options.botsOnly) {
332
+ filtered = filtered.filter((c) => c.isBot);
333
+ } else if (options.humansOnly) {
334
+ filtered = filtered.filter((c) => !c.isBot);
335
+ }
336
+
337
+ if (options.filter === "unresolved") {
338
+ filtered = filtered.filter((c) => !(c.isResolved || c.hasHumanReply));
339
+ } else if (options.filter === "unanswered") {
340
+ filtered = filtered.filter((c) => !c.hasAnyReply);
341
+ }
342
+
343
+ return filtered;
344
+ }
345
+
346
+ // ---------------------------------------------------------------------------
347
+ // Reply & resolve
348
+ // ---------------------------------------------------------------------------
349
+
350
+ export async function replyToComment(
351
+ owner: string,
352
+ repo: string,
353
+ prNumber: number,
354
+ commentId: number,
355
+ message: string,
356
+ token: string,
357
+ proxyFetch: ProxyFetch,
358
+ ): Promise<{ html_url: string }> {
359
+ // Try review comment reply endpoint first
360
+ const response = await ghFetch(
361
+ `https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}/comments/${commentId}/replies`,
362
+ token,
363
+ proxyFetch,
364
+ {
365
+ method: "POST",
366
+ headers: { "Content-Type": "application/json" },
367
+ body: JSON.stringify({ body: message }),
368
+ },
369
+ );
370
+
371
+ if (!response.ok) {
372
+ // Fallback to issue comment endpoint
373
+ const issueResponse = await ghFetch(
374
+ `https://api.github.com/repos/${owner}/${repo}/issues/${prNumber}/comments`,
375
+ token,
376
+ proxyFetch,
377
+ {
378
+ method: "POST",
379
+ headers: { "Content-Type": "application/json" },
380
+ body: JSON.stringify({
381
+ body: `> Re: comment ${commentId}\n\n${message}`,
382
+ }),
383
+ },
384
+ );
385
+
386
+ if (!issueResponse.ok) {
387
+ const error = await issueResponse.text();
388
+ throw new Error(`Failed to reply: ${issueResponse.status} - ${error}`);
389
+ }
390
+
391
+ return issueResponse.json() as Promise<{ html_url: string }>;
392
+ }
393
+
394
+ return response.json() as Promise<{ html_url: string }>;
395
+ }
396
+
397
+ export async function resolveThread(
398
+ owner: string,
399
+ repo: string,
400
+ prNumber: number,
401
+ commentId: number,
402
+ token: string,
403
+ proxyFetch: ProxyFetch,
404
+ ): Promise<
405
+ | { resolved: true; threadId: string }
406
+ | { alreadyResolved: true; threadId: string }
407
+ | { skipped: true; reason: string }
408
+ > {
409
+ const query = `
410
+ query($owner: String!, $repo: String!, $pr: Int!, $cursor: String) {
411
+ repository(owner: $owner, name: $repo) {
412
+ pullRequest(number: $pr) {
413
+ reviewThreads(first: 100, after: $cursor) {
414
+ pageInfo { hasNextPage endCursor }
415
+ nodes {
416
+ id
417
+ isResolved
418
+ comments(first: 1) {
419
+ nodes { databaseId }
420
+ }
421
+ }
422
+ }
423
+ }
424
+ }
425
+ }
426
+ `;
427
+
428
+ interface ReviewThreadNode {
429
+ id: string;
430
+ isResolved: boolean;
431
+ comments: { nodes: { databaseId: number }[] };
432
+ }
433
+
434
+ let cursor: string | null = null;
435
+ let thread: ReviewThreadNode | null = null;
436
+
437
+ while (!thread) {
438
+ const response = await ghFetch("https://api.github.com/graphql", token, proxyFetch, {
439
+ method: "POST",
440
+ headers: { "Content-Type": "application/json" },
441
+ body: JSON.stringify({
442
+ query,
443
+ variables: { owner, repo, pr: prNumber, cursor },
444
+ }),
445
+ });
446
+
447
+ if (!response.ok) {
448
+ throw new Error(`GraphQL query failed: ${response.status}`);
449
+ }
450
+
451
+ const data = (await response.json()) as {
452
+ errors?: { message: string }[];
453
+ data?: {
454
+ repository?: {
455
+ pullRequest?: {
456
+ reviewThreads?: {
457
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
458
+ nodes: ReviewThreadNode[];
459
+ };
460
+ };
461
+ };
462
+ };
463
+ };
464
+ if (data.errors?.[0]) {
465
+ throw new Error(`GraphQL error: ${data.errors[0].message}`);
466
+ }
467
+
468
+ const reviewThreads = data.data?.repository?.pullRequest?.reviewThreads;
469
+ if (!reviewThreads) {
470
+ break;
471
+ }
472
+
473
+ thread =
474
+ reviewThreads.nodes.find((t: ReviewThreadNode) =>
475
+ t.comments.nodes.some((c: { databaseId: number }) => c.databaseId === commentId),
476
+ ) ?? null;
477
+
478
+ if (!thread && reviewThreads.pageInfo.hasNextPage) {
479
+ cursor = reviewThreads.pageInfo.endCursor;
480
+ } else {
481
+ break;
482
+ }
483
+ }
484
+
485
+ if (!thread) {
486
+ return { skipped: true, reason: "not a review comment thread" };
487
+ }
488
+
489
+ if (thread.isResolved) {
490
+ return { alreadyResolved: true, threadId: thread.id };
491
+ }
492
+
493
+ const mutation = `
494
+ mutation($threadId: ID!) {
495
+ resolveReviewThread(input: { threadId: $threadId }) {
496
+ thread { id isResolved }
497
+ }
498
+ }
499
+ `;
500
+
501
+ const resolveResponse = await ghFetch("https://api.github.com/graphql", token, proxyFetch, {
502
+ method: "POST",
503
+ headers: { "Content-Type": "application/json" },
504
+ body: JSON.stringify({
505
+ query: mutation,
506
+ variables: { threadId: thread.id },
507
+ }),
508
+ });
509
+
510
+ if (!resolveResponse.ok) {
511
+ throw new Error(`Failed to resolve thread: ${resolveResponse.status}`);
512
+ }
513
+
514
+ const resolveData = (await resolveResponse.json()) as {
515
+ errors?: { message: string }[];
516
+ };
517
+ if (resolveData.errors?.[0]) {
518
+ throw new Error(`GraphQL error: ${resolveData.errors[0].message}`);
519
+ }
520
+
521
+ return { resolved: true, threadId: thread.id };
522
+ }