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}