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