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}