main
  1/**
  2 * Utility functions for GitHub extension
  3 */
  4
  5import type { Theme, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
  6import type { GhDetails, GhPR, GhIssue, GhCheck, GhRun, GhReview, GhReviewComment, GhReviewSummary, GhRelease, GhRepo } from "./types";
  7
  8// ============================================================================
  9// Git Repository Detection (shared with handlers)
 10// ============================================================================
 11
 12let cachedGitRoot: string | null = null;
 13let cachedWorktreeRoot: string | null = null;
 14
 15/**
 16 * Find the git repository root directory.
 17 * Returns null if not in a git repository.
 18 */
 19async function findGitRoot(pi: ExtensionAPI, cwd: string): Promise<string | null> {
 20	if (cachedGitRoot !== null) return cachedGitRoot;
 21	
 22	try {
 23		const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd, timeout: 5000 });
 24		if (result.code === 0 && result.stdout.trim()) {
 25			cachedGitRoot = result.stdout.trim();
 26			return cachedGitRoot;
 27		}
 28	} catch {
 29		// Ignore errors
 30	}
 31	return null;
 32}
 33
 34/**
 35 * Detect if we're working in a git worktree context.
 36 * Checks git_worktree tool results and recent bash operations.
 37 * Returns the worktree path if detected, null otherwise.
 38 */
 39async function detectWorktreeContext(
 40	pi: ExtensionAPI,
 41	ctx: ExtensionContext
 42): Promise<string | null> {
 43	if (cachedWorktreeRoot !== null) return cachedWorktreeRoot;
 44	
 45	// Strategy 1: Check if ctx.cwd itself is a worktree
 46	const gitDirResult = await pi.exec("git", ["rev-parse", "--git-dir"], { cwd: ctx.cwd, timeout: 5000 });
 47	if (gitDirResult.code === 0 && gitDirResult.stdout.includes("/worktrees/")) {
 48		// We're already in a worktree
 49		const toplevel = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: ctx.cwd, timeout: 5000 });
 50		if (toplevel.code === 0 && toplevel.stdout.trim()) {
 51			cachedWorktreeRoot = toplevel.stdout.trim();
 52			return cachedWorktreeRoot;
 53		}
 54	}
 55	
 56	// Strategy 2: Check for git_worktree tool results (most reliable)
 57	try {
 58		const entries = ctx.sessionManager.getBranch();
 59		let mostRecentWorktree: string | null = null;
 60		let mostRecentTimestamp = 0;
 61		
 62		for (const entry of entries) {
 63			if (entry.type !== "message") continue;
 64			const msg = entry.message;
 65			
 66			// Look for git_worktree tool results (from custom git extension)
 67			if (msg.role === "toolResult" && msg.toolName === "git_worktree") {
 68				const details = msg.details as any;
 69				const timestamp = typeof entry.timestamp === "number" ? entry.timestamp : 0;
 70				
 71				// git_worktree create returns: { path: string, branch: string }
 72				if (details?.path && timestamp > mostRecentTimestamp) {
 73					mostRecentWorktree = details.path;
 74					mostRecentTimestamp = timestamp;
 75				}
 76			}
 77		}
 78		
 79		if (mostRecentWorktree) {
 80			// Verify it still exists and is a valid git directory
 81			const checkResult = await pi.exec("git", ["rev-parse", "--show-toplevel"], { 
 82				cwd: mostRecentWorktree, 
 83				timeout: 5000 
 84			});
 85			if (checkResult.code === 0 && checkResult.stdout.trim()) {
 86				cachedWorktreeRoot = checkResult.stdout.trim();
 87				return cachedWorktreeRoot;
 88			}
 89		}
 90	} catch {
 91		// Ignore errors in git_worktree detection
 92	}
 93	
 94	// Strategy 3: Fallback to checking bash history for worktree operations
 95	try {
 96		const entries = ctx.sessionManager.getBranch();
 97		for (const entry of entries.reverse()) { // Start from most recent
 98			if (entry.type !== "message") continue;
 99			const msg = entry.message;
100			if (msg.role !== "toolResult" || msg.toolName !== "bash") continue;
101			
102			const details = msg.details as any;
103			if (!details?.cwd) continue;
104			
105			// Check if this command was run in a worktree directory
106			if (details.cwd.includes("/.local/share/worktrees/")) {
107				// Verify it's still a valid git directory
108				const checkResult = await pi.exec("git", ["rev-parse", "--show-toplevel"], { 
109					cwd: details.cwd, 
110					timeout: 5000 
111				});
112				if (checkResult.code === 0 && checkResult.stdout.trim()) {
113					cachedWorktreeRoot = checkResult.stdout.trim();
114					return cachedWorktreeRoot;
115				}
116			}
117		}
118	} catch {
119		// Ignore errors in bash history scanning
120	}
121
122	// Strategy 4: Check `git worktree list` from the main repo for any linked
123	// worktrees under the conventional ~/.local/share/worktrees/ directory.
124	// Only use this if there is exactly one linked worktree (ambiguous otherwise).
125	try {
126		const wtResult = await pi.exec(
127			"git", ["worktree", "list", "--porcelain"],
128			{ cwd: ctx.cwd, timeout: 5000 },
129		);
130		if (wtResult.code === 0) {
131			const lines = wtResult.stdout.split("\n");
132			const worktrees: string[] = [];
133			for (const line of lines) {
134				if (line.startsWith("worktree ")) {
135					const wtPath = line.slice("worktree ".length);
136					if (wtPath.includes("/.local/share/worktrees/")) {
137						worktrees.push(wtPath);
138					}
139				}
140			}
141			if (worktrees.length === 1) {
142				cachedWorktreeRoot = worktrees[0];
143				return cachedWorktreeRoot;
144			}
145		}
146	} catch {
147		// Ignore errors
148	}
149	
150	return null;
151}
152
153/**
154 * Reset cached git root (call on session change)
155 */
156export function resetGitRoot() {
157	cachedGitRoot = null;
158	cachedWorktreeRoot = null;
159}
160
161/**
162 * Resolve the effective git working directory.
163 * Prefers worktree context, falls back to git root, then ctx.cwd.
164 */
165export async function resolveGitCwd(
166	pi: ExtensionAPI,
167	ctx: ExtensionContext,
168): Promise<string> {
169	const worktree = await detectWorktreeContext(pi, ctx);
170	if (worktree) return worktree;
171	const root = await findGitRoot(pi, ctx.cwd);
172	return root ?? ctx.cwd;
173}
174
175/**
176 * Execute gh command with correct working directory.
177 * Automatically uses git repository root if available.
178 * Detects and prefers worktree context when available.
179 */
180export async function execGh(
181	pi: ExtensionAPI,
182	ctx: ExtensionContext,
183	args: string[],
184	options?: { signal?: AbortSignal; timeout?: number }
185) {
186	const cwd = await resolveGitCwd(pi, ctx);
187	return await pi.exec("gh", args, { ...options, cwd });
188}
189
190/**
191 * Find PR template in common locations.
192 * Returns the path to the template file if found, null otherwise.
193 */
194export async function findPRTemplate(
195	pi: ExtensionAPI,
196	ctx: ExtensionContext
197): Promise<string | null> {
198	// Determine the correct working directory (worktree-aware)
199	const worktree = await detectWorktreeContext(pi, ctx);
200	const gitRoot = worktree ?? await findGitRoot(pi, ctx.cwd) ?? ctx.cwd;
201	
202	const templatePaths = [
203		".github/PULL_REQUEST_TEMPLATE.md",
204		".github/pull_request_template.md",
205		"docs/PULL_REQUEST_TEMPLATE.md",
206		"PULL_REQUEST_TEMPLATE.md",
207	];
208	
209	for (const templatePath of templatePaths) {
210		const result = await pi.exec("test", ["-f", templatePath], { cwd: gitRoot });
211		if (result.code === 0) {
212			return templatePath;
213		}
214	}
215	
216	return null;
217}
218
219// ============================================================================
220// Parsing: gh CLI JSON output → typed objects
221// ============================================================================
222
223export function parsePRList(json: string): GhPR[] {
224	try {
225		const data = JSON.parse(json);
226		if (!Array.isArray(data)) return [];
227		return data.map(parsePRItem);
228	} catch {
229		return [];
230	}
231}
232
233export function parsePRItem(item: any): GhPR {
234	return {
235		number: item.number ?? 0,
236		title: item.title ?? "",
237		state: item.state ?? "OPEN",
238		author: item.author?.login ?? "",
239		branch: item.headRefName ?? "",
240		base: item.baseRefName ?? "",
241		url: item.url ?? "",
242		isDraft: item.isDraft ?? false,
243		labels: (item.labels ?? []).map((l: any) => l.name ?? l),
244		reviewDecision: item.reviewDecision ?? "",
245		additions: item.additions ?? 0,
246		deletions: item.deletions ?? 0,
247		changedFiles: item.changedFiles ?? 0,
248		createdAt: item.createdAt ?? "",
249		updatedAt: item.updatedAt ?? "",
250	};
251}
252
253export function parseIssueList(json: string): GhIssue[] {
254	try {
255		const data = JSON.parse(json);
256		if (!Array.isArray(data)) return [];
257		return data.map(parseIssueItem);
258	} catch {
259		return [];
260	}
261}
262
263export function parseIssueItem(item: any): GhIssue {
264	return {
265		number: item.number ?? 0,
266		title: item.title ?? "",
267		state: item.state ?? "OPEN",
268		author: item.author?.login ?? "",
269		url: item.url ?? "",
270		labels: (item.labels ?? []).map((l: any) => l.name ?? l),
271		assignees: (item.assignees ?? []).map((a: any) => a.login ?? a),
272		createdAt: item.createdAt ?? "",
273		updatedAt: item.updatedAt ?? "",
274		body: item.body ?? "",
275		comments: item.comments?.totalCount ?? item.comments ?? 0,
276	};
277}
278
279export function parseChecks(json: string): GhCheck[] {
280	try {
281		const data = JSON.parse(json);
282		// gh pr view --json statusCheckRollup returns { statusCheckRollup: [...] }
283		const checks = data.statusCheckRollup ?? data;
284		if (!Array.isArray(checks)) return [];
285		return checks.map((item: any) => ({
286			name: item.name ?? item.context ?? "",
287			status: item.status ?? "",
288			conclusion: item.conclusion ?? "",
289			startedAt: item.startedAt ?? "",
290			completedAt: item.completedAt ?? "",
291			detailsUrl: item.detailsUrl ?? item.targetUrl ?? "",
292		}));
293	} catch {
294		return [];
295	}
296}
297
298export function parseRunList(json: string): GhRun[] {
299	try {
300		const data = JSON.parse(json);
301		if (!Array.isArray(data)) return [];
302		return data.map((item: any) => ({
303			databaseId: item.databaseId ?? 0,
304			name: item.name ?? "",
305			displayTitle: item.displayTitle ?? "",
306			status: item.status ?? "",
307			conclusion: item.conclusion ?? "",
308			headBranch: item.headBranch ?? "",
309			event: item.event ?? "",
310			url: item.url ?? "",
311			createdAt: item.createdAt ?? "",
312			updatedAt: item.updatedAt ?? "",
313		}));
314	} catch {
315		return [];
316	}
317}
318
319export function parseReviews(json: string): GhReview[] {
320	try {
321		const data = JSON.parse(json);
322		// gh pr view --json reviews returns { reviews: [...] }
323		const reviews = data.reviews ?? data;
324		if (!Array.isArray(reviews)) return [];
325		return reviews.map((item: any) => ({
326			author: item.author?.login ?? "",
327			state: item.state ?? "",
328			body: item.body ?? "",
329			submittedAt: item.submittedAt ?? "",
330		}));
331	} catch {
332		return [];
333	}
334}
335
336export function parseReviewComments(json: string): GhReviewComment[] {
337	try {
338		const data = JSON.parse(json);
339		const comments = Array.isArray(data) ? data : (data.comments ?? []);
340		if (!Array.isArray(comments)) return [];
341		return comments.map((item: any) => ({
342			id: item.id ?? 0,
343			author: item.author?.login ?? item.user?.login ?? "",
344			body: item.body ?? "",
345			path: item.path ?? "",
346			line: item.line ?? item.original_line ?? 0,
347			createdAt: item.createdAt ?? item.created_at ?? "",
348			updatedAt: item.updatedAt ?? item.updated_at ?? "",
349			inReplyToId: item.in_reply_to_id ?? undefined,
350			htmlUrl: item.html_url ?? "",
351		}));
352	} catch {
353		return [];
354	}
355}
356
357export function parseReviewSummaries(json: string): GhReviewSummary[] {
358	try {
359		const data = JSON.parse(json);
360		const reviews = Array.isArray(data) ? data : (data.reviews ?? []);
361		if (!Array.isArray(reviews)) return [];
362		return reviews.map((item: any) => ({
363			id: item.id ?? 0,
364			author: item.author?.login ?? item.user?.login ?? "",
365			state: item.state ?? "",
366			body: item.body ?? "",
367			submittedAt: item.submittedAt ?? item.submitted_at ?? "",
368			htmlUrl: item.html_url ?? "",
369			commitId: item.commit_id ?? "",
370		}));
371	} catch {
372		return [];
373	}
374}
375
376export function parseReleaseList(json: string): GhRelease[] {
377	try {
378		const data = JSON.parse(json);
379		if (!Array.isArray(data)) return [];
380		return data.map((item: any) => ({
381			tagName: item.tagName ?? "",
382			name: item.name ?? "",
383			isDraft: item.isDraft ?? false,
384			isPrerelease: item.isPrerelease ?? false,
385			publishedAt: item.publishedAt ?? "",
386			url: item.url ?? "",
387		}));
388	} catch {
389		return [];
390	}
391}
392
393export function parseRepo(json: string): GhRepo | null {
394	try {
395		const data = JSON.parse(json);
396		return {
397			nameWithOwner: data.nameWithOwner ?? "",
398			description: data.description ?? "",
399			defaultBranch: data.defaultBranchRef?.name ?? data.defaultBranch ?? "",
400			visibility: data.visibility ?? "",
401			url: data.url ?? "",
402			stargazerCount: data.stargazerCount ?? 0,
403			forkCount: data.forkCount ?? 0,
404			isArchived: data.isArchived ?? false,
405		};
406	} catch {
407		return null;
408	}
409}
410
411// ============================================================================
412// Formatting helpers
413// ============================================================================
414
415export function truncate(text: string, maxLength: number): string {
416	if (text.length <= maxLength) return text;
417	return text.slice(0, maxLength - 3) + "...";
418}
419
420export function formatDate(dateStr: string): string {
421	if (!dateStr) return "";
422	try {
423		const date = new Date(dateStr);
424		return date.toLocaleDateString();
425	} catch {
426		return dateStr;
427	}
428}
429
430export function formatRelativeDate(dateStr: string): string {
431	if (!dateStr) return "";
432	try {
433		const date = new Date(dateStr);
434		const now = new Date();
435		const diffMs = now.getTime() - date.getTime();
436		const diffMins = Math.floor(diffMs / 60000);
437		const diffHours = Math.floor(diffMs / 3600000);
438		const diffDays = Math.floor(diffMs / 86400000);
439
440		if (diffMins < 1) return "just now";
441		if (diffMins < 60) return `${diffMins}m ago`;
442		if (diffHours < 24) return `${diffHours}h ago`;
443		if (diffDays < 30) return `${diffDays}d ago`;
444		return formatDate(dateStr);
445	} catch {
446		return dateStr;
447	}
448}
449
450// ============================================================================
451// Rendering helpers
452// ============================================================================
453
454export function getPRStateIcon(pr: GhPR): string {
455	if (pr.state === "MERGED") return "⏣";
456	if (pr.state === "CLOSED") return "✗";
457	if (pr.isDraft) return "◌";
458	return "●";
459}
460
461export function getPRStateColor(pr: GhPR, theme: Theme): string {
462	const icon = getPRStateIcon(pr);
463	if (pr.state === "MERGED") return theme.fg("accent", icon);
464	if (pr.state === "CLOSED") return theme.fg("error", icon);
465	if (pr.isDraft) return theme.fg("dim", icon);
466	return theme.fg("success", icon);
467}
468
469export function getCheckIcon(check: GhCheck): string {
470	if (check.conclusion === "SUCCESS") return "✓";
471	if (check.conclusion === "FAILURE") return "✗";
472	if (check.conclusion === "CANCELLED") return "⊘";
473	if (check.conclusion === "SKIPPED") return "⊘";
474	if (check.status === "IN_PROGRESS" || check.status === "QUEUED") return "⏳";
475	return "?";
476}
477
478export function getCheckColor(check: GhCheck, theme: Theme): string {
479	const icon = getCheckIcon(check);
480	if (check.conclusion === "SUCCESS") return theme.fg("success", icon);
481	if (check.conclusion === "FAILURE") return theme.fg("error", icon);
482	if (check.conclusion === "CANCELLED" || check.conclusion === "SKIPPED") return theme.fg("dim", icon);
483	if (check.status === "IN_PROGRESS" || check.status === "QUEUED") return theme.fg("warning", icon);
484	return theme.fg("muted", icon);
485}
486
487export function getRunStatusIcon(run: GhRun): string {
488	if (run.conclusion === "success") return "✓";
489	if (run.conclusion === "failure") return "✗";
490	if (run.conclusion === "cancelled") return "⊘";
491	if (run.status === "in_progress" || run.status === "queued") return "⏳";
492	return "?";
493}
494
495export function getRunStatusColor(run: GhRun, theme: Theme): string {
496	const icon = getRunStatusIcon(run);
497	if (run.conclusion === "success") return theme.fg("success", icon);
498	if (run.conclusion === "failure") return theme.fg("error", icon);
499	if (run.conclusion === "cancelled") return theme.fg("dim", icon);
500	if (run.status === "in_progress" || run.status === "queued") return theme.fg("warning", icon);
501	return theme.fg("muted", icon);
502}
503
504export function getReviewDecisionText(decision: string): string {
505	switch (decision) {
506		case "APPROVED": return "✓ Approved";
507		case "CHANGES_REQUESTED": return "✗ Changes requested";
508		case "REVIEW_REQUIRED": return "⏳ Review required";
509		default: return decision || "No reviews";
510	}
511}
512
513// ============================================================================
514// PR write argument helpers
515// ============================================================================
516
517export function normalizePRNumbers(params: { numbers?: unknown }): number[] {
518	if (!Array.isArray(params.numbers) || params.numbers.length === 0) return [];
519	if (!params.numbers.every((number) => Number.isInteger(number) && (number as number) > 0)) return [];
520	return [...new Set(params.numbers as number[])];
521}
522
523/** Convert persisted calls from the former singular PR write interface. */
524export function prepareGithubArguments(args: unknown): unknown {
525	if (!args || typeof args !== "object") return args;
526	const params = args as { action?: string; number?: unknown; numbers?: unknown };
527	if (
528		(params.action === "pr-review" || params.action === "pr-comment") &&
529		params.numbers === undefined &&
530		typeof params.number === "number"
531	) {
532		const { number, ...rest } = params;
533		return { ...rest, numbers: [number] };
534	}
535	return args;
536}
537
538// ============================================================================
539// Approval gate helper (with mutex to prevent parallel dialog deadlocks)
540// ============================================================================
541
542export type ApprovalResult =
543	| { outcome: "accepted" }
544	| { outcome: "modify" }
545	| { outcome: "rejected" };
546
547// Mutex to serialize concurrent approval dialogs.
548// When the LLM issues multiple write tool calls in parallel, each hits
549// approvalGate(). Without serialization, multiple ctx.ui.select() dialogs
550// overlap and cause a UI deadlock (the same bug as the Jira extension).
551let approvalMutex: Promise<void> = Promise.resolve();
552
553/**
554 * Three-way approval gate: Accept / Modify / Reject.
555 *
556 * - "Accept" proceeds with execution.
557 * - "Modify" tells the LLM the user wants to iterate on the content before
558 *   submitting. The LLM should ask the user what to change.
559 * - "Reject" means the user does not want this action at all.
560 *
561 * Uses a mutex so parallel tool calls present dialogs one at a time
562 * instead of all at once (which deadlocks the UI).
563 *
564 * Returns the user's choice so the caller can build the appropriate response.
565 */
566export async function approvalGate(
567	ctx: ExtensionContext,
568	title: string,
569	description: string,
570): Promise<ApprovalResult> {
571	// Chain onto the mutex so dialogs appear sequentially
572	const result = await new Promise<ApprovalResult>((resolve) => {
573		approvalMutex = approvalMutex.then(async () => {
574			const prompt = description ? `${title}\n\n${description}` : title;
575			const choice = await ctx.ui.select(prompt, [
576				"✓ Accept",
577				"✎ Modify",
578				"✗ Reject",
579			]);
580
581			if (choice === "✓ Accept") resolve({ outcome: "accepted" });
582			else if (choice === "✎ Modify") resolve({ outcome: "modify" });
583			else resolve({ outcome: "rejected" });
584		});
585	});
586
587	return result;
588}
589
590/**
591 * Approval gate with body preview for actions that carry long text content.
592 *
593 * When `bodyText` exceeds 200 characters a fourth option —
594 * "📄 Preview body first" — is offered before the standard
595 * Accept / Modify / Reject flow.  Choosing it opens an editor pane so the
596 * user can read the full content, then re-presents the confirmation dialog.
597 *
598 * @param ctx          Extension context
599 * @param title        Confirmation dialog title
600 * @param description  Confirmation dialog description (with truncated preview)
601 * @param previewTitle Label shown at the top of the editor preview pane
602 * @param bodyText     Full body text; only previewed when length > 200
603 */
604export async function approvalGateWithBodyPreview(
605	ctx: ExtensionContext,
606	title: string,
607	description: string,
608	previewTitle: string,
609	bodyText: string,
610): Promise<ApprovalResult> {
611	if (bodyText.length <= 200) {
612		return approvalGate(ctx, title, description);
613	}
614
615	const prompt = description ? `${title}\n\n${description}` : title;
616	const choice = await ctx.ui.select(prompt, [
617		"✓ Accept",
618		"📄 Preview body first",
619		"✎ Modify",
620		"✗ Reject",
621	]);
622
623	if (choice === undefined || choice === "✗ Reject") return { outcome: "rejected" };
624	if (choice === "✎ Modify") return { outcome: "modify" };
625
626	if (choice === "📄 Preview body first") {
627		await ctx.ui.editor(`${previewTitle}\n\n---\n\n`, bodyText);
628		// Re-present the standard three-way dialog after the preview
629		return approvalGate(ctx, title, description);
630	}
631
632	// "✓ Accept"
633	return { outcome: "accepted" };
634}
635
636/**
637 * Build a tool result for when the user wants modifications.
638 * The message clearly tells the LLM to ask the user what they want changed.
639 */
640export function buildModifyResult(action: string, details: Partial<GhDetails>): any {
641	return {
642		content: [{ type: "text", text: `User wants to modify the ${action} before submitting. Ask the user what they would like to change, then retry with the updated parameters.` }],
643		details: { action: details.action, modifyRequested: true, ...details } as GhDetails,
644	};
645}
646
647/**
648 * Build a tool result for when the user rejects the action entirely.
649 * The message explicitly tells the LLM NOT to retry.
650 */
651export function buildRejectResult(action: string, details: Partial<GhDetails>): any {
652	return {
653		content: [{ type: "text", text: `User rejected this ${action}. Do NOT retry or attempt this action again.` }],
654		details: { action: details.action, cancelled: true, ...details } as GhDetails,
655	};
656}
657
658// ============================================================================
659// Confirmation builders
660// ============================================================================
661
662export function buildPRCreateConfirmation(params: any): string {
663	let msg = "";
664	msg += `Title: "${params.title}"\n`;
665	if (params.base) msg += `Base: ${params.base}\n`;
666	if (params.body) {
667		const preview = params.body.length > 200 ? params.body.slice(0, 197) + "..." : params.body;
668		msg += `Body: ${preview}\n`;
669	}
670	if (params.draft) msg += `Draft: yes\n`;
671	if (params.labels?.length) msg += `Labels: ${params.labels.join(", ")}\n`;
672	if (params.reviewers?.length) msg += `Reviewers: ${params.reviewers.join(", ")}\n`;
673	msg += "\nThis will create a new pull request on GitHub.";
674	return msg;
675}
676
677export function buildPRMergeConfirmation(params: any): string {
678	let msg = `PR: #${params.number}\n`;
679	msg += `Method: ${params.method || "merge"}\n`;
680	if (params.deleteBranch) msg += `Delete branch: yes\n`;
681	msg += "\nThis will merge the pull request.";
682	return msg;
683}
684
685export function buildReviewConfirmation(params: any): string {
686	const numbers = normalizePRNumbers(params);
687	let msg = `PRs: ${numbers.map((number) => `#${number}`).join(", ")}\n`;
688	msg += `Action: ${params.reviewAction}\n`;
689	if (params.body) {
690		const preview = params.body.length > 200 ? params.body.slice(0, 197) + "..." : params.body;
691		msg += `Comment: ${preview}\n`;
692	} else {
693		msg += `Comment: (none)\n`;
694	}
695	msg += "\nThis will submit a review on the pull request.";
696	return msg;
697}
698
699export function buildIssueCreateConfirmation(params: any): string {
700	let msg = "";
701	msg += `Title: "${params.title}"\n`;
702	if (params.body) {
703		const preview = params.body.length > 200 ? params.body.slice(0, 197) + "..." : params.body;
704		msg += `Body: ${preview}\n`;
705	}
706	if (params.labels?.length) msg += `Labels: ${params.labels.join(", ")}\n`;
707	if (params.assignees?.length) msg += `Assignees: ${params.assignees.join(", ")}\n`;
708	msg += "\nThis will create a new issue on GitHub.";
709	return msg;
710}
711
712export function buildLineCommentConfirmation(number: number, path: string, line: number, body: string, startLine?: number): string {
713	const preview = body.length > 200 ? body.slice(0, 197) + "..." : body;
714	const range = startLine ? `lines ${startLine}-${line}` : `line ${line}`;
715	let msg = `PR: #${number}\n`;
716	msg += `File: ${path}:${range}\n\n`;
717	msg += `Comment:\n"${preview}"\n\n`;
718	msg += "This will post an inline comment on the PR diff.";
719	return msg;
720}
721
722export function buildReviewWithCommentsConfirmation(number: number, reviewAction: string, body: string | undefined, commentsCount: number): string {
723	let msg = `PR: #${number}\n`;
724	msg += `Action: ${reviewAction}\n`;
725	msg += `Inline comments: ${commentsCount}\n`;
726	if (body) {
727		const preview = body.length > 200 ? body.slice(0, 197) + "..." : body;
728		msg += `Review body: ${preview}\n`;
729	}
730	msg += "\nThis will submit a review with inline comments on the PR.";
731	return msg;
732}
733
734export function buildReviewEditConfirmation(number: number, reviewId: number, body: string): string {
735	const preview = body.length > 200 ? body.slice(0, 197) + "..." : body;
736	let msg = `PR: #${number}\n`;
737	msg += `Review ID: ${reviewId}\n\n`;
738	msg += `New body:\n"${preview}"\n\n`;
739	msg += "This will update the review body on the PR.";
740	return msg;
741}
742
743export function buildReviewCommentEditConfirmation(commentId: number, body: string): string {
744	const preview = body.length > 200 ? body.slice(0, 197) + "..." : body;
745	let msg = `Comment ID: ${commentId}\n\n`;
746	msg += `New body:\n"${preview}"\n\n`;
747	msg += "This will update the inline review comment.";
748	return msg;
749}
750
751export function buildReviewCommentDeleteConfirmation(commentId: number): string {
752	let msg = `Comment ID: ${commentId}\n\n`;
753	msg += "This will permanently delete the inline review comment.";
754	return msg;
755}
756
757export function buildSubIssueConfirmation(action: "add" | "remove", parentNumber: number, subIssueNumber: number): string {
758	const verb = action === "add" ? "Add" : "Remove";
759	let msg = `${verb} sub-issue relationship:\n\n`;
760	msg += `Parent: #${parentNumber}\n`;
761	msg += `Sub-issue: #${subIssueNumber}\n\n`;
762	msg += action === "add"
763		? "This will make the sub-issue a child of the parent issue."
764		: "This will remove the parent-child relationship between these issues.";
765	return msg;
766}
767
768export function buildCommentConfirmation(kind: string, numbers: number | number[], comment: string): string {
769	const preview = comment.length > 200 ? comment.slice(0, 197) + "..." : comment;
770	const values = Array.isArray(numbers) ? numbers : [numbers];
771	let msg = `${kind}s: ${values.map((number) => `#${number}`).join(", ")}\n\n`;
772	msg += `Comment preview:\n"${preview}"\n\n`;
773	msg += "This will post a public comment.";
774	return msg;
775}
776
777// ============================================================================
778// GraphQL helpers
779// ============================================================================
780
781/**
782 * Resolve an issue number to its GitHub node ID via GraphQL.
783 * Requires the owner/repo to be detected from the current git context.
784 */
785export async function resolveIssueNodeId(
786	pi: ExtensionAPI,
787	ctx: ExtensionContext,
788	issueNumber: number,
789	signal?: AbortSignal,
790): Promise<{ id: string; owner: string; repo: string } | null> {
791	// Get owner/repo from the current git context
792	const nwoResult = await execGh(pi, ctx, [
793		"repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner",
794	], { signal, timeout: 10000 });
795
796	if (nwoResult.code !== 0) return null;
797
798	const nwo = nwoResult.stdout.trim();
799	const [owner, repo] = nwo.split("/");
800	if (!owner || !repo) return null;
801
802	const query = `query($owner: String!, $repo: String!, $number: Int!) {
803		repository(owner: $owner, name: $repo) {
804			issue(number: $number) { id }
805		}
806	}`;
807
808	const result = await execGh(pi, ctx, [
809		"api", "graphql",
810		"-f", `query=${query}`,
811		"-f", `owner=${owner}`,
812		"-f", `repo=${repo}`,
813		"-F", `number=${issueNumber}`,
814		"--jq", ".data.repository.issue.id",
815	], { signal, timeout: 10000 });
816
817	if (result.code !== 0 || !result.stdout.trim()) return null;
818
819	return { id: result.stdout.trim(), owner, repo };
820}
821
822/**
823 * Add a sub-issue relationship between two issues via GraphQL.
824 */
825export async function addSubIssue(
826	pi: ExtensionAPI,
827	ctx: ExtensionContext,
828	parentNodeId: string,
829	subIssueNodeId: string,
830	signal?: AbortSignal,
831): Promise<{ success: boolean; error?: string }> {
832	const mutation = `mutation($parentId: ID!, $subIssueId: ID!) {
833		addSubIssue(input: { issueId: $parentId, subIssueId: $subIssueId }) {
834			issue { number }
835			subIssue { number }
836		}
837	}`;
838
839	const result = await execGh(pi, ctx, [
840		"api", "graphql",
841		"-f", `query=${mutation}`,
842		"-f", `parentId=${parentNodeId}`,
843		"-f", `subIssueId=${subIssueNodeId}`,
844	], { signal, timeout: 10000 });
845
846	if (result.code !== 0) {
847		return { success: false, error: result.stderr.trim() || "Failed to add sub-issue" };
848	}
849
850	return { success: true };
851}
852
853/**
854 * Remove a sub-issue relationship between two issues via GraphQL.
855 */
856export async function removeSubIssue(
857	pi: ExtensionAPI,
858	ctx: ExtensionContext,
859	parentNodeId: string,
860	subIssueNodeId: string,
861	signal?: AbortSignal,
862): Promise<{ success: boolean; error?: string }> {
863	const mutation = `mutation($parentId: ID!, $subIssueId: ID!) {
864		removeSubIssue(input: { issueId: $parentId, subIssueId: $subIssueId }) {
865			issue { number }
866			subIssue { number }
867		}
868	}`;
869
870	const result = await execGh(pi, ctx, [
871		"api", "graphql",
872		"-f", `query=${mutation}`,
873		"-f", `parentId=${parentNodeId}`,
874		"-f", `subIssueId=${subIssueNodeId}`,
875	], { signal, timeout: 10000 });
876
877	if (result.code !== 0) {
878		return { success: false, error: result.stderr.trim() || "Failed to remove sub-issue" };
879	}
880
881	return { success: true };
882}
883
884// ============================================================================
885// Error helpers
886// ============================================================================
887
888export function isAuthError(stderr: string): boolean {
889	const lower = stderr.toLowerCase();
890	return (
891		lower.includes("authentication") ||
892		lower.includes("unauthorized") ||
893		lower.includes("not logged in") ||
894		lower.includes("gh auth login")
895	);
896}
897
898export function isNotFoundError(stderr: string): boolean {
899	const lower = stderr.toLowerCase();
900	return lower.includes("not found") || lower.includes("could not resolve");
901}
902
903export function isRepoError(stderr: string): boolean {
904	const lower = stderr.toLowerCase();
905	return lower.includes("not a git repository") || lower.includes("no git remotes");
906}
907
908export function getErrorMessage(stderr: string, action: string): string {
909	if (isAuthError(stderr)) {
910		return "Authentication failed. Run: gh auth login";
911	}
912	if (isRepoError(stderr)) {
913		return "Not in a GitHub repository or no git remotes found.";
914	}
915	if (isNotFoundError(stderr)) {
916		return `${action}: Resource not found`;
917	}
918	return stderr.trim();
919}
920
921// ============================================================================
922// URL/number extraction
923// ============================================================================
924
925export function extractPRNumber(output: string): number | null {
926	// Match GitHub PR URL
927	const urlMatch = output.match(/\/pull\/(\d+)/);
928	if (urlMatch) return parseInt(urlMatch[1], 10);
929	// Match bare number
930	const numMatch = output.match(/#(\d+)/);
931	if (numMatch) return parseInt(numMatch[1], 10);
932	return null;
933}
934
935export function extractIssueNumber(output: string): number | null {
936	// Match GitHub issue URL
937	const urlMatch = output.match(/\/issues\/(\d+)/);
938	if (urlMatch) return parseInt(urlMatch[1], 10);
939	// Match bare number
940	const numMatch = output.match(/#(\d+)/);
941	if (numMatch) return parseInt(numMatch[1], 10);
942	return null;
943}
944
945export function extractPRUrl(output: string): string | null {
946	const match = output.match(/(https:\/\/github\.com\/[^\s]+\/pull\/\d+)/);
947	return match ? match[1] : null;
948}
949
950export function extractIssueUrl(output: string): string | null {
951	const match = output.match(/(https:\/\/github\.com\/[^\s]+\/issues\/\d+)/);
952	return match ? match[1] : null;
953}