main
1/**
2 * Pull Request action handlers for GitHub extension
3 */
4
5import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
6import type { GhDetails, GhLineComment } from "../types";
7import {
8 parsePRList,
9 parsePRItem,
10 parseReviewComments,
11 parseReviewSummaries,
12 getErrorMessage,
13 extractPRNumber,
14 extractPRUrl,
15 buildPRCreateConfirmation,
16 buildPRMergeConfirmation,
17 buildReviewConfirmation,
18 buildCommentConfirmation,
19 buildLineCommentConfirmation,
20 buildReviewWithCommentsConfirmation,
21 buildReviewEditConfirmation,
22 buildReviewCommentEditConfirmation,
23 buildReviewCommentDeleteConfirmation,
24 truncate,
25 getReviewDecisionText,
26 formatRelativeDate,
27 execGh,
28 resolveGitCwd,
29 findPRTemplate,
30 approvalGate,
31 approvalGateWithBodyPreview,
32 buildModifyResult,
33 buildRejectResult,
34 normalizePRNumbers,
35} from "../utils";
36
37const PR_LIST_FIELDS = "number,title,state,author,headRefName,baseRefName,url,isDraft,labels,reviewDecision,additions,deletions,changedFiles,createdAt,updatedAt";
38
39function formatBatchResult(
40 action: string,
41 succeeded: number[],
42 failed: Array<{ number: number; error: string }>,
43): string {
44 const lines = [`Batch ${action}: ${succeeded.length} succeeded, ${failed.length} failed`];
45 for (const number of succeeded) lines.push(`✓ #${number}`);
46 for (const item of failed) lines.push(`✗ #${item.number}: ${item.error}`);
47 return lines.join("\n");
48}
49
50/**
51 * List pull requests
52 */
53export async function handlePRList(
54 pi: ExtensionAPI,
55 params: any,
56 signal: AbortSignal | undefined,
57 onUpdate: any,
58 ctx: ExtensionContext,
59 currentUser: string,
60): Promise<any> {
61 const args = ["pr", "list", "--json", PR_LIST_FIELDS];
62
63 if (params.state) args.push("--state", params.state);
64 if (params.author) {
65 args.push("--author", params.author === "me" ? (currentUser || "@me") : params.author);
66 }
67 if (params.label) args.push("--label", params.label);
68 if (params.base) args.push("--base", params.base);
69 if (params.limit) args.push("--limit", String(params.limit));
70 else args.push("--limit", "20");
71
72 onUpdate?.({ content: [{ type: "text", text: "Fetching PRs..." }] });
73
74 const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
75
76 if (result.code !== 0) {
77 return {
78 content: [{ type: "text", text: getErrorMessage(result.stderr, "List PRs") }],
79 details: { action: "pr-list", error: result.stderr } as GhDetails,
80 isError: true,
81 };
82 }
83
84 const prs = parsePRList(result.stdout);
85
86 let output = "";
87 if (prs.length === 0) {
88 output = "No pull requests found.";
89 } else {
90 output = prs
91 .map((pr) => {
92 const draft = pr.isDraft ? " [draft]" : "";
93 const review = pr.reviewDecision ? ` (${getReviewDecisionText(pr.reviewDecision)})` : "";
94 const changes = `+${pr.additions}/-${pr.deletions}`;
95 return `#${pr.number} ${pr.title}${draft}${review} (${pr.branch} → ${pr.base}) ${changes} @${pr.author}`;
96 })
97 .join("\n");
98 }
99
100 const prNumbers = prs.map((p) => p.number);
101
102 return {
103 content: [{ type: "text", text: output }],
104 details: { action: "pr-list", output, prNumbers } as GhDetails,
105 };
106}
107
108/**
109 * View pull request details
110 */
111export async function handlePRView(
112 pi: ExtensionAPI,
113 params: any,
114 signal: AbortSignal | undefined,
115 onUpdate: any,
116 ctx: ExtensionContext,
117): Promise<any> {
118 if (!params.number) {
119 return {
120 content: [{ type: "text", text: "Error: 'number' parameter is required for pr-view action" }],
121 details: { action: "pr-view", error: "missing_number" } as GhDetails,
122 isError: true,
123 };
124 }
125
126 onUpdate?.({ content: [{ type: "text", text: `Fetching PR #${params.number}...` }] });
127
128 const fields = `${PR_LIST_FIELDS},body,mergeStateStatus,statusCheckRollup,reviews,comments`;
129 const result = await execGh(pi, ctx, ["pr", "view", String(params.number), "--json", fields], {
130 signal,
131 timeout: 30000,
132 });
133
134 if (result.code !== 0) {
135 return {
136 content: [{ type: "text", text: getErrorMessage(result.stderr, "View PR") }],
137 details: { action: "pr-view", error: result.stderr, prNumber: params.number } as GhDetails,
138 isError: true,
139 };
140 }
141
142 let data: any;
143 try {
144 data = JSON.parse(result.stdout);
145 } catch {
146 return {
147 content: [{ type: "text", text: result.stdout }],
148 details: { action: "pr-view", output: result.stdout, prNumber: params.number } as GhDetails,
149 };
150 }
151
152 const pr = parsePRItem(data);
153
154 // Build readable output
155 let output = "";
156 output += `# PR #${pr.number}: ${pr.title}\n\n`;
157 output += `State: ${pr.state}${pr.isDraft ? " (draft)" : ""}\n`;
158 output += `Author: @${pr.author}\n`;
159 output += `Branch: ${pr.branch} → ${pr.base}\n`;
160 output += `Review: ${getReviewDecisionText(pr.reviewDecision)}\n`;
161 output += `Changes: ${pr.changedFiles} files (+${pr.additions}/-${pr.deletions})\n`;
162 output += `URL: ${pr.url}\n`;
163
164 if (pr.labels.length > 0) {
165 output += `Labels: ${pr.labels.join(", ")}\n`;
166 }
167
168 if (data.body) {
169 output += `\n## Description\n\n${data.body}\n`;
170 }
171
172 // Checks summary
173 const checks = data.statusCheckRollup ?? [];
174 if (checks.length > 0) {
175 const passed = checks.filter((c: any) => c.conclusion === "SUCCESS").length;
176 const failed = checks.filter((c: any) => c.conclusion === "FAILURE").length;
177 const pending = checks.filter((c: any) => !c.conclusion || c.status === "IN_PROGRESS" || c.status === "QUEUED").length;
178 output += `\n## Checks: ${passed} passed, ${failed} failed, ${pending} pending\n\n`;
179 for (const check of checks) {
180 const icon = check.conclusion === "SUCCESS" ? "✓" : check.conclusion === "FAILURE" ? "✗" : "⏳";
181 output += `${icon} ${check.name ?? check.context ?? "?"} (${check.conclusion || check.status || "pending"})\n`;
182 }
183 }
184
185 // Reviews summary
186 const reviews = data.reviews ?? [];
187 if (reviews.length > 0) {
188 output += `\n## Reviews\n\n`;
189 for (const review of reviews) {
190 output += `@${review.author?.login ?? "?"}: ${review.state}`;
191 if (review.body) output += ` - ${truncate(review.body, 100)}`;
192 output += "\n";
193 }
194 }
195
196 return {
197 content: [{ type: "text", text: output }],
198 details: { action: "pr-view", output, prNumber: pr.number, prUrl: pr.url } as GhDetails,
199 };
200}
201
202/**
203 * View PR diff
204 */
205export async function handlePRDiff(
206 pi: ExtensionAPI,
207 params: any,
208 signal: AbortSignal | undefined,
209 onUpdate: any,
210 ctx: ExtensionContext,
211): Promise<any> {
212 if (!params.number) {
213 return {
214 content: [{ type: "text", text: "Error: 'number' parameter is required for pr-diff action" }],
215 details: { action: "pr-diff", error: "missing_number" } as GhDetails,
216 isError: true,
217 };
218 }
219
220 onUpdate?.({ content: [{ type: "text", text: `Fetching diff for PR #${params.number}...` }] });
221
222 const result = await execGh(pi, ctx, ["pr", "diff", String(params.number)], { signal, timeout: 30000 });
223
224 if (result.code !== 0) {
225 return {
226 content: [{ type: "text", text: getErrorMessage(result.stderr, "PR diff") }],
227 details: { action: "pr-diff", error: result.stderr, prNumber: params.number } as GhDetails,
228 isError: true,
229 };
230 }
231
232 const diff = result.stdout;
233 // Truncate if very large
234 const maxLen = 50000;
235 const output = diff.length > maxLen ? diff.slice(0, maxLen) + "\n\n[... diff truncated, use `gh pr diff` for full output]" : diff;
236
237 return {
238 content: [{ type: "text", text: output }],
239 details: { action: "pr-diff", output: `Diff for PR #${params.number} (${diff.length} chars)`, prNumber: params.number } as GhDetails,
240 };
241}
242
243/**
244 * Create pull request (requires approval)
245 */
246export async function handlePRCreate(
247 pi: ExtensionAPI,
248 params: any,
249 signal: AbortSignal | undefined,
250 onUpdate: any,
251 ctx: ExtensionContext,
252): Promise<any> {
253 if (!params.title) {
254 return {
255 content: [{ type: "text", text: "Error: 'title' parameter is required for pr-create action" }],
256 details: { action: "pr-create", error: "missing_title" } as GhDetails,
257 isError: true,
258 };
259 }
260
261 // Find PR template
262 const template = await findPRTemplate(pi, ctx);
263
264 // APPROVAL GATE
265 if (ctx.hasUI) {
266 let confirmMessage = buildPRCreateConfirmation(params);
267 if (template) {
268 confirmMessage += `\n\nTemplate: ${template}`;
269 }
270
271 // If body is long, offer to preview full content
272 if (params.body && params.body.length > 200) {
273 confirmMessage += `\n\n📝 Body: ${params.body.length} characters (truncated in preview)`;
274
275 const choice = await ctx.ui.select(
276 `Create Pull Request?\n\n${confirmMessage}`,
277 ["✓ Accept", "👁 Preview body first", "✎ Modify", "✗ Reject"]
278 );
279
280 if (choice === undefined || choice === "✗ Reject") {
281 ctx.ui.notify("PR creation rejected", "info");
282 return buildRejectResult("PR creation", { action: "pr-create" });
283 }
284
285 if (choice === "✎ Modify") {
286 ctx.ui.notify("PR creation paused for modifications", "info");
287 return buildModifyResult("PR creation", { action: "pr-create" });
288 }
289
290 if (choice === "👁 Preview body first") {
291 await ctx.ui.editor(
292 `PR Body Preview (${params.body.length} chars):\n\nTitle: ${params.title}\n\n---\n\n`,
293 params.body
294 );
295
296 // Ask again after preview
297 const approval = await approvalGate(ctx, "Create Pull Request?", confirmMessage);
298 if (approval.outcome === "modify") {
299 ctx.ui.notify("PR creation paused for modifications", "info");
300 return buildModifyResult("PR creation", { action: "pr-create" });
301 }
302 if (approval.outcome === "rejected") {
303 ctx.ui.notify("PR creation rejected", "info");
304 return buildRejectResult("PR creation", { action: "pr-create" });
305 }
306 }
307 } else {
308 const approval = await approvalGate(ctx, "Create Pull Request?", confirmMessage);
309 if (approval.outcome === "modify") {
310 ctx.ui.notify("PR creation paused for modifications", "info");
311 return buildModifyResult("PR creation", { action: "pr-create" });
312 }
313 if (approval.outcome === "rejected") {
314 ctx.ui.notify("PR creation rejected", "info");
315 return buildRejectResult("PR creation", { action: "pr-create" });
316 }
317 }
318 }
319
320 const args = ["pr", "create", "--title", params.title];
321
322 // Use explicit --head when provided (e.g. "vdemeester:feature-branch"),
323 // otherwise auto-detect from the cwd's git context.
324 if (params.head) {
325 args.push("--head", params.head);
326 } else {
327 try {
328 const gitCwd = await resolveGitCwd(pi, ctx);
329 const branchResult = await pi.exec(
330 "git", ["rev-parse", "--abbrev-ref", "HEAD"],
331 { cwd: gitCwd, timeout: 5000 },
332 );
333 if (branchResult.code === 0) {
334 const branch = branchResult.stdout.trim();
335 const defaultBranches = ["main", "master"];
336 if (branch && !defaultBranches.includes(branch)) {
337 // Detect fork owner from the "origin" remote URL.
338 // `gh repo view` returns the *upstream* repo owner (the
339 // GH default repo), not the fork, so we parse origin
340 // instead — that's where the branch was pushed.
341 const ownerResult = await pi.exec(
342 "git", ["remote", "get-url", "origin"],
343 { cwd: gitCwd, timeout: 5000 },
344 );
345 if (ownerResult.code === 0 && ownerResult.stdout.trim()) {
346 const originUrl = ownerResult.stdout.trim();
347 // Extract owner from SSH (git@github.com:owner/repo) or HTTPS (github.com/owner/repo)
348 const match = originUrl.match(/[:/]([^/]+)\/[^/]+(?:\.git)?$/);
349 const forkOwner = match?.[1];
350 if (forkOwner) {
351 args.push("--head", `${forkOwner}:${branch}`);
352 } else {
353 args.push("--head", branch);
354 }
355 } else {
356 args.push("--head", branch);
357 }
358 }
359 }
360 } catch {
361 // Ignore detection errors — gh will use defaults
362 }
363 }
364
365 // Add template if found and body not explicitly provided
366 if (template && !params.body) {
367 args.push("--template", template);
368 }
369
370 if (params.body) args.push("--body", params.body);
371 if (params.base) args.push("--base", params.base);
372 if (params.draft) args.push("--draft");
373 if (params.labels?.length) {
374 for (const label of params.labels) args.push("--label", label);
375 }
376 if (params.reviewers?.length) {
377 for (const reviewer of params.reviewers) args.push("--reviewer", reviewer);
378 }
379
380 onUpdate?.({ content: [{ type: "text", text: "Creating PR..." }] });
381
382 const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
383
384 if (result.code !== 0) {
385 let errorMsg = getErrorMessage(result.stderr, "Create PR");
386
387 // Add helpful hints for common issues
388 if (result.stderr.includes("No commits between")) {
389 errorMsg += "\n\n💡 Tip: Make sure you have committed and pushed your changes to the branch.";
390 errorMsg += "\n If working in a worktree, ensure you're on the correct branch.";
391 }
392 if (result.stderr.includes("uncommitted changes")) {
393 errorMsg += "\n\n💡 Tip: Commit or stash your changes before creating a PR.";
394 }
395 if (result.stderr.includes("head repository")) {
396 errorMsg += "\n\n💡 Tip: Make sure you've pushed your branch to your fork.";
397 }
398
399 return {
400 content: [{ type: "text", text: errorMsg }],
401 details: { action: "pr-create", error: result.stderr } as GhDetails,
402 isError: true,
403 };
404 }
405
406 const prNumber = extractPRNumber(result.stdout);
407 const prUrl = extractPRUrl(result.stdout) || result.stdout.trim();
408
409 return {
410 content: [{ type: "text", text: `Created PR${prNumber ? ` #${prNumber}` : ""}: ${prUrl}` }],
411 details: { action: "pr-create", output: result.stdout.trim(), prNumber: prNumber ?? undefined, prUrl: prUrl ?? undefined } as GhDetails,
412 };
413}
414
415/**
416 * Checkout PR locally
417 */
418export async function handlePRCheckout(
419 pi: ExtensionAPI,
420 params: any,
421 signal: AbortSignal | undefined,
422 onUpdate: any,
423 ctx: ExtensionContext,
424): Promise<any> {
425 if (!params.number) {
426 return {
427 content: [{ type: "text", text: "Error: 'number' parameter is required for pr-checkout action" }],
428 details: { action: "pr-checkout", error: "missing_number" } as GhDetails,
429 isError: true,
430 };
431 }
432
433 onUpdate?.({ content: [{ type: "text", text: `Checking out PR #${params.number}...` }] });
434
435 const result = await execGh(pi, ctx, ["pr", "checkout", String(params.number)], { signal, timeout: 30000 });
436
437 if (result.code !== 0) {
438 return {
439 content: [{ type: "text", text: getErrorMessage(result.stderr, "Checkout PR") }],
440 details: { action: "pr-checkout", error: result.stderr, prNumber: params.number } as GhDetails,
441 isError: true,
442 };
443 }
444
445 return {
446 content: [{ type: "text", text: `Checked out PR #${params.number}\n${result.stdout.trim()}` }],
447 details: { action: "pr-checkout", output: result.stdout.trim(), prNumber: params.number } as GhDetails,
448 };
449}
450
451/**
452 * Merge pull request (requires approval)
453 */
454export async function handlePRMerge(
455 pi: ExtensionAPI,
456 params: any,
457 signal: AbortSignal | undefined,
458 onUpdate: any,
459 ctx: ExtensionContext,
460): Promise<any> {
461 if (!params.number) {
462 return {
463 content: [{ type: "text", text: "Error: 'number' parameter is required for pr-merge action" }],
464 details: { action: "pr-merge", error: "missing_number" } as GhDetails,
465 isError: true,
466 };
467 }
468
469 // APPROVAL GATE
470 if (ctx.hasUI) {
471 const confirmMessage = buildPRMergeConfirmation(params);
472 const approval = await approvalGate(ctx, `Merge PR #${params.number}?`, confirmMessage);
473 if (approval.outcome === "modify") {
474 ctx.ui.notify("Merge paused for modifications", "info");
475 return buildModifyResult("merge", { action: "pr-merge", prNumber: params.number });
476 }
477 if (approval.outcome === "rejected") {
478 ctx.ui.notify("Merge rejected", "info");
479 return buildRejectResult("merge", { action: "pr-merge", prNumber: params.number });
480 }
481 }
482
483 const args = ["pr", "merge", String(params.number)];
484
485 const method = params.method || "rebase";
486 if (method === "squash") args.push("--squash");
487 else if (method === "rebase") args.push("--rebase");
488 else args.push("--merge");
489
490 if (params.deleteBranch) args.push("--delete-branch");
491
492 onUpdate?.({ content: [{ type: "text", text: `Merging PR #${params.number}...` }] });
493
494 const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
495
496 if (result.code !== 0) {
497 return {
498 content: [{ type: "text", text: getErrorMessage(result.stderr, "Merge PR") }],
499 details: { action: "pr-merge", error: result.stderr, prNumber: params.number } as GhDetails,
500 isError: true,
501 };
502 }
503
504 return {
505 content: [{ type: "text", text: `Merged PR #${params.number} (${method})\n${result.stdout.trim()}` }],
506 details: { action: "pr-merge", output: result.stdout.trim(), prNumber: params.number, mergeMethod: method } as GhDetails,
507 };
508}
509
510/**
511 * Submit PR review (requires approval)
512 */
513export async function handlePRReview(
514 pi: ExtensionAPI,
515 params: any,
516 signal: AbortSignal | undefined,
517 onUpdate: any,
518 ctx: ExtensionContext,
519): Promise<any> {
520 const numbers = normalizePRNumbers(params);
521 if (numbers.length === 0) {
522 return {
523 content: [{ type: "text", text: "Error: 'numbers' must contain at least one positive PR number" }],
524 details: { action: "pr-review", error: "invalid_numbers" } as GhDetails,
525 isError: true,
526 };
527 }
528
529 if (!params.reviewAction) {
530 return {
531 content: [{ type: "text", text: "Error: 'reviewAction' parameter is required (approve, request-changes, comment)" }],
532 details: { action: "pr-review", error: "missing_review_action" } as GhDetails,
533 isError: true,
534 };
535 }
536
537 // APPROVAL GATE
538 if (ctx.hasUI) {
539 const targets = await buildPRTargetSummary(pi, ctx, numbers, signal);
540 const confirmMessage = `${targets}\n\n${buildReviewConfirmation({ ...params, numbers })}`;
541 const approval = params.body
542 ? await approvalGateWithBodyPreview(
543 ctx,
544 `Submit review on ${numbers.length} PR(s)?`,
545 confirmMessage,
546 `Shared review body for ${numbers.length} PR(s) (${params.body.length} chars):`,
547 params.body,
548 )
549 : await approvalGate(ctx, `Submit review on ${numbers.length} PR(s)?`, confirmMessage);
550 if (approval.outcome === "modify") {
551 ctx.ui.notify("Review paused for modifications", "info");
552 return buildModifyResult("review", { action: "pr-review", prNumbers: numbers });
553 }
554 if (approval.outcome === "rejected") {
555 ctx.ui.notify("Review rejected", "info");
556 return buildRejectResult("review", { action: "pr-review", prNumbers: numbers });
557 }
558 }
559
560 const succeeded: number[] = [];
561 const failed: Array<{ number: number; error: string }> = [];
562 for (const number of numbers) {
563 const args = ["pr", "review", String(number)];
564 if (params.reviewAction === "approve") args.push("--approve");
565 else if (params.reviewAction === "request-changes") args.push("--request-changes");
566 else args.push("--comment");
567 if (params.body) args.push("--body", params.body);
568
569 onUpdate?.({ content: [{ type: "text", text: `Submitting review on PR #${number}...` }] });
570 const result = await execGh(pi, ctx, args, { signal, timeout: 30000 });
571 if (result.code === 0) succeeded.push(number);
572 else failed.push({ number, error: getErrorMessage(result.stderr, "Submit review") });
573 }
574
575 const output = formatBatchResult(`${params.reviewAction} review`, succeeded, failed);
576 return {
577 content: [{ type: "text", text: output }],
578 details: { action: "pr-review", output, prNumbers: numbers, reviewAction: params.reviewAction, succeeded, failed } as GhDetails,
579 };
580}
581
582/**
583 * Comment on a PR (requires approval)
584 */
585export async function handlePRComment(
586 pi: ExtensionAPI,
587 params: any,
588 signal: AbortSignal | undefined,
589 onUpdate: any,
590 ctx: ExtensionContext,
591): Promise<any> {
592 const numbers = normalizePRNumbers(params);
593 if (numbers.length === 0) {
594 return {
595 content: [{ type: "text", text: "Error: 'numbers' must contain at least one positive PR number" }],
596 details: { action: "pr-comment", error: "invalid_numbers" } as GhDetails,
597 isError: true,
598 };
599 }
600
601 if (!params.body) {
602 return {
603 content: [{ type: "text", text: "Error: 'body' parameter is required for pr-comment action" }],
604 details: { action: "pr-comment", error: "missing_body" } as GhDetails,
605 isError: true,
606 };
607 }
608
609 // APPROVAL GATE
610 if (ctx.hasUI) {
611 const targets = await buildPRTargetSummary(pi, ctx, numbers, signal);
612 const confirmMessage = `${targets}\n\n${buildCommentConfirmation("PR", numbers, params.body)}`;
613 const approval = await approvalGateWithBodyPreview(
614 ctx,
615 `Comment on ${numbers.length} PR(s)?`,
616 confirmMessage,
617 `Shared comment for ${numbers.length} PR(s) (${params.body.length} chars):`,
618 params.body,
619 );
620 if (approval.outcome === "modify") {
621 ctx.ui.notify("Comment paused for modifications", "info");
622 return buildModifyResult("comment", { action: "pr-comment", prNumbers: numbers });
623 }
624 if (approval.outcome === "rejected") {
625 ctx.ui.notify("Comment rejected", "info");
626 return buildRejectResult("comment", { action: "pr-comment", prNumbers: numbers });
627 }
628 }
629
630 const succeeded: number[] = [];
631 const failed: Array<{ number: number; error: string }> = [];
632 for (const number of numbers) {
633 onUpdate?.({ content: [{ type: "text", text: `Adding comment to PR #${number}...` }] });
634 const result = await execGh(pi, ctx, ["pr", "comment", String(number), "--body", params.body], {
635 signal,
636 timeout: 20000,
637 });
638 if (result.code === 0) succeeded.push(number);
639 else failed.push({ number, error: getErrorMessage(result.stderr, "Comment on PR") });
640 }
641
642 const output = formatBatchResult("comment", succeeded, failed);
643 return {
644 content: [{ type: "text", text: output }],
645 details: { action: "pr-comment", output, prNumbers: numbers, succeeded, failed } as GhDetails,
646 };
647}
648
649/**
650 * Mark draft PR as ready for review (requires approval)
651 */
652export async function handlePRReady(
653 pi: ExtensionAPI,
654 params: any,
655 signal: AbortSignal | undefined,
656 onUpdate: any,
657 ctx: ExtensionContext,
658): Promise<any> {
659 if (!params.number) {
660 return {
661 content: [{ type: "text", text: "Error: 'number' parameter is required for pr-ready action" }],
662 details: { action: "pr-ready", error: "missing_number" } as GhDetails,
663 isError: true,
664 };
665 }
666
667 // APPROVAL GATE
668 if (ctx.hasUI) {
669 const title = await getPRTitle(pi, ctx, params.number, signal);
670 const description = title
671 ? `"${truncate(title, 80)}"\n\nThis will mark the draft PR as ready for review.`
672 : "This will mark the draft PR as ready for review.";
673 const approval = await approvalGate(ctx, `Mark PR #${params.number} as ready?`, description);
674 if (approval.outcome === "modify") {
675 ctx.ui.notify("Paused for modifications", "info");
676 return buildModifyResult("mark-ready", { action: "pr-ready", prNumber: params.number });
677 }
678 if (approval.outcome === "rejected") {
679 ctx.ui.notify("Rejected", "info");
680 return buildRejectResult("mark-ready", { action: "pr-ready", prNumber: params.number });
681 }
682 }
683
684 onUpdate?.({ content: [{ type: "text", text: `Marking PR #${params.number} as ready...` }] });
685
686 const result = await execGh(pi, ctx, ["pr", "ready", String(params.number)], { signal, timeout: 20000 });
687
688 if (result.code !== 0) {
689 return {
690 content: [{ type: "text", text: getErrorMessage(result.stderr, "Mark PR ready") }],
691 details: { action: "pr-ready", error: result.stderr, prNumber: params.number } as GhDetails,
692 isError: true,
693 };
694 }
695
696 return {
697 content: [{ type: "text", text: `PR #${params.number} marked as ready for review` }],
698 details: { action: "pr-ready", output: result.stdout.trim(), prNumber: params.number } as GhDetails,
699 };
700}
701
702/**
703 * Close a PR (requires approval)
704 */
705export async function handlePRClose(
706 pi: ExtensionAPI,
707 params: any,
708 signal: AbortSignal | undefined,
709 onUpdate: any,
710 ctx: ExtensionContext,
711): Promise<any> {
712 if (!params.number) {
713 return {
714 content: [{ type: "text", text: "Error: 'number' parameter is required for pr-close action" }],
715 details: { action: "pr-close", error: "missing_number" } as GhDetails,
716 isError: true,
717 };
718 }
719
720 // APPROVAL GATE
721 if (ctx.hasUI) {
722 const title = await getPRTitle(pi, ctx, params.number, signal);
723 const description = title
724 ? `"${truncate(title, 80)}"\n\nThis will close the pull request without merging.`
725 : "This will close the pull request without merging.";
726 const approval = await approvalGate(ctx, `Close PR #${params.number}?`, description);
727 if (approval.outcome === "modify") {
728 ctx.ui.notify("Paused for modifications", "info");
729 return buildModifyResult("close PR", { action: "pr-close", prNumber: params.number });
730 }
731 if (approval.outcome === "rejected") {
732 ctx.ui.notify("Rejected", "info");
733 return buildRejectResult("close PR", { action: "pr-close", prNumber: params.number });
734 }
735 }
736
737 const result = await execGh(pi, ctx, ["pr", "close", String(params.number)], { signal, timeout: 20000 });
738
739 if (result.code !== 0) {
740 return {
741 content: [{ type: "text", text: getErrorMessage(result.stderr, "Close PR") }],
742 details: { action: "pr-close", error: result.stderr, prNumber: params.number } as GhDetails,
743 isError: true,
744 };
745 }
746
747 return {
748 content: [{ type: "text", text: `Closed PR #${params.number}` }],
749 details: { action: "pr-close", output: result.stdout.trim(), prNumber: params.number } as GhDetails,
750 };
751}
752
753/** Build the target list shown once before a batch PR write. */
754async function buildPRTargetSummary(
755 pi: ExtensionAPI,
756 ctx: ExtensionContext,
757 numbers: number[],
758 signal?: AbortSignal,
759): Promise<string> {
760 const titles = await Promise.all(numbers.map((number) => getPRTitle(pi, ctx, number, signal)));
761 return numbers.map((number, index) => `#${number}${titles[index] ? ` ${truncate(titles[index]!, 80)}` : ""}`).join("\n");
762}
763
764/**
765 * Helper: get the title for a PR (used in confirmation dialogs)
766 */
767async function getPRTitle(
768 pi: ExtensionAPI,
769 ctx: ExtensionContext,
770 prNumber: number,
771 signal?: AbortSignal,
772): Promise<string | null> {
773 const result = await execGh(pi, ctx,
774 ["pr", "view", String(prNumber), "--json", "title", "--jq", ".title"],
775 { signal, timeout: 15000 },
776 );
777 if (result.code !== 0 || !result.stdout.trim()) return null;
778 return result.stdout.trim();
779}
780
781/**
782 * Helper: get the HEAD commit SHA for a PR (needed for line comments API)
783 */
784async function getPRHeadSha(
785 pi: ExtensionAPI,
786 ctx: ExtensionContext,
787 prNumber: number,
788 signal?: AbortSignal,
789): Promise<string | null> {
790 const result = await execGh(pi, ctx,
791 ["pr", "view", String(prNumber), "--json", "headRefOid", "--jq", ".headRefOid"],
792 { signal, timeout: 15000 },
793 );
794 if (result.code !== 0 || !result.stdout.trim()) return null;
795 return result.stdout.trim();
796}
797
798/**
799 * Post an inline comment on a PR diff (requires approval)
800 *
801 * Uses: POST /repos/{owner}/{repo}/pulls/{pull_number}/comments
802 */
803export async function handlePRLineComment(
804 pi: ExtensionAPI,
805 params: any,
806 signal: AbortSignal | undefined,
807 onUpdate: any,
808 ctx: ExtensionContext,
809): Promise<any> {
810 if (!params.number) {
811 return {
812 content: [{ type: "text", text: "Error: 'number' parameter is required for pr-line-comment action" }],
813 details: { action: "pr-line-comment", error: "missing_number" } as GhDetails,
814 isError: true,
815 };
816 }
817 if (!params.path) {
818 return {
819 content: [{ type: "text", text: "Error: 'path' parameter is required (file path in the diff)" }],
820 details: { action: "pr-line-comment", error: "missing_path" } as GhDetails,
821 isError: true,
822 };
823 }
824 if (!params.line) {
825 return {
826 content: [{ type: "text", text: "Error: 'line' parameter is required (line number in the diff)" }],
827 details: { action: "pr-line-comment", error: "missing_line" } as GhDetails,
828 isError: true,
829 };
830 }
831 if (!params.body) {
832 return {
833 content: [{ type: "text", text: "Error: 'body' parameter is required (comment text)" }],
834 details: { action: "pr-line-comment", error: "missing_body" } as GhDetails,
835 isError: true,
836 };
837 }
838
839 // APPROVAL GATE
840 if (ctx.hasUI) {
841 const confirmMessage = buildLineCommentConfirmation(params.number, params.path, params.line, params.body, params.startLine);
842 const range = params.startLine ? `${params.path}:${params.startLine}-${params.line}` : `${params.path}:${params.line}`;
843 const approval = await approvalGateWithBodyPreview(
844 ctx,
845 `Add inline comment on PR #${params.number}?`,
846 confirmMessage,
847 `PR #${params.number} Inline Comment Preview at ${range} (${params.body.length} chars):`,
848 params.body,
849 );
850 if (approval.outcome === "modify") {
851 ctx.ui.notify("Inline comment paused for modifications", "info");
852 return buildModifyResult("inline comment", { action: "pr-line-comment", prNumber: params.number });
853 }
854 if (approval.outcome === "rejected") {
855 ctx.ui.notify("Inline comment rejected", "info");
856 return buildRejectResult("inline comment", { action: "pr-line-comment", prNumber: params.number });
857 }
858 }
859
860 onUpdate?.({ content: [{ type: "text", text: `Fetching PR #${params.number} HEAD SHA...` }] });
861
862 const commitId = await getPRHeadSha(pi, ctx, params.number, signal);
863 if (!commitId) {
864 return {
865 content: [{ type: "text", text: `Error: Could not get HEAD commit SHA for PR #${params.number}` }],
866 details: { action: "pr-line-comment", error: "no_head_sha", prNumber: params.number } as GhDetails,
867 isError: true,
868 };
869 }
870
871 // Build API payload
872 const payload: Record<string, any> = {
873 body: params.body,
874 commit_id: commitId,
875 path: params.path,
876 line: params.line,
877 side: params.side || "RIGHT",
878 };
879
880 if (params.startLine) {
881 payload.start_line = params.startLine;
882 payload.start_side = params.startSide || params.side || "RIGHT";
883 }
884
885 onUpdate?.({ content: [{ type: "text", text: `Posting inline comment on ${params.path}:${params.line}...` }] });
886
887 // Write payload to temp file (pi.exec doesn't support stdin)
888 const tmpFile = `/tmp/gh-line-comment-${Date.now()}.json`;
889 await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${JSON.stringify(payload)}\nGHEOF`], { signal });
890
891 const result = await execGh(pi, ctx,
892 [
893 "api",
894 "repos/{owner}/{repo}/pulls/" + params.number + "/comments",
895 "--method", "POST",
896 "--input", tmpFile,
897 ],
898 { signal, timeout: 20000 },
899 );
900
901 // Clean up
902 await pi.exec("rm", ["-f", tmpFile], { signal });
903
904 if (result.code !== 0) {
905 return {
906 content: [{ type: "text", text: getErrorMessage(result.stderr, "Post inline comment") }],
907 details: { action: "pr-line-comment", error: result.stderr, prNumber: params.number } as GhDetails,
908 isError: true,
909 };
910 }
911
912 let commentUrl = "";
913 try {
914 const data = JSON.parse(result.stdout);
915 commentUrl = data.html_url || "";
916 } catch {
917 // ok
918 }
919
920 const range = params.startLine ? `${params.path}:${params.startLine}-${params.line}` : `${params.path}:${params.line}`;
921
922 return {
923 content: [{ type: "text", text: `Posted inline comment on PR #${params.number} at ${range}${commentUrl ? "\n" + commentUrl : ""}` }],
924 details: { action: "pr-line-comment", output: range, prNumber: params.number } as GhDetails,
925 };
926}
927
928/**
929 * Submit a review with inline comments (requires approval)
930 *
931 * Uses: POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews
932 * This is for submitting a batch of inline comments as part of a review.
933 */
934export async function handlePRReviewWithComments(
935 pi: ExtensionAPI,
936 params: any,
937 signal: AbortSignal | undefined,
938 onUpdate: any,
939 ctx: ExtensionContext,
940): Promise<any> {
941 if (!params.number) {
942 return {
943 content: [{ type: "text", text: "Error: 'number' parameter is required" }],
944 details: { action: "pr-review-comments", error: "missing_number" } as GhDetails,
945 isError: true,
946 };
947 }
948 if (!params.reviewAction) {
949 return {
950 content: [{ type: "text", text: "Error: 'reviewAction' parameter is required (approve, request-changes, comment)" }],
951 details: { action: "pr-review-comments", error: "missing_review_action" } as GhDetails,
952 isError: true,
953 };
954 }
955 if (!params.comments || !Array.isArray(params.comments) || params.comments.length === 0) {
956 return {
957 content: [{ type: "text", text: "Error: 'comments' array is required with at least one inline comment" }],
958 details: { action: "pr-review-comments", error: "missing_comments" } as GhDetails,
959 isError: true,
960 };
961 }
962
963 // APPROVAL GATE
964 if (ctx.hasUI) {
965 const confirmMessage = buildReviewWithCommentsConfirmation(params.number, params.reviewAction, params.body, params.comments.length);
966 const approval = await approvalGate(ctx, `Submit review with ${params.comments.length} inline comment(s) on PR #${params.number}?`, confirmMessage);
967 if (approval.outcome === "modify") {
968 ctx.ui.notify("Review paused for modifications", "info");
969 return buildModifyResult("review with comments", { action: "pr-review-comments", prNumber: params.number });
970 }
971 if (approval.outcome === "rejected") {
972 ctx.ui.notify("Review rejected", "info");
973 return buildRejectResult("review with comments", { action: "pr-review-comments", prNumber: params.number });
974 }
975 }
976
977 onUpdate?.({ content: [{ type: "text", text: `Fetching PR #${params.number} HEAD SHA...` }] });
978
979 const commitId = await getPRHeadSha(pi, ctx, params.number, signal);
980 if (!commitId) {
981 return {
982 content: [{ type: "text", text: `Error: Could not get HEAD commit SHA for PR #${params.number}` }],
983 details: { action: "pr-review-comments", error: "no_head_sha", prNumber: params.number } as GhDetails,
984 isError: true,
985 };
986 }
987
988 // Map review action to API event
989 const eventMap: Record<string, string> = {
990 "approve": "APPROVE",
991 "request-changes": "REQUEST_CHANGES",
992 "comment": "COMMENT",
993 };
994 const event = eventMap[params.reviewAction] || "COMMENT";
995
996 // Build comments array for the API
997 const apiComments = (params.comments as GhLineComment[]).map((c) => {
998 const comment: Record<string, any> = {
999 path: c.path,
1000 body: c.body,
1001 line: c.line,
1002 side: c.side || "RIGHT",
1003 };
1004 if (c.startLine) {
1005 comment.start_line = c.startLine;
1006 comment.start_side = c.startSide || c.side || "RIGHT";
1007 }
1008 return comment;
1009 });
1010
1011 const payload: Record<string, any> = {
1012 commit_id: commitId,
1013 event,
1014 comments: apiComments,
1015 };
1016 if (params.body) payload.body = params.body;
1017
1018 onUpdate?.({ content: [{ type: "text", text: `Submitting review with ${apiComments.length} comment(s)...` }] });
1019
1020 // Write payload to temp file (pi.exec doesn't support stdin)
1021 const tmpFile = `/tmp/gh-review-${Date.now()}.json`;
1022 await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${JSON.stringify(payload)}\nGHEOF`], { signal });
1023
1024 const result = await execGh(pi, ctx,
1025 [
1026 "api",
1027 "repos/{owner}/{repo}/pulls/" + params.number + "/reviews",
1028 "--method", "POST",
1029 "--input", tmpFile,
1030 ],
1031 { signal, timeout: 30000 },
1032 );
1033
1034 // Clean up
1035 await pi.exec("rm", ["-f", tmpFile], { signal });
1036
1037 if (result.code !== 0) {
1038 return {
1039 content: [{ type: "text", text: getErrorMessage(result.stderr, "Submit review with comments") }],
1040 details: { action: "pr-review-comments", error: result.stderr, prNumber: params.number } as GhDetails,
1041 isError: true,
1042 };
1043 }
1044
1045 let reviewUrl = "";
1046 try {
1047 const data = JSON.parse(result.stdout);
1048 reviewUrl = data.html_url || "";
1049 } catch {
1050 // ok
1051 }
1052
1053 return {
1054 content: [{ type: "text", text: `Submitted ${params.reviewAction} review on PR #${params.number} with ${apiComments.length} inline comment(s)${reviewUrl ? "\n" + reviewUrl : ""}` }],
1055 details: {
1056 action: "pr-review-comments",
1057 output: `${params.reviewAction} with ${apiComments.length} comments`,
1058 prNumber: params.number,
1059 reviewAction: params.reviewAction,
1060 commentCount: apiComments.length,
1061 } as GhDetails,
1062 };
1063}
1064
1065// ============================================================================
1066// Review listing & editing
1067// ============================================================================
1068
1069/**
1070 * List reviews on a PR (top-level review summaries with IDs)
1071 */
1072export async function handlePRReviewsList(
1073 pi: ExtensionAPI,
1074 params: any,
1075 signal: AbortSignal | undefined,
1076 onUpdate: any,
1077 ctx: ExtensionContext,
1078): Promise<any> {
1079 if (!params.number) {
1080 return {
1081 content: [{ type: "text", text: "Error: 'number' parameter is required" }],
1082 details: { action: "pr-reviews-list", error: "missing_number" } as GhDetails,
1083 isError: true,
1084 };
1085 }
1086
1087 onUpdate?.({ content: [{ type: "text", text: `Fetching reviews for PR #${params.number}...` }] });
1088
1089 const result = await execGh(pi, ctx,
1090 ["api", `repos/{owner}/{repo}/pulls/${params.number}/reviews`, "--paginate"],
1091 { signal, timeout: 15000 },
1092 );
1093
1094 if (result.code !== 0) {
1095 return {
1096 content: [{ type: "text", text: getErrorMessage(result.stderr, "List reviews") }],
1097 details: { action: "pr-reviews-list", error: result.stderr, prNumber: params.number } as GhDetails,
1098 isError: true,
1099 };
1100 }
1101
1102 const reviews = parseReviewSummaries(result.stdout);
1103
1104 if (reviews.length === 0) {
1105 return {
1106 content: [{ type: "text", text: `No reviews found on PR #${params.number}` }],
1107 details: { action: "pr-reviews-list", output: "empty", prNumber: params.number } as GhDetails,
1108 };
1109 }
1110
1111 let text = `Reviews on PR #${params.number}:\n\n`;
1112 for (const r of reviews) {
1113 const body = r.body ? `\n ${truncate(r.body, 120)}` : "";
1114 text += `• [${r.id}] ${r.state} by @${r.author} (${formatRelativeDate(r.submittedAt)})${body}\n`;
1115 if (r.htmlUrl) text += ` ${r.htmlUrl}\n`;
1116 }
1117
1118 return {
1119 content: [{ type: "text", text }],
1120 details: { action: "pr-reviews-list", output: `${reviews.length} reviews`, prNumber: params.number } as GhDetails,
1121 };
1122}
1123
1124/**
1125 * Edit a review body (the top-level review comment, not inline comments)
1126 * Uses: PUT /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}
1127 */
1128export async function handlePRReviewEdit(
1129 pi: ExtensionAPI,
1130 params: any,
1131 signal: AbortSignal | undefined,
1132 onUpdate: any,
1133 ctx: ExtensionContext,
1134): Promise<any> {
1135 if (!params.number) {
1136 return {
1137 content: [{ type: "text", text: "Error: 'number' parameter is required" }],
1138 details: { action: "pr-review-edit", error: "missing_number" } as GhDetails,
1139 isError: true,
1140 };
1141 }
1142 if (!params.reviewId) {
1143 return {
1144 content: [{ type: "text", text: "Error: 'reviewId' parameter is required (use pr-reviews-list to find IDs)" }],
1145 details: { action: "pr-review-edit", error: "missing_review_id" } as GhDetails,
1146 isError: true,
1147 };
1148 }
1149 if (!params.body) {
1150 return {
1151 content: [{ type: "text", text: "Error: 'body' parameter is required (new review body text)" }],
1152 details: { action: "pr-review-edit", error: "missing_body" } as GhDetails,
1153 isError: true,
1154 };
1155 }
1156
1157 // APPROVAL GATE
1158 if (ctx.hasUI) {
1159 const confirmMessage = buildReviewEditConfirmation(params.number, params.reviewId, params.body);
1160 const approval = await approvalGateWithBodyPreview(
1161 ctx,
1162 `Edit review ${params.reviewId} on PR #${params.number}?`,
1163 confirmMessage,
1164 `PR #${params.number} Review ${params.reviewId} New Body Preview (${params.body.length} chars):`,
1165 params.body,
1166 );
1167 if (approval.outcome === "modify") {
1168 ctx.ui.notify("Review edit paused for modifications", "info");
1169 return buildModifyResult("review edit", { action: "pr-review-edit", prNumber: params.number, reviewId: params.reviewId });
1170 }
1171 if (approval.outcome === "rejected") {
1172 ctx.ui.notify("Review edit rejected", "info");
1173 return buildRejectResult("review edit", { action: "pr-review-edit", prNumber: params.number, reviewId: params.reviewId });
1174 }
1175 }
1176
1177 onUpdate?.({ content: [{ type: "text", text: `Editing review ${params.reviewId}...` }] });
1178
1179 const payload = JSON.stringify({ body: params.body });
1180 const tmpFile = `/tmp/gh-review-edit-${Date.now()}.json`;
1181 await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${payload}\nGHEOF`], { signal });
1182
1183 const result = await execGh(pi, ctx,
1184 [
1185 "api",
1186 `repos/{owner}/{repo}/pulls/${params.number}/reviews/${params.reviewId}`,
1187 "--method", "PUT",
1188 "--input", tmpFile,
1189 ],
1190 { signal, timeout: 20000 },
1191 );
1192
1193 await pi.exec("rm", ["-f", tmpFile], { signal });
1194
1195 if (result.code !== 0) {
1196 return {
1197 content: [{ type: "text", text: getErrorMessage(result.stderr, "Edit review") }],
1198 details: { action: "pr-review-edit", error: result.stderr, prNumber: params.number, reviewId: params.reviewId } as GhDetails,
1199 isError: true,
1200 };
1201 }
1202
1203 let url = "";
1204 try {
1205 const data = JSON.parse(result.stdout);
1206 url = data.html_url || "";
1207 } catch { /* ok */ }
1208
1209 return {
1210 content: [{ type: "text", text: `Updated review ${params.reviewId} on PR #${params.number}${url ? "\n" + url : ""}` }],
1211 details: { action: "pr-review-edit", prNumber: params.number, reviewId: params.reviewId } as GhDetails,
1212 };
1213}
1214
1215// ============================================================================
1216// Inline review comment listing, editing, deleting
1217// ============================================================================
1218
1219/**
1220 * List inline review comments on a PR (with IDs for editing/deleting)
1221 * Uses: GET /repos/{owner}/{repo}/pulls/{pull_number}/comments
1222 */
1223export async function handlePRReviewCommentsList(
1224 pi: ExtensionAPI,
1225 params: any,
1226 signal: AbortSignal | undefined,
1227 onUpdate: any,
1228 ctx: ExtensionContext,
1229): Promise<any> {
1230 if (!params.number) {
1231 return {
1232 content: [{ type: "text", text: "Error: 'number' parameter is required" }],
1233 details: { action: "pr-review-comments-list", error: "missing_number" } as GhDetails,
1234 isError: true,
1235 };
1236 }
1237
1238 onUpdate?.({ content: [{ type: "text", text: `Fetching review comments for PR #${params.number}...` }] });
1239
1240 const result = await execGh(pi, ctx,
1241 ["api", `repos/{owner}/{repo}/pulls/${params.number}/comments`, "--paginate"],
1242 { signal, timeout: 15000 },
1243 );
1244
1245 if (result.code !== 0) {
1246 return {
1247 content: [{ type: "text", text: getErrorMessage(result.stderr, "List review comments") }],
1248 details: { action: "pr-review-comments-list", error: result.stderr, prNumber: params.number } as GhDetails,
1249 isError: true,
1250 };
1251 }
1252
1253 const comments = parseReviewComments(result.stdout);
1254
1255 if (comments.length === 0) {
1256 return {
1257 content: [{ type: "text", text: `No inline review comments on PR #${params.number}` }],
1258 details: { action: "pr-review-comments-list", output: "empty", prNumber: params.number } as GhDetails,
1259 };
1260 }
1261
1262 let text = `Inline review comments on PR #${params.number}:\n\n`;
1263 for (const c of comments) {
1264 const reply = c.inReplyToId ? ` (reply to ${c.inReplyToId})` : "";
1265 text += `• [${c.id}] ${c.path}:${c.line} by @${c.author} (${formatRelativeDate(c.createdAt)})${reply}\n`;
1266 text += ` ${truncate(c.body, 120)}\n`;
1267 if (c.htmlUrl) text += ` ${c.htmlUrl}\n`;
1268 }
1269
1270 return {
1271 content: [{ type: "text", text }],
1272 details: { action: "pr-review-comments-list", output: `${comments.length} comments`, prNumber: params.number } as GhDetails,
1273 };
1274}
1275
1276/**
1277 * Edit an inline review comment by ID
1278 * Uses: PATCH /repos/{owner}/{repo}/pulls/comments/{comment_id}
1279 */
1280export async function handlePRReviewCommentEdit(
1281 pi: ExtensionAPI,
1282 params: any,
1283 signal: AbortSignal | undefined,
1284 onUpdate: any,
1285 ctx: ExtensionContext,
1286): Promise<any> {
1287 if (!params.commentId) {
1288 return {
1289 content: [{ type: "text", text: "Error: 'commentId' parameter is required (use pr-review-comments-list to find IDs)" }],
1290 details: { action: "pr-review-comment-edit", error: "missing_comment_id" } as GhDetails,
1291 isError: true,
1292 };
1293 }
1294 if (!params.body) {
1295 return {
1296 content: [{ type: "text", text: "Error: 'body' parameter is required (new comment text)" }],
1297 details: { action: "pr-review-comment-edit", error: "missing_body" } as GhDetails,
1298 isError: true,
1299 };
1300 }
1301
1302 // APPROVAL GATE
1303 if (ctx.hasUI) {
1304 const confirmMessage = buildReviewCommentEditConfirmation(params.commentId, params.body);
1305 const approval = await approvalGateWithBodyPreview(
1306 ctx,
1307 `Edit review comment ${params.commentId}?`,
1308 confirmMessage,
1309 `Review Comment ${params.commentId} New Body Preview (${params.body.length} chars):`,
1310 params.body,
1311 );
1312 if (approval.outcome === "modify") {
1313 ctx.ui.notify("Comment edit paused for modifications", "info");
1314 return buildModifyResult("review comment edit", { action: "pr-review-comment-edit", commentId: params.commentId });
1315 }
1316 if (approval.outcome === "rejected") {
1317 ctx.ui.notify("Comment edit rejected", "info");
1318 return buildRejectResult("review comment edit", { action: "pr-review-comment-edit", commentId: params.commentId });
1319 }
1320 }
1321
1322 onUpdate?.({ content: [{ type: "text", text: `Editing comment ${params.commentId}...` }] });
1323
1324 const payload = JSON.stringify({ body: params.body });
1325 const tmpFile = `/tmp/gh-comment-edit-${Date.now()}.json`;
1326 await pi.exec("sh", ["-c", `cat > ${tmpFile} << 'GHEOF'\n${payload}\nGHEOF`], { signal });
1327
1328 const result = await execGh(pi, ctx,
1329 [
1330 "api",
1331 `repos/{owner}/{repo}/pulls/comments/${params.commentId}`,
1332 "--method", "PATCH",
1333 "--input", tmpFile,
1334 ],
1335 { signal, timeout: 20000 },
1336 );
1337
1338 await pi.exec("rm", ["-f", tmpFile], { signal });
1339
1340 if (result.code !== 0) {
1341 return {
1342 content: [{ type: "text", text: getErrorMessage(result.stderr, "Edit review comment") }],
1343 details: { action: "pr-review-comment-edit", error: result.stderr, commentId: params.commentId } as GhDetails,
1344 isError: true,
1345 };
1346 }
1347
1348 let url = "";
1349 try {
1350 const data = JSON.parse(result.stdout);
1351 url = data.html_url || "";
1352 } catch { /* ok */ }
1353
1354 return {
1355 content: [{ type: "text", text: `Updated review comment ${params.commentId}${url ? "\n" + url : ""}` }],
1356 details: { action: "pr-review-comment-edit", commentId: params.commentId } as GhDetails,
1357 };
1358}
1359
1360/**
1361 * Delete an inline review comment by ID
1362 * Uses: DELETE /repos/{owner}/{repo}/pulls/comments/{comment_id}
1363 */
1364export async function handlePRReviewCommentDelete(
1365 pi: ExtensionAPI,
1366 params: any,
1367 signal: AbortSignal | undefined,
1368 onUpdate: any,
1369 ctx: ExtensionContext,
1370): Promise<any> {
1371 if (!params.commentId) {
1372 return {
1373 content: [{ type: "text", text: "Error: 'commentId' parameter is required (use pr-review-comments-list to find IDs)" }],
1374 details: { action: "pr-review-comment-delete", error: "missing_comment_id" } as GhDetails,
1375 isError: true,
1376 };
1377 }
1378
1379 // APPROVAL GATE
1380 if (ctx.hasUI) {
1381 const confirmMessage = buildReviewCommentDeleteConfirmation(params.commentId);
1382 const approval = await approvalGate(ctx, `Delete review comment ${params.commentId}?`, confirmMessage);
1383 if (approval.outcome === "modify") {
1384 ctx.ui.notify("Delete paused for modifications", "info");
1385 return buildModifyResult("review comment delete", { action: "pr-review-comment-delete", commentId: params.commentId });
1386 }
1387 if (approval.outcome === "rejected") {
1388 ctx.ui.notify("Delete rejected", "info");
1389 return buildRejectResult("review comment delete", { action: "pr-review-comment-delete", commentId: params.commentId });
1390 }
1391 }
1392
1393 onUpdate?.({ content: [{ type: "text", text: `Deleting comment ${params.commentId}...` }] });
1394
1395 const result = await execGh(pi, ctx,
1396 [
1397 "api",
1398 `repos/{owner}/{repo}/pulls/comments/${params.commentId}`,
1399 "--method", "DELETE",
1400 ],
1401 { signal, timeout: 20000 },
1402 );
1403
1404 if (result.code !== 0) {
1405 return {
1406 content: [{ type: "text", text: getErrorMessage(result.stderr, "Delete review comment") }],
1407 details: { action: "pr-review-comment-delete", error: result.stderr, commentId: params.commentId } as GhDetails,
1408 isError: true,
1409 };
1410 }
1411
1412 return {
1413 content: [{ type: "text", text: `Deleted review comment ${params.commentId}` }],
1414 details: { action: "pr-review-comment-delete", commentId: params.commentId } as GhDetails,
1415 };
1416}