flake-update-20260505
   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}