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}