main
  1/**
  2 * Utility functions for GitHub extension
  3 */
  4
  5import type { Theme, ExtensionAPI, ExtensionContext } from "@mariozechner/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// Approval gate helper (with mutex to prevent parallel dialog deadlocks)
515// ============================================================================
516
517export type ApprovalResult =
518	| { outcome: "accepted" }
519	| { outcome: "modify" }
520	| { outcome: "rejected" };
521
522// Mutex to serialize concurrent approval dialogs.
523// When the LLM issues multiple write tool calls in parallel, each hits
524// approvalGate(). Without serialization, multiple ctx.ui.select() dialogs
525// overlap and cause a UI deadlock (the same bug as the Jira extension).
526let approvalMutex: Promise<void> = Promise.resolve();
527
528/**
529 * Three-way approval gate: Accept / Modify / Reject.
530 *
531 * - "Accept" proceeds with execution.
532 * - "Modify" tells the LLM the user wants to iterate on the content before
533 *   submitting. The LLM should ask the user what to change.
534 * - "Reject" means the user does not want this action at all.
535 *
536 * Uses a mutex so parallel tool calls present dialogs one at a time
537 * instead of all at once (which deadlocks the UI).
538 *
539 * Returns the user's choice so the caller can build the appropriate response.
540 */
541export async function approvalGate(
542	ctx: ExtensionContext,
543	title: string,
544	description: string,
545): Promise<ApprovalResult> {
546	// Chain onto the mutex so dialogs appear sequentially
547	const result = await new Promise<ApprovalResult>((resolve) => {
548		approvalMutex = approvalMutex.then(async () => {
549			const prompt = description ? `${title}\n\n${description}` : title;
550			const choice = await ctx.ui.select(prompt, [
551				"✓ Accept",
552				"✎ Modify",
553				"✗ Reject",
554			]);
555
556			if (choice === "✓ Accept") resolve({ outcome: "accepted" });
557			else if (choice === "✎ Modify") resolve({ outcome: "modify" });
558			else resolve({ outcome: "rejected" });
559		});
560	});
561
562	return result;
563}
564
565/**
566 * Approval gate with body preview for actions that carry long text content.
567 *
568 * When `bodyText` exceeds 200 characters a fourth option —
569 * "📄 Preview body first" — is offered before the standard
570 * Accept / Modify / Reject flow.  Choosing it opens an editor pane so the
571 * user can read the full content, then re-presents the confirmation dialog.
572 *
573 * @param ctx          Extension context
574 * @param title        Confirmation dialog title
575 * @param description  Confirmation dialog description (with truncated preview)
576 * @param previewTitle Label shown at the top of the editor preview pane
577 * @param bodyText     Full body text; only previewed when length > 200
578 */
579export async function approvalGateWithBodyPreview(
580	ctx: ExtensionContext,
581	title: string,
582	description: string,
583	previewTitle: string,
584	bodyText: string,
585): Promise<ApprovalResult> {
586	if (bodyText.length <= 200) {
587		return approvalGate(ctx, title, description);
588	}
589
590	const prompt = description ? `${title}\n\n${description}` : title;
591	const choice = await ctx.ui.select(prompt, [
592		"✓ Accept",
593		"📄 Preview body first",
594		"✎ Modify",
595		"✗ Reject",
596	]);
597
598	if (choice === undefined || choice === "✗ Reject") return { outcome: "rejected" };
599	if (choice === "✎ Modify") return { outcome: "modify" };
600
601	if (choice === "📄 Preview body first") {
602		await ctx.ui.editor(`${previewTitle}\n\n---\n\n`, bodyText);
603		// Re-present the standard three-way dialog after the preview
604		return approvalGate(ctx, title, description);
605	}
606
607	// "✓ Accept"
608	return { outcome: "accepted" };
609}
610
611/**
612 * Build a tool result for when the user wants modifications.
613 * The message clearly tells the LLM to ask the user what they want changed.
614 */
615export function buildModifyResult(action: string, details: Partial<GhDetails>): any {
616	return {
617		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.` }],
618		details: { action: details.action, modifyRequested: true, ...details } as GhDetails,
619	};
620}
621
622/**
623 * Build a tool result for when the user rejects the action entirely.
624 * The message explicitly tells the LLM NOT to retry.
625 */
626export function buildRejectResult(action: string, details: Partial<GhDetails>): any {
627	return {
628		content: [{ type: "text", text: `User rejected this ${action}. Do NOT retry or attempt this action again.` }],
629		details: { action: details.action, cancelled: true, ...details } as GhDetails,
630	};
631}
632
633// ============================================================================
634// Confirmation builders
635// ============================================================================
636
637export function buildPRCreateConfirmation(params: any): string {
638	let msg = "";
639	msg += `Title: "${params.title}"\n`;
640	if (params.base) msg += `Base: ${params.base}\n`;
641	if (params.body) {
642		const preview = params.body.length > 200 ? params.body.slice(0, 197) + "..." : params.body;
643		msg += `Body: ${preview}\n`;
644	}
645	if (params.draft) msg += `Draft: yes\n`;
646	if (params.labels?.length) msg += `Labels: ${params.labels.join(", ")}\n`;
647	if (params.reviewers?.length) msg += `Reviewers: ${params.reviewers.join(", ")}\n`;
648	msg += "\nThis will create a new pull request on GitHub.";
649	return msg;
650}
651
652export function buildPRMergeConfirmation(params: any): string {
653	let msg = `PR: #${params.number}\n`;
654	msg += `Method: ${params.method || "merge"}\n`;
655	if (params.deleteBranch) msg += `Delete branch: yes\n`;
656	msg += "\nThis will merge the pull request.";
657	return msg;
658}
659
660export function buildReviewConfirmation(params: any): string {
661	let msg = `PR: #${params.number}\n`;
662	msg += `Action: ${params.reviewAction}\n`;
663	if (params.body) {
664		const preview = params.body.length > 200 ? params.body.slice(0, 197) + "..." : params.body;
665		msg += `Comment: ${preview}\n`;
666	} else {
667		msg += `Comment: (none)\n`;
668	}
669	msg += "\nThis will submit a review on the pull request.";
670	return msg;
671}
672
673export function buildIssueCreateConfirmation(params: any): string {
674	let msg = "";
675	msg += `Title: "${params.title}"\n`;
676	if (params.body) {
677		const preview = params.body.length > 200 ? params.body.slice(0, 197) + "..." : params.body;
678		msg += `Body: ${preview}\n`;
679	}
680	if (params.labels?.length) msg += `Labels: ${params.labels.join(", ")}\n`;
681	if (params.assignees?.length) msg += `Assignees: ${params.assignees.join(", ")}\n`;
682	msg += "\nThis will create a new issue on GitHub.";
683	return msg;
684}
685
686export function buildLineCommentConfirmation(number: number, path: string, line: number, body: string, startLine?: number): string {
687	const preview = body.length > 200 ? body.slice(0, 197) + "..." : body;
688	const range = startLine ? `lines ${startLine}-${line}` : `line ${line}`;
689	let msg = `PR: #${number}\n`;
690	msg += `File: ${path}:${range}\n\n`;
691	msg += `Comment:\n"${preview}"\n\n`;
692	msg += "This will post an inline comment on the PR diff.";
693	return msg;
694}
695
696export function buildReviewWithCommentsConfirmation(number: number, reviewAction: string, body: string | undefined, commentsCount: number): string {
697	let msg = `PR: #${number}\n`;
698	msg += `Action: ${reviewAction}\n`;
699	msg += `Inline comments: ${commentsCount}\n`;
700	if (body) {
701		const preview = body.length > 200 ? body.slice(0, 197) + "..." : body;
702		msg += `Review body: ${preview}\n`;
703	}
704	msg += "\nThis will submit a review with inline comments on the PR.";
705	return msg;
706}
707
708export function buildReviewEditConfirmation(number: number, reviewId: number, body: string): string {
709	const preview = body.length > 200 ? body.slice(0, 197) + "..." : body;
710	let msg = `PR: #${number}\n`;
711	msg += `Review ID: ${reviewId}\n\n`;
712	msg += `New body:\n"${preview}"\n\n`;
713	msg += "This will update the review body on the PR.";
714	return msg;
715}
716
717export function buildReviewCommentEditConfirmation(commentId: number, body: string): string {
718	const preview = body.length > 200 ? body.slice(0, 197) + "..." : body;
719	let msg = `Comment ID: ${commentId}\n\n`;
720	msg += `New body:\n"${preview}"\n\n`;
721	msg += "This will update the inline review comment.";
722	return msg;
723}
724
725export function buildReviewCommentDeleteConfirmation(commentId: number): string {
726	let msg = `Comment ID: ${commentId}\n\n`;
727	msg += "This will permanently delete the inline review comment.";
728	return msg;
729}
730
731export function buildSubIssueConfirmation(action: "add" | "remove", parentNumber: number, subIssueNumber: number): string {
732	const verb = action === "add" ? "Add" : "Remove";
733	let msg = `${verb} sub-issue relationship:\n\n`;
734	msg += `Parent: #${parentNumber}\n`;
735	msg += `Sub-issue: #${subIssueNumber}\n\n`;
736	msg += action === "add"
737		? "This will make the sub-issue a child of the parent issue."
738		: "This will remove the parent-child relationship between these issues.";
739	return msg;
740}
741
742export function buildCommentConfirmation(kind: string, number: number, comment: string): string {
743	const preview = comment.length > 200 ? comment.slice(0, 197) + "..." : comment;
744	let msg = `${kind}: #${number}\n\n`;
745	msg += `Comment preview:\n"${preview}"\n\n`;
746	msg += "This will post a public comment.";
747	return msg;
748}
749
750// ============================================================================
751// GraphQL helpers
752// ============================================================================
753
754/**
755 * Resolve an issue number to its GitHub node ID via GraphQL.
756 * Requires the owner/repo to be detected from the current git context.
757 */
758export async function resolveIssueNodeId(
759	pi: ExtensionAPI,
760	ctx: ExtensionContext,
761	issueNumber: number,
762	signal?: AbortSignal,
763): Promise<{ id: string; owner: string; repo: string } | null> {
764	// Get owner/repo from the current git context
765	const nwoResult = await execGh(pi, ctx, [
766		"repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner",
767	], { signal, timeout: 10000 });
768
769	if (nwoResult.code !== 0) return null;
770
771	const nwo = nwoResult.stdout.trim();
772	const [owner, repo] = nwo.split("/");
773	if (!owner || !repo) return null;
774
775	const query = `query($owner: String!, $repo: String!, $number: Int!) {
776		repository(owner: $owner, name: $repo) {
777			issue(number: $number) { id }
778		}
779	}`;
780
781	const result = await execGh(pi, ctx, [
782		"api", "graphql",
783		"-f", `query=${query}`,
784		"-f", `owner=${owner}`,
785		"-f", `repo=${repo}`,
786		"-F", `number=${issueNumber}`,
787		"--jq", ".data.repository.issue.id",
788	], { signal, timeout: 10000 });
789
790	if (result.code !== 0 || !result.stdout.trim()) return null;
791
792	return { id: result.stdout.trim(), owner, repo };
793}
794
795/**
796 * Add a sub-issue relationship between two issues via GraphQL.
797 */
798export async function addSubIssue(
799	pi: ExtensionAPI,
800	ctx: ExtensionContext,
801	parentNodeId: string,
802	subIssueNodeId: string,
803	signal?: AbortSignal,
804): Promise<{ success: boolean; error?: string }> {
805	const mutation = `mutation($parentId: ID!, $subIssueId: ID!) {
806		addSubIssue(input: { issueId: $parentId, subIssueId: $subIssueId }) {
807			issue { number }
808			subIssue { number }
809		}
810	}`;
811
812	const result = await execGh(pi, ctx, [
813		"api", "graphql",
814		"-f", `query=${mutation}`,
815		"-f", `parentId=${parentNodeId}`,
816		"-f", `subIssueId=${subIssueNodeId}`,
817	], { signal, timeout: 10000 });
818
819	if (result.code !== 0) {
820		return { success: false, error: result.stderr.trim() || "Failed to add sub-issue" };
821	}
822
823	return { success: true };
824}
825
826/**
827 * Remove a sub-issue relationship between two issues via GraphQL.
828 */
829export async function removeSubIssue(
830	pi: ExtensionAPI,
831	ctx: ExtensionContext,
832	parentNodeId: string,
833	subIssueNodeId: string,
834	signal?: AbortSignal,
835): Promise<{ success: boolean; error?: string }> {
836	const mutation = `mutation($parentId: ID!, $subIssueId: ID!) {
837		removeSubIssue(input: { issueId: $parentId, subIssueId: $subIssueId }) {
838			issue { number }
839			subIssue { number }
840		}
841	}`;
842
843	const result = await execGh(pi, ctx, [
844		"api", "graphql",
845		"-f", `query=${mutation}`,
846		"-f", `parentId=${parentNodeId}`,
847		"-f", `subIssueId=${subIssueNodeId}`,
848	], { signal, timeout: 10000 });
849
850	if (result.code !== 0) {
851		return { success: false, error: result.stderr.trim() || "Failed to remove sub-issue" };
852	}
853
854	return { success: true };
855}
856
857// ============================================================================
858// Error helpers
859// ============================================================================
860
861export function isAuthError(stderr: string): boolean {
862	const lower = stderr.toLowerCase();
863	return (
864		lower.includes("authentication") ||
865		lower.includes("unauthorized") ||
866		lower.includes("not logged in") ||
867		lower.includes("gh auth login")
868	);
869}
870
871export function isNotFoundError(stderr: string): boolean {
872	const lower = stderr.toLowerCase();
873	return lower.includes("not found") || lower.includes("could not resolve");
874}
875
876export function isRepoError(stderr: string): boolean {
877	const lower = stderr.toLowerCase();
878	return lower.includes("not a git repository") || lower.includes("no git remotes");
879}
880
881export function getErrorMessage(stderr: string, action: string): string {
882	if (isAuthError(stderr)) {
883		return "Authentication failed. Run: gh auth login";
884	}
885	if (isRepoError(stderr)) {
886		return "Not in a GitHub repository or no git remotes found.";
887	}
888	if (isNotFoundError(stderr)) {
889		return `${action}: Resource not found`;
890	}
891	return stderr.trim();
892}
893
894// ============================================================================
895// URL/number extraction
896// ============================================================================
897
898export function extractPRNumber(output: string): number | null {
899	// Match GitHub PR URL
900	const urlMatch = output.match(/\/pull\/(\d+)/);
901	if (urlMatch) return parseInt(urlMatch[1], 10);
902	// Match bare number
903	const numMatch = output.match(/#(\d+)/);
904	if (numMatch) return parseInt(numMatch[1], 10);
905	return null;
906}
907
908export function extractIssueNumber(output: string): number | null {
909	// Match GitHub issue URL
910	const urlMatch = output.match(/\/issues\/(\d+)/);
911	if (urlMatch) return parseInt(urlMatch[1], 10);
912	// Match bare number
913	const numMatch = output.match(/#(\d+)/);
914	if (numMatch) return parseInt(numMatch[1], 10);
915	return null;
916}
917
918export function extractPRUrl(output: string): string | null {
919	const match = output.match(/(https:\/\/github\.com\/[^\s]+\/pull\/\d+)/);
920	return match ? match[1] : null;
921}
922
923export function extractIssueUrl(output: string): string | null {
924	const match = output.match(/(https:\/\/github\.com\/[^\s]+\/issues\/\d+)/);
925	return match ? match[1] : null;
926}