flake-update-20260505
1/**
2 * Pi Extension: GitHub Management
3 *
4 * Provides GitHub integration via the gh CLI with:
5 * - Read operations: PR list/view/diff, issue list/view, checks, runs, repo, releases
6 * - Write operations (with approval): PR create/merge/review/comment/close/ready,
7 * issue create/close/comment/edit, checks restart
8 * - Custom rendering for PRs, issues, checks
9 * - Slash commands for instant results
10 * - Auto-detection of GitHub PR/issue URLs
11 *
12 * Requirements:
13 * - gh CLI: https://cli.github.com/
14 * - Authenticated: gh auth login
15 */
16
17import type { ExtensionAPI, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
18import {
19 Text,
20 type AutocompleteItem,
21 type AutocompleteProvider,
22 type AutocompleteSuggestions,
23 fuzzyFilter,
24} from "@mariozechner/pi-tui";
25import { Type } from "@sinclair/typebox";
26import { StringEnum } from "@mariozechner/pi-ai";
27
28import type { GhDetails } from "./types";
29import {
30 handlePRList,
31 handlePRView,
32 handlePRDiff,
33 handlePRCreate,
34 handlePRCheckout,
35 handlePRMerge,
36 handlePRReview,
37 handlePRComment,
38 handlePRReady,
39 handlePRClose,
40 handlePRLineComment,
41 handlePRReviewWithComments,
42 handlePRReviewsList,
43 handlePRReviewEdit,
44 handlePRReviewCommentsList,
45 handlePRReviewCommentEdit,
46 handlePRReviewCommentDelete,
47} from "./actions/pr";
48import {
49 handleChecks,
50 handleChecksLog,
51 handleChecksRestart,
52 handleRunList,
53 handleRunView,
54} from "./actions/checks";
55import {
56 handleIssueList,
57 handleIssueView,
58 handleIssueCreate,
59 handleIssueClose,
60 handleIssueComment,
61 handleIssueEdit,
62 handleIssueAddSubIssue,
63 handleIssueRemoveSubIssue,
64} from "./actions/issue";
65import { handleRepoView, handleReleaseList } from "./actions/repo";
66import {
67 parsePRList,
68 parseIssueList,
69 parseChecks,
70 truncate,
71 getPRStateIcon,
72 getCheckIcon,
73 getRunStatusIcon,
74 getReviewDecisionText,
75 formatRelativeDate,
76 resetGitRoot,
77 execGh,
78} from "./utils";
79
80export default function (pi: ExtensionAPI) {
81 // ========================================================================
82 // State Management
83 // ========================================================================
84
85 let currentUser = "";
86 let recentPRs: { number: number; title: string }[] = [];
87 let recentIssues: { number: number; title: string }[] = [];
88
89 const reconstructState = (ctx: ExtensionContext) => {
90 currentUser = "";
91 recentPRs = [];
92 recentIssues = [];
93 resetGitRoot(); // Reset git root detection on session change
94
95 for (const entry of ctx.sessionManager.getBranch()) {
96 if (entry.type !== "message") continue;
97 const msg = entry.message;
98 if (msg.role !== "toolResult" || msg.toolName !== "github") continue;
99
100 const details = msg.details as GhDetails | undefined;
101 if (!details) continue;
102
103 // Track recent PR numbers
104 if (details.prNumber && !recentPRs.find((p) => p.number === details.prNumber)) {
105 recentPRs.push({ number: details.prNumber, title: "" });
106 }
107 if (details.prNumbers) {
108 for (const n of details.prNumbers) {
109 if (!recentPRs.find((p) => p.number === n)) {
110 recentPRs.push({ number: n, title: "" });
111 }
112 }
113 }
114
115 // Track recent issue numbers
116 if (details.issueNumber && !recentIssues.find((i) => i.number === details.issueNumber)) {
117 recentIssues.push({ number: details.issueNumber, title: "" });
118 }
119 if (details.issueNumbers) {
120 for (const n of details.issueNumbers) {
121 if (!recentIssues.find((i) => i.number === n)) {
122 recentIssues.push({ number: n, title: "" });
123 }
124 }
125 }
126 }
127
128 // Keep only last 20
129 if (recentPRs.length > 20) recentPRs = recentPRs.slice(-20);
130 if (recentIssues.length > 20) recentIssues = recentIssues.slice(-20);
131 };
132
133 pi.on("session_start", async (_event, ctx) => {
134 reconstructState(ctx);
135 setupIssueAutocomplete(pi, ctx);
136 });
137 pi.on("session_switch", async (_event, ctx) => reconstructState(ctx));
138 pi.on("session_fork", async (_event, ctx) => reconstructState(ctx));
139 pi.on("session_tree", async (_event, ctx) => reconstructState(ctx));
140
141 // Helper: fetch current user lazily
142 async function ensureCurrentUser(ctx: ExtensionContext, signal?: AbortSignal): Promise<string> {
143 if (currentUser) return currentUser;
144 const result = await execGh(pi, ctx, ["api", "user", "--jq", ".login"], { signal, timeout: 10000 });
145 if (result.code === 0) {
146 currentUser = result.stdout.trim();
147 }
148 return currentUser;
149 }
150
151 // ========================================================================
152 // Tool Registration
153 // ========================================================================
154
155 pi.registerTool({
156 name: "github",
157 label: "GitHub",
158 description:
159 "Manage GitHub PRs, issues, checks, and runs via gh CLI. " +
160 "Write operations require user approval. " +
161 "IMPORTANT: Call write operations (pr-create, pr-merge, pr-review, pr-comment, pr-close, pr-ready, pr-line-comment, pr-review-comments, issue-create, issue-close, issue-comment, issue-edit, checks-restart) ONE AT A TIME, never in parallel — parallel approval dialogs deadlock the UI. " +
162 "checks-log accepts runId or number (PR) — PR auto-selects first failed run. " +
163 "pr-review-comments submits a review with inline comments. " +
164 "issue-create with parent auto-links as sub-issue.",
165
166 parameters: Type.Object({
167 action: StringEnum([
168 "pr-list",
169 "pr-view",
170 "pr-diff",
171 "pr-create",
172 "pr-checkout",
173 "pr-merge",
174 "pr-review",
175 "pr-comment",
176 "pr-ready",
177 "pr-line-comment",
178 "pr-review-comments",
179 "pr-reviews-list",
180 "pr-review-edit",
181 "pr-review-comments-list",
182 "pr-review-comment-edit",
183 "pr-review-comment-delete",
184 "pr-close",
185 "checks",
186 "checks-log",
187 "checks-restart",
188 "run-list",
189 "run-view",
190 "issue-list",
191 "issue-view",
192 "issue-create",
193 "issue-close",
194 "issue-comment",
195 "issue-edit",
196 "issue-add-sub-issue",
197 "issue-remove-sub-issue",
198 "repo-view",
199 "release-list",
200 ] as const),
201
202 // PR/Issue number
203 number: Type.Optional(Type.Number({ description: "PR or issue number" })),
204
205 // PR list filters
206 state: Type.Optional(Type.String({ description: "Filter by state: open, closed, merged, all" })),
207 author: Type.Optional(Type.String({ description: "Filter by author (username or 'me')" })),
208 label: Type.Optional(Type.String({ description: "Filter by label" })),
209 base: Type.Optional(Type.String({ description: "Filter PRs by base branch" })),
210 limit: Type.Optional(Type.Number({ description: "Max results (default 20)" })),
211
212 // PR create
213 title: Type.Optional(Type.String({ description: "PR or issue title" })),
214 body: Type.Optional(Type.String({ description: "PR/issue body or comment text" })),
215 head: Type.Optional(Type.String({ description: "Head branch for PR (owner:branch). Overrides auto-detection from cwd." })),
216 draft: Type.Optional(Type.Boolean({ description: "Create as draft PR" })),
217 reviewers: Type.Optional(Type.Array(Type.String(), { description: "PR reviewers to request" })),
218 labels: Type.Optional(Type.Array(Type.String(), { description: "Labels to add" })),
219
220 // PR merge
221 method: Type.Optional(Type.String({ description: "Merge method: merge, squash, rebase" })),
222 deleteBranch: Type.Optional(Type.Boolean({ description: "Delete branch after merge" })),
223
224 // Review
225 reviewAction: Type.Optional(Type.String({ description: "Review action: approve, request-changes, comment" })),
226 reviewId: Type.Optional(Type.Number({ description: "Review ID (from pr-reviews-list, for pr-review-edit)" })),
227 commentId: Type.Optional(Type.Number({ description: "Review comment ID (from pr-review-comments-list, for edit/delete)" })),
228
229 // Line comments (pr-line-comment, pr-review-comments)
230 path: Type.Optional(Type.String({ description: "File path in the diff for inline comment" })),
231 line: Type.Optional(Type.Number({ description: "Line number in the diff for inline comment" })),
232 side: Type.Optional(Type.String({ description: "Diff side: RIGHT (additions, default) or LEFT (deletions)" })),
233 startLine: Type.Optional(Type.Number({ description: "Start line for multi-line comment range" })),
234 startSide: Type.Optional(Type.String({ description: "Diff side for start line" })),
235 comments: Type.Optional(Type.Array(
236 Type.Object({
237 path: Type.String({ description: "File path" }),
238 body: Type.String({ description: "Comment text" }),
239 line: Type.Number({ description: "Line number" }),
240 side: Type.Optional(Type.String({ description: "RIGHT or LEFT" })),
241 startLine: Type.Optional(Type.Number({ description: "Start line for range" })),
242 startSide: Type.Optional(Type.String({ description: "Start side for range" })),
243 }),
244 { description: "Array of inline comments for pr-review-comments action" },
245 )),
246
247 // Checks/Runs
248 runId: Type.Optional(Type.Number({ description: "Workflow run ID" })),
249 failedOnly: Type.Optional(Type.Boolean({ description: "Restart only failed jobs (default true)" })),
250 branch: Type.Optional(Type.String({ description: "Filter runs by branch" })),
251 status: Type.Optional(Type.String({ description: "Filter runs by status" })),
252 workflow: Type.Optional(Type.String({ description: "Filter runs by workflow name" })),
253
254 // Issue filters
255 assignee: Type.Optional(Type.String({ description: "Filter issues by assignee (or 'me')" })),
256 milestone: Type.Optional(Type.String({ description: "Filter issues by milestone" })),
257
258 // Issue edit
259 addLabels: Type.Optional(Type.Array(Type.String(), { description: "Labels to add" })),
260 removeLabels: Type.Optional(Type.Array(Type.String(), { description: "Labels to remove" })),
261 addAssignees: Type.Optional(Type.Array(Type.String(), { description: "Assignees to add" })),
262 removeAssignees: Type.Optional(Type.Array(Type.String(), { description: "Assignees to remove" })),
263
264 // Issue close
265 reason: Type.Optional(Type.String({ description: "Close reason: completed, not planned" })),
266
267 // Sub-issues
268 parent: Type.Optional(Type.Number({ description: "Parent issue number (for issue-create, links created issue as sub-issue)" })),
269 subIssueNumber: Type.Optional(Type.Number({ description: "Sub-issue number (for issue-add-sub-issue, issue-remove-sub-issue)" })),
270 }),
271
272 async execute(toolCallId, params, signal, onUpdate, ctx) {
273 try {
274 switch (params.action) {
275 // PR actions
276 case "pr-list":
277 return await handlePRList(pi, params, signal, onUpdate, ctx, currentUser);
278 case "pr-view":
279 return await handlePRView(pi, params, signal, onUpdate, ctx);
280 case "pr-diff":
281 return await handlePRDiff(pi, params, signal, onUpdate, ctx);
282 case "pr-create":
283 return await handlePRCreate(pi, params, signal, onUpdate, ctx);
284 case "pr-checkout":
285 return await handlePRCheckout(pi, params, signal, onUpdate, ctx);
286 case "pr-merge":
287 return await handlePRMerge(pi, params, signal, onUpdate, ctx);
288 case "pr-review":
289 return await handlePRReview(pi, params, signal, onUpdate, ctx);
290 case "pr-comment":
291 return await handlePRComment(pi, params, signal, onUpdate, ctx);
292 case "pr-ready":
293 return await handlePRReady(pi, params, signal, onUpdate, ctx);
294 case "pr-line-comment":
295 return await handlePRLineComment(pi, params, signal, onUpdate, ctx);
296 case "pr-review-comments":
297 return await handlePRReviewWithComments(pi, params, signal, onUpdate, ctx);
298 case "pr-reviews-list":
299 return await handlePRReviewsList(pi, params, signal, onUpdate, ctx);
300 case "pr-review-edit":
301 return await handlePRReviewEdit(pi, params, signal, onUpdate, ctx);
302 case "pr-review-comments-list":
303 return await handlePRReviewCommentsList(pi, params, signal, onUpdate, ctx);
304 case "pr-review-comment-edit":
305 return await handlePRReviewCommentEdit(pi, params, signal, onUpdate, ctx);
306 case "pr-review-comment-delete":
307 return await handlePRReviewCommentDelete(pi, params, signal, onUpdate, ctx);
308 case "pr-close":
309 return await handlePRClose(pi, params, signal, onUpdate, ctx);
310
311 // Check/Run actions
312 case "checks":
313 return await handleChecks(pi, params, signal, onUpdate, ctx);
314 case "checks-log":
315 return await handleChecksLog(pi, params, signal, onUpdate, ctx);
316 case "checks-restart":
317 return await handleChecksRestart(pi, params, signal, onUpdate, ctx);
318 case "run-list":
319 return await handleRunList(pi, params, signal, onUpdate, ctx);
320 case "run-view":
321 return await handleRunView(pi, params, signal, onUpdate, ctx);
322
323 // Issue actions
324 case "issue-list":
325 return await handleIssueList(pi, params, signal, onUpdate, ctx, currentUser);
326 case "issue-view":
327 return await handleIssueView(pi, params, signal, onUpdate, ctx);
328 case "issue-create":
329 return await handleIssueCreate(pi, params, signal, onUpdate, ctx);
330 case "issue-close":
331 return await handleIssueClose(pi, params, signal, onUpdate, ctx);
332 case "issue-comment":
333 return await handleIssueComment(pi, params, signal, onUpdate, ctx);
334 case "issue-edit":
335 return await handleIssueEdit(pi, params, signal, onUpdate, ctx);
336 case "issue-add-sub-issue":
337 return await handleIssueAddSubIssue(pi, params, signal, onUpdate, ctx);
338 case "issue-remove-sub-issue":
339 return await handleIssueRemoveSubIssue(pi, params, signal, onUpdate, ctx);
340
341 // Repo actions
342 case "repo-view":
343 return await handleRepoView(pi, params, signal, onUpdate, ctx);
344 case "release-list":
345 return await handleReleaseList(pi, params, signal, onUpdate, ctx);
346
347 default:
348 return {
349 content: [{ type: "text", text: `Unknown action: ${params.action}` }],
350 details: { action: params.action, error: "unknown_action" } as GhDetails,
351 isError: true,
352 };
353 }
354 } catch (error) {
355 return {
356 content: [{ type: "text", text: `Error: ${error instanceof Error ? error.message : String(error)}` }],
357 details: { action: params.action, error: String(error) } as GhDetails,
358 isError: true,
359 };
360 }
361 },
362
363 // ====================================================================
364 // Custom Rendering
365 // ====================================================================
366
367 renderCall(args, theme) {
368 let text = theme.fg("toolTitle", theme.bold("github "));
369 text += theme.fg("muted", args.action);
370
371 if (args.number) {
372 text += " " + theme.fg("accent", `#${args.number}`);
373 }
374 if (args.runId) {
375 text += " " + theme.fg("accent", String(args.runId));
376 }
377 if (args.title) {
378 text += " " + theme.fg("dim", `"${truncate(args.title, 50)}"`);
379 }
380
381 return new Text(text, 0, 0);
382 },
383
384 renderResult(result, { expanded }, theme) {
385 const details = result.details as GhDetails | undefined;
386
387 if (!details) {
388 const text = result.content[0];
389 return new Text(text?.type === "text" ? text.text : "", 0, 0);
390 }
391
392 if (details.error) {
393 return new Text(theme.fg("error", `✗ Error: ${details.error}`), 0, 0);
394 }
395
396 if (details.modifyRequested) {
397 return new Text(theme.fg("warning", "✎ User requested modifications"), 0, 0);
398 }
399
400 if (details.cancelled) {
401 return new Text(theme.fg("error", "✗ Rejected by user"), 0, 0);
402 }
403
404 switch (details.action) {
405 case "pr-list":
406 return renderPRList(details, expanded, theme);
407 case "pr-view":
408 return renderLongOutput(details, expanded, theme, "PR");
409 case "pr-diff":
410 return renderDiff(details, expanded, theme);
411 case "pr-create":
412 return renderCreated(details, theme, "PR", details.prNumber, details.prUrl);
413 case "pr-checkout":
414 return new Text(theme.fg("success", `✓ Checked out PR #${details.prNumber}`), 0, 0);
415 case "pr-merge":
416 return new Text(
417 theme.fg("success", "✓ Merged ") +
418 theme.fg("accent", `#${details.prNumber}`) +
419 theme.fg("muted", ` (${details.mergeMethod || "merge"})`),
420 0,
421 0,
422 );
423 case "pr-review":
424 return new Text(
425 theme.fg("success", `✓ ${details.reviewAction} `) +
426 theme.fg("accent", `#${details.prNumber}`),
427 0,
428 0,
429 );
430 case "pr-comment":
431 return new Text(theme.fg("success", "✓ Comment added to ") + theme.fg("accent", `#${details.prNumber}`), 0, 0);
432 case "pr-ready":
433 return new Text(theme.fg("success", "✓ PR ready for review: ") + theme.fg("accent", `#${details.prNumber}`), 0, 0);
434 case "pr-line-comment":
435 return new Text(
436 theme.fg("success", "✓ Inline comment on ") +
437 theme.fg("accent", `#${details.prNumber}`) +
438 theme.fg("muted", ` (${details.output || ""})`),
439 0,
440 0,
441 );
442 case "pr-review-comments":
443 return new Text(
444 theme.fg("success", `✓ ${details.reviewAction} `) +
445 theme.fg("accent", `#${details.prNumber}`) +
446 theme.fg("muted", ` (${details.commentCount} inline comment${details.commentCount === 1 ? "" : "s"})`),
447 0,
448 0,
449 );
450 case "pr-reviews-list":
451 return new Text(
452 theme.fg("success", "Reviews ") +
453 theme.fg("accent", `#${details.prNumber}`) +
454 theme.fg("muted", ` (${details.output})`),
455 0,
456 0,
457 );
458 case "pr-review-edit":
459 return new Text(
460 theme.fg("success", "✓ Edited review ") +
461 theme.fg("accent", `${details.reviewId}`) +
462 theme.fg("muted", ` on #${details.prNumber}`),
463 0,
464 0,
465 );
466 case "pr-review-comments-list":
467 return new Text(
468 theme.fg("success", "Review comments ") +
469 theme.fg("accent", `#${details.prNumber}`) +
470 theme.fg("muted", ` (${details.output})`),
471 0,
472 0,
473 );
474 case "pr-review-comment-edit":
475 return new Text(
476 theme.fg("success", "✓ Edited comment ") +
477 theme.fg("accent", `${details.commentId}`),
478 0,
479 0,
480 );
481 case "pr-review-comment-delete":
482 return new Text(
483 theme.fg("error", "✗ Deleted comment ") +
484 theme.fg("accent", `${details.commentId}`),
485 0,
486 0,
487 );
488 case "pr-close":
489 return new Text(theme.fg("success", "✓ Closed ") + theme.fg("accent", `#${details.prNumber}`), 0, 0);
490
491 case "checks":
492 return renderChecks(details, expanded, theme);
493 case "checks-log":
494 return renderLongOutput(details, expanded, theme, "Logs");
495 case "checks-restart":
496 return new Text(theme.fg("success", `✓ Restarted run ${details.runId}`), 0, 0);
497 case "run-list":
498 return renderRunList(details, expanded, theme);
499 case "run-view":
500 return renderLongOutput(details, expanded, theme, "Run");
501
502 case "issue-list":
503 return renderIssueList(details, expanded, theme);
504 case "issue-view":
505 return renderLongOutput(details, expanded, theme, "Issue");
506 case "issue-create":
507 return renderCreated(details, theme, "Issue", details.issueNumber, details.issueUrl, details.parentNumber);
508 case "issue-close":
509 return new Text(theme.fg("success", "✓ Closed issue ") + theme.fg("accent", `#${details.issueNumber}`), 0, 0);
510 case "issue-comment":
511 return new Text(theme.fg("success", "✓ Comment added to issue ") + theme.fg("accent", `#${details.issueNumber}`), 0, 0);
512 case "issue-edit":
513 return new Text(theme.fg("success", "✓ Updated issue ") + theme.fg("accent", `#${details.issueNumber}`), 0, 0);
514 case "issue-add-sub-issue":
515 return new Text(
516 theme.fg("success", "✓ Added ") +
517 theme.fg("accent", `#${details.subIssueNumber}`) +
518 theme.fg("success", " as sub-issue of ") +
519 theme.fg("accent", `#${details.parentNumber}`),
520 0,
521 0,
522 );
523 case "issue-remove-sub-issue":
524 return new Text(
525 theme.fg("success", "✓ Removed ") +
526 theme.fg("accent", `#${details.subIssueNumber}`) +
527 theme.fg("success", " as sub-issue of ") +
528 theme.fg("accent", `#${details.parentNumber}`),
529 0,
530 0,
531 );
532
533 case "repo-view":
534 case "release-list":
535 return renderLongOutput(details, expanded, theme, "");
536
537 default:
538 return new Text(details.output || "", 0, 0);
539 }
540 },
541 });
542
543 // ========================================================================
544 // Slash Commands
545 // ========================================================================
546
547 // /gh - Show my open PRs
548 pi.registerCommand("gh", {
549 description: "Show my open PRs in this repo",
550 handler: async (_args, ctx) => {
551 if (!ctx.hasUI) {
552 ctx.ui.notify("/gh requires interactive mode", "error");
553 return;
554 }
555
556 const user = await ensureCurrentUser(ctx);
557 const result = await execGh(
558 pi,
559 ctx,
560 [
561 "pr",
562 "list",
563 "--author",
564 user || "@me",
565 "--state",
566 "open",
567 "--json",
568 "number,title,state,headRefName,baseRefName,isDraft,reviewDecision,additions,deletions,url",
569 ],
570 { timeout: 30000 },
571 );
572
573 if (result.code !== 0) {
574 ctx.ui.notify(`Error: ${result.stderr}`, "error");
575 return;
576 }
577
578 const prs = parsePRList(result.stdout);
579
580 // Track
581 for (const pr of prs) {
582 if (!recentPRs.find((p) => p.number === pr.number)) {
583 recentPRs.push({ number: pr.number, title: pr.title });
584 }
585 }
586 if (recentPRs.length > 20) recentPRs.splice(0, recentPRs.length - 20);
587
588 const lines: string[] = [];
589 lines.push("## My Open PRs");
590 lines.push("");
591
592 if (prs.length === 0) {
593 lines.push("*No open PRs* ✨");
594 } else {
595 lines.push("| # | Title | Branch | Review | Changes |");
596 lines.push("|---|-------|--------|--------|---------|");
597 for (const pr of prs) {
598 const draft = pr.isDraft ? " 📝" : "";
599 const review = getReviewDecisionText(pr.reviewDecision);
600 const changes = `+${pr.additions}/-${pr.deletions}`;
601 lines.push(`| #${pr.number}${draft} | ${truncate(pr.title, 50)} | ${pr.branch} → ${pr.base} | ${review} | ${changes} |`);
602 }
603 }
604
605 pi.sendMessage({
606 customType: "gh-prs",
607 content: lines.join("\n"),
608 display: true,
609 });
610 },
611 });
612
613 // /gh-prs - Show all open PRs
614 pi.registerCommand("gh-prs", {
615 description: "Show all open PRs in this repo",
616 handler: async (_args, ctx) => {
617 if (!ctx.hasUI) {
618 ctx.ui.notify("/gh-prs requires interactive mode", "error");
619 return;
620 }
621
622 const result = await execGh(
623 pi,
624 ctx,
625 [
626 "pr",
627 "list",
628 "--state",
629 "open",
630 "--json",
631 "number,title,state,author,headRefName,baseRefName,isDraft,labels,reviewDecision,additions,deletions,url",
632 "--limit",
633 "20",
634 ],
635 { timeout: 30000 },
636 );
637
638 if (result.code !== 0) {
639 ctx.ui.notify(`Error: ${result.stderr}`, "error");
640 return;
641 }
642
643 const prs = parsePRList(result.stdout);
644
645 // Track
646 for (const pr of prs) {
647 if (!recentPRs.find((p) => p.number === pr.number)) {
648 recentPRs.push({ number: pr.number, title: pr.title });
649 }
650 }
651 if (recentPRs.length > 20) recentPRs.splice(0, recentPRs.length - 20);
652
653 const lines: string[] = [];
654 lines.push("## Open Pull Requests");
655 lines.push("");
656
657 if (prs.length === 0) {
658 lines.push("*No open PRs* ✨");
659 } else {
660 lines.push("| # | Title | Author | Branch | Review | Changes |");
661 lines.push("|---|-------|--------|--------|--------|---------|");
662 for (const pr of prs) {
663 const draft = pr.isDraft ? " 📝" : "";
664 const review = getReviewDecisionText(pr.reviewDecision);
665 const changes = `+${pr.additions}/-${pr.deletions}`;
666 lines.push(`| #${pr.number}${draft} | ${truncate(pr.title, 40)} | @${pr.author} | ${pr.branch} → ${pr.base} | ${review} | ${changes} |`);
667 }
668 }
669
670 pi.sendMessage({
671 customType: "gh-prs",
672 content: lines.join("\n"),
673 display: true,
674 });
675 },
676 });
677
678 // /gh-pr <number> - View specific PR
679 pi.registerCommand("gh-pr", {
680 description: "View a PR (e.g., /gh-pr 123)",
681 getArgumentCompletions: (prefix: string) => {
682 if (recentPRs.length === 0) return null;
683 const items = recentPRs.map((p) => ({
684 value: String(p.number),
685 label: `#${p.number}${p.title ? ` - ${truncate(p.title, 50)}` : ""}`,
686 }));
687 if (!prefix.trim()) return items;
688 const filtered = items.filter((i) => i.value.startsWith(prefix.trim()));
689 return filtered.length > 0 ? filtered : null;
690 },
691 handler: async (args, ctx) => {
692 if (!args?.trim()) {
693 ctx.ui.notify("Usage: /gh-pr <number>", "error");
694 return;
695 }
696
697 const number = parseInt(args.trim(), 10);
698 if (isNaN(number)) {
699 ctx.ui.notify(`Invalid PR number: ${args}`, "error");
700 return;
701 }
702
703 const result = await execGh(
704 pi,
705 ctx,
706 ["pr", "view", String(number), "--json", "number,title,state,author,headRefName,baseRefName,isDraft,url,reviewDecision,additions,deletions,changedFiles,body,statusCheckRollup"],
707 { timeout: 30000 },
708 );
709
710 if (result.code !== 0) {
711 ctx.ui.notify(`Error: ${result.stderr}`, "error");
712 return;
713 }
714
715 let data: any;
716 try {
717 data = JSON.parse(result.stdout);
718 } catch {
719 ctx.ui.notify("Could not parse PR data", "error");
720 return;
721 }
722
723 // Track
724 if (!recentPRs.find((p) => p.number === data.number)) {
725 recentPRs.push({ number: data.number, title: data.title });
726 }
727
728 const checks = data.statusCheckRollup ?? [];
729 const passed = checks.filter((c: any) => c.conclusion === "SUCCESS").length;
730 const failed = checks.filter((c: any) => c.conclusion === "FAILURE").length;
731 const pending = checks.filter((c: any) => !c.conclusion).length;
732
733 const lines: string[] = [];
734 lines.push(`## PR #${data.number}: ${data.title}`);
735 lines.push("");
736 lines.push(`- **State:** ${data.state}${data.isDraft ? " (draft)" : ""}`);
737 lines.push(`- **Author:** @${data.author?.login ?? "?"}`);
738 lines.push(`- **Branch:** ${data.headRefName} → ${data.baseRefName}`);
739 lines.push(`- **Review:** ${getReviewDecisionText(data.reviewDecision ?? "")}`);
740 lines.push(`- **Changes:** ${data.changedFiles} files (+${data.additions}/-${data.deletions})`);
741 if (checks.length > 0) {
742 lines.push(`- **Checks:** ${passed}✓ ${failed}✗ ${pending}⏳`);
743 }
744 lines.push(`- **URL:** ${data.url}`);
745
746 if (data.body) {
747 lines.push("");
748 lines.push("### Description");
749 lines.push("");
750 lines.push(data.body);
751 }
752
753 pi.sendMessage({
754 customType: "gh-pr-view",
755 content: lines.join("\n"),
756 display: true,
757 });
758 },
759 });
760
761 // /gh-checks <number> - Show check status
762 pi.registerCommand("gh-checks", {
763 description: "Show check status for a PR (e.g., /gh-checks 123)",
764 getArgumentCompletions: (prefix: string) => {
765 if (recentPRs.length === 0) return null;
766 const items = recentPRs.map((p) => ({
767 value: String(p.number),
768 label: `#${p.number}${p.title ? ` - ${truncate(p.title, 50)}` : ""}`,
769 }));
770 if (!prefix.trim()) return items;
771 const filtered = items.filter((i) => i.value.startsWith(prefix.trim()));
772 return filtered.length > 0 ? filtered : null;
773 },
774 handler: async (args, ctx) => {
775 if (!args?.trim()) {
776 ctx.ui.notify("Usage: /gh-checks <number>", "error");
777 return;
778 }
779
780 const number = parseInt(args.trim(), 10);
781 if (isNaN(number)) {
782 ctx.ui.notify(`Invalid PR number: ${args}`, "error");
783 return;
784 }
785
786 const result = await execGh(
787 pi,
788 ctx,
789 ["pr", "view", String(number), "--json", "statusCheckRollup"],
790 { timeout: 30000 },
791 );
792
793 if (result.code !== 0) {
794 ctx.ui.notify(`Error: ${result.stderr}`, "error");
795 return;
796 }
797
798 const checks = parseChecks(result.stdout);
799
800 const lines: string[] = [];
801 lines.push(`## Checks for PR #${number}`);
802 lines.push("");
803
804 if (checks.length === 0) {
805 lines.push("*No checks found*");
806 } else {
807 const passed = checks.filter((c) => c.conclusion === "SUCCESS").length;
808 const failed = checks.filter((c) => c.conclusion === "FAILURE").length;
809 const pending = checks.filter((c) => !c.conclusion || c.status === "IN_PROGRESS" || c.status === "QUEUED").length;
810
811 lines.push(`**Summary:** ${passed} passed, ${failed} failed, ${pending} pending`);
812 lines.push("");
813 lines.push("| Status | Name | Details |");
814 lines.push("|--------|------|---------|");
815 for (const check of checks) {
816 const icon = getCheckIcon(check);
817 lines.push(`| ${icon} | ${check.name} | ${check.conclusion || check.status || "pending"} |`);
818 }
819 }
820
821 pi.sendMessage({
822 customType: "gh-checks",
823 content: lines.join("\n"),
824 display: true,
825 });
826 },
827 });
828
829 // /gh-issues - Show open issues
830 pi.registerCommand("gh-issues", {
831 description: "Show open issues in this repo",
832 handler: async (_args, ctx) => {
833 if (!ctx.hasUI) {
834 ctx.ui.notify("/gh-issues requires interactive mode", "error");
835 return;
836 }
837
838 const result = await execGh(
839 pi,
840 ctx,
841 ["issue", "list", "--state", "open", "--json", "number,title,state,labels,assignees,url", "--limit", "20"],
842 { timeout: 30000 },
843 );
844
845 if (result.code !== 0) {
846 ctx.ui.notify(`Error: ${result.stderr}`, "error");
847 return;
848 }
849
850 const issues = parseIssueList(result.stdout);
851
852 // Track
853 for (const issue of issues) {
854 if (!recentIssues.find((i) => i.number === issue.number)) {
855 recentIssues.push({ number: issue.number, title: issue.title });
856 }
857 }
858 if (recentIssues.length > 20) recentIssues.splice(0, recentIssues.length - 20);
859
860 const lines: string[] = [];
861 lines.push("## Open Issues");
862 lines.push("");
863
864 if (issues.length === 0) {
865 lines.push("*No open issues* ✨");
866 } else {
867 lines.push("| # | Title | Labels | Assignees |");
868 lines.push("|---|-------|--------|-----------|");
869 for (const issue of issues) {
870 const labels = issue.labels.length > 0 ? issue.labels.join(", ") : "-";
871 const assignees = issue.assignees.length > 0 ? issue.assignees.map((a) => `@${a}`).join(", ") : "-";
872 lines.push(`| #${issue.number} | ${truncate(issue.title, 50)} | ${labels} | ${assignees} |`);
873 }
874 }
875
876 pi.sendMessage({
877 customType: "gh-issues",
878 content: lines.join("\n"),
879 display: true,
880 });
881 },
882 });
883
884 // /gh-runs - Show recent workflow runs
885 pi.registerCommand("gh-runs", {
886 description: "Show recent workflow runs",
887 handler: async (_args, ctx) => {
888 if (!ctx.hasUI) {
889 ctx.ui.notify("/gh-runs requires interactive mode", "error");
890 return;
891 }
892
893 const result = await execGh(
894 pi,
895 ctx,
896 ["run", "list", "--json", "databaseId,name,displayTitle,status,conclusion,headBranch,url,createdAt", "--limit", "15"],
897 { timeout: 30000 },
898 );
899
900 if (result.code !== 0) {
901 ctx.ui.notify(`Error: ${result.stderr}`, "error");
902 return;
903 }
904
905 let runs: any[];
906 try {
907 runs = JSON.parse(result.stdout);
908 } catch {
909 ctx.ui.notify("Could not parse run data", "error");
910 return;
911 }
912
913 const lines: string[] = [];
914 lines.push("## Recent Workflow Runs");
915 lines.push("");
916
917 if (runs.length === 0) {
918 lines.push("*No recent runs*");
919 } else {
920 lines.push("| Status | ID | Workflow | Title | Branch | Age |");
921 lines.push("|--------|-----|----------|-------|--------|-----|");
922 for (const run of runs) {
923 const icon = run.conclusion === "success" ? "✓" : run.conclusion === "failure" ? "✗" : "⏳";
924 const age = formatRelativeDate(run.createdAt);
925 lines.push(`| ${icon} | ${run.databaseId} | ${run.name} | ${truncate(run.displayTitle, 35)} | ${run.headBranch} | ${age} |`);
926 }
927 }
928
929 pi.sendMessage({
930 customType: "gh-runs",
931 content: lines.join("\n"),
932 display: true,
933 });
934 },
935 });
936
937 // ========================================================================
938 // Auto-detection: GitHub PR/Issue URLs
939 // ========================================================================
940
941 pi.on("input", async (event, ctx) => {
942 if (event.source !== "interactive") return { action: "continue" as const };
943
944 const text = event.text.trim();
945
946 // Detect GitHub PR URLs
947 const prUrlMatch = text.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+\/pull\/(\d+)\/?$/);
948 if (prUrlMatch) {
949 return { action: "transform" as const, text: `View GitHub PR #${prUrlMatch[1]}` };
950 }
951
952 // Detect GitHub issue URLs
953 const issueUrlMatch = text.match(/^https:\/\/github\.com\/[^\/]+\/[^\/]+\/issues\/(\d+)\/?$/);
954 if (issueUrlMatch) {
955 return { action: "transform" as const, text: `View GitHub issue #${issueUrlMatch[1]}` };
956 }
957
958 return { action: "continue" as const };
959 });
960}
961
962// ============================================================================
963// Autocomplete: GitHub Issues/PRs
964// ============================================================================
965
966type GitHubItem = {
967 number: number;
968 title: string;
969 state: string;
970 type: "issue" | "pr";
971};
972
973const MAX_ITEMS = 100;
974const MAX_SUGGESTIONS = 20;
975
976function extractHashToken(textBeforeCursor: string): { repo?: string; query: string } | undefined {
977 const match = textBeforeCursor.match(/(?:^|[ \t])(?:([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+))?#([^\s#]*)$/);
978 if (!match) return undefined;
979 return { repo: match[1], query: match[2] };
980}
981
982function formatGitHubItem(item: GitHubItem, repoPrefix?: string): AutocompleteItem {
983 const kind = item.type === "pr" ? "pr" : "issue";
984 const prefix = repoPrefix ? `${repoPrefix}#` : "#";
985 return {
986 value: `${prefix}${item.number}`,
987 label: `${prefix}${item.number}`,
988 description: `[${kind}] ${item.title}`,
989 };
990}
991
992function filterGitHubItems(items: GitHubItem[], query: string, repoPrefix?: string): AutocompleteItem[] {
993 if (!query.trim()) {
994 return items.slice(0, MAX_SUGGESTIONS).map((i) => formatGitHubItem(i, repoPrefix));
995 }
996
997 if (/^\d+$/.test(query)) {
998 const numericMatches = items
999 .filter((item) => String(item.number).startsWith(query))
1000 .slice(0, MAX_SUGGESTIONS)
1001 .map((i) => formatGitHubItem(i, repoPrefix));
1002 if (numericMatches.length > 0) return numericMatches;
1003 }
1004
1005 return fuzzyFilter(items, query, (item) => `${item.number} ${item.title}`)
1006 .slice(0, MAX_SUGGESTIONS)
1007 .map((i) => formatGitHubItem(i, repoPrefix));
1008}
1009
1010/** Manages cached items per repo, with on-demand loading and live lookups for cache misses. */
1011class GitHubItemCache {
1012 private caches = new Map<string, Promise<GitHubItem[] | undefined>>();
1013 private liveLookups = new Map<string, Promise<GitHubItem | undefined>>();
1014
1015 constructor(
1016 private pi: ExtensionAPI,
1017 private ctx: ExtensionContext,
1018 ) {}
1019
1020 /** Get items for a repo (empty string = current repo). Lazy-loads on first call. */
1021 getItems(repo: string): Promise<GitHubItem[] | undefined> {
1022 let promise = this.caches.get(repo);
1023 if (!promise) {
1024 promise = this.loadItems(repo);
1025 this.caches.set(repo, promise);
1026 }
1027 return promise;
1028 }
1029
1030 /** Look up a specific number not found in cache. Returns the item and merges it into the cache. */
1031 async liveLookup(repo: string, number: number): Promise<GitHubItem | undefined> {
1032 const key = `${repo}#${number}`;
1033 let promise = this.liveLookups.get(key);
1034 if (promise) return promise;
1035
1036 promise = this.doLiveLookup(repo, number);
1037 this.liveLookups.set(key, promise);
1038
1039 const item = await promise;
1040 if (item) {
1041 // Merge into cache
1042 const items = await this.caches.get(repo);
1043 if (items && !items.find((i) => i.number === number)) {
1044 items.push(item);
1045 items.sort((a, b) => b.number - a.number);
1046 }
1047 }
1048 return item;
1049 }
1050
1051 private async loadItems(repo: string): Promise<GitHubItem[] | undefined> {
1052 const repoArgs = repo ? ["--repo", repo] : [];
1053 const [issueResult, prResult] = await Promise.all([
1054 execGh(this.pi, this.ctx, [
1055 "issue", "list", ...repoArgs, "--state", "open",
1056 "--limit", String(MAX_ITEMS),
1057 "--json", "number,title,state",
1058 ], { timeout: 15000 }),
1059 execGh(this.pi, this.ctx, [
1060 "pr", "list", ...repoArgs, "--state", "open",
1061 "--limit", String(MAX_ITEMS),
1062 "--json", "number,title,state",
1063 ], { timeout: 15000 }),
1064 ]);
1065
1066 const items: GitHubItem[] = [];
1067
1068 if (issueResult.code === 0) {
1069 try {
1070 for (const issue of JSON.parse(issueResult.stdout)) {
1071 items.push({ ...issue, type: "issue" });
1072 }
1073 } catch {}
1074 }
1075
1076 if (prResult.code === 0) {
1077 try {
1078 for (const pr of JSON.parse(prResult.stdout)) {
1079 items.push({ ...pr, type: "pr" });
1080 }
1081 } catch {}
1082 }
1083
1084 if (items.length === 0) return undefined;
1085
1086 items.sort((a, b) => b.number - a.number);
1087 return items;
1088 }
1089
1090 private async doLiveLookup(repo: string, number: number): Promise<GitHubItem | undefined> {
1091 const repoArgs = repo ? ["--repo", repo] : [];
1092
1093 // Try issue first, then PR
1094 const issueResult = await execGh(this.pi, this.ctx, [
1095 "issue", "view", String(number), ...repoArgs,
1096 "--json", "number,title,state",
1097 ], { timeout: 10000 });
1098
1099 if (issueResult.code === 0) {
1100 try {
1101 const data = JSON.parse(issueResult.stdout);
1102 return { number: data.number, title: data.title, state: data.state, type: "issue" };
1103 } catch {}
1104 }
1105
1106 const prResult = await execGh(this.pi, this.ctx, [
1107 "pr", "view", String(number), ...repoArgs,
1108 "--json", "number,title,state",
1109 ], { timeout: 10000 });
1110
1111 if (prResult.code === 0) {
1112 try {
1113 const data = JSON.parse(prResult.stdout);
1114 return { number: data.number, title: data.title, state: data.state, type: "pr" };
1115 } catch {}
1116 }
1117
1118 return undefined;
1119 }
1120}
1121
1122function createGitHubAutocompleteProvider(
1123 current: AutocompleteProvider,
1124 cache: GitHubItemCache,
1125): AutocompleteProvider {
1126 return {
1127 async getSuggestions(lines, cursorLine, cursorCol, options): Promise<AutocompleteSuggestions | null> {
1128 const currentLine = lines[cursorLine] ?? "";
1129 const textBeforeCursor = currentLine.slice(0, cursorCol);
1130 const token = extractHashToken(textBeforeCursor);
1131
1132 if (!token) {
1133 return current.getSuggestions(lines, cursorLine, cursorCol, options);
1134 }
1135
1136 const repo = token.repo || "";
1137 const items = await cache.getItems(repo);
1138 if (options.signal.aborted) return null;
1139
1140 let suggestions: AutocompleteItem[] = [];
1141 if (items && items.length > 0) {
1142 suggestions = filterGitHubItems(items, token.query, token.repo);
1143 }
1144
1145 // If query looks like a full number with no matches, try a live lookup
1146 if (suggestions.length === 0 && /^\d+$/.test(token.query) && token.query.length >= 1) {
1147 const num = parseInt(token.query, 10);
1148 const found = await cache.liveLookup(repo, num);
1149 if (options.signal.aborted) return null;
1150 if (found) {
1151 suggestions = [formatGitHubItem(found, token.repo)];
1152 }
1153 }
1154
1155 if (suggestions.length === 0) {
1156 return current.getSuggestions(lines, cursorLine, cursorCol, options);
1157 }
1158
1159 const prefix = token.repo ? `${token.repo}#${token.query}` : `#${token.query}`;
1160 return { items: suggestions, prefix };
1161 },
1162
1163 applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
1164 // Handle #-prefixed completions ourselves to avoid the default
1165 // provider misinterpreting org/repo# as a file path
1166 if (prefix.includes("#")) {
1167 const currentLine = lines[cursorLine] || "";
1168 const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
1169 const afterCursor = currentLine.slice(cursorCol);
1170 const newLine = beforePrefix + item.value + " " + afterCursor;
1171 const newLines = [...lines];
1172 newLines[cursorLine] = newLine;
1173 return {
1174 lines: newLines,
1175 cursorLine,
1176 cursorCol: beforePrefix.length + item.value.length + 1,
1177 };
1178 }
1179 return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
1180 },
1181
1182 shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
1183 // Always allow trigger — we handle # context in getSuggestions.
1184 // Returning false here would block Tab from working for org/repo# completions.
1185 return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
1186 },
1187 };
1188}
1189
1190function setupIssueAutocomplete(pi: ExtensionAPI, ctx: ExtensionContext): void {
1191 const cache = new GitHubItemCache(pi, ctx);
1192
1193 // Preload current repo in background
1194 void cache.getItems("");
1195
1196 ctx.ui.addAutocompleteProvider((current) => createGitHubAutocompleteProvider(current, cache));
1197}
1198
1199// ============================================================================
1200// Rendering Functions
1201// ============================================================================
1202
1203function renderPRList(details: GhDetails, expanded: boolean, theme: Theme): Text {
1204 if (!details.output) return new Text(theme.fg("dim", "No PRs found"), 0, 0);
1205
1206 const lines = details.output.split("\n").filter((l) => l.trim());
1207 if (lines.length === 0) return new Text(theme.fg("dim", "No PRs found"), 0, 0);
1208
1209 let text = theme.fg("muted", `${lines.length} PR(s):`);
1210
1211 const display = expanded ? lines : lines.slice(0, 5);
1212 for (const line of display) {
1213 text += `\n${theme.fg("text", line)}`;
1214 }
1215
1216 if (!expanded && lines.length > 5) {
1217 text += `\n${theme.fg("dim", `... ${lines.length - 5} more (expand for all)`)}`;
1218 }
1219
1220 return new Text(text, 0, 0);
1221}
1222
1223function renderIssueList(details: GhDetails, expanded: boolean, theme: Theme): Text {
1224 if (!details.output) return new Text(theme.fg("dim", "No issues found"), 0, 0);
1225
1226 const lines = details.output.split("\n").filter((l) => l.trim());
1227 if (lines.length === 0) return new Text(theme.fg("dim", "No issues found"), 0, 0);
1228
1229 let text = theme.fg("muted", `${lines.length} issue(s):`);
1230
1231 const display = expanded ? lines : lines.slice(0, 5);
1232 for (const line of display) {
1233 text += `\n${theme.fg("text", line)}`;
1234 }
1235
1236 if (!expanded && lines.length > 5) {
1237 text += `\n${theme.fg("dim", `... ${lines.length - 5} more (expand for all)`)}`;
1238 }
1239
1240 return new Text(text, 0, 0);
1241}
1242
1243function renderChecks(details: GhDetails, expanded: boolean, theme: Theme): Text {
1244 if (!details.output) return new Text(theme.fg("dim", "No checks"), 0, 0);
1245
1246 const lines = details.output.split("\n").filter((l) => l.trim());
1247 if (lines.length === 0) return new Text(theme.fg("dim", "No checks"), 0, 0);
1248
1249 // First line is the summary
1250 let text = theme.fg("muted", lines[0]);
1251
1252 const checkLines = lines.slice(1);
1253 const display = expanded ? checkLines : checkLines.slice(0, 8);
1254 for (const line of display) {
1255 if (line.startsWith("✓")) text += `\n${theme.fg("success", line)}`;
1256 else if (line.startsWith("✗")) text += `\n${theme.fg("error", line)}`;
1257 else if (line.startsWith("⏳")) text += `\n${theme.fg("warning", line)}`;
1258 else text += `\n${theme.fg("text", line)}`;
1259 }
1260
1261 if (!expanded && checkLines.length > 8) {
1262 text += `\n${theme.fg("dim", `... ${checkLines.length - 8} more (expand for all)`)}`;
1263 }
1264
1265 return new Text(text, 0, 0);
1266}
1267
1268function renderRunList(details: GhDetails, expanded: boolean, theme: Theme): Text {
1269 if (!details.output) return new Text(theme.fg("dim", "No runs"), 0, 0);
1270
1271 const lines = details.output.split("\n").filter((l) => l.trim());
1272 if (lines.length === 0) return new Text(theme.fg("dim", "No runs"), 0, 0);
1273
1274 // First line is the summary
1275 let text = theme.fg("muted", lines[0]);
1276
1277 const runLines = lines.slice(1);
1278 const display = expanded ? runLines : runLines.slice(0, 8);
1279 for (const line of display) {
1280 if (line.startsWith("✓")) text += `\n${theme.fg("success", line)}`;
1281 else if (line.startsWith("✗")) text += `\n${theme.fg("error", line)}`;
1282 else if (line.startsWith("⏳")) text += `\n${theme.fg("warning", line)}`;
1283 else text += `\n${theme.fg("text", line)}`;
1284 }
1285
1286 if (!expanded && runLines.length > 8) {
1287 text += `\n${theme.fg("dim", `... ${runLines.length - 8} more (expand for all)`)}`;
1288 }
1289
1290 return new Text(text, 0, 0);
1291}
1292
1293function renderLongOutput(details: GhDetails, expanded: boolean, theme: Theme, prefix: string): Text {
1294 if (!details.output) return new Text(theme.fg("dim", `No ${prefix.toLowerCase()} data`), 0, 0);
1295
1296 if (expanded) {
1297 return new Text(details.output, 0, 0);
1298 }
1299
1300 const lines = details.output.split("\n");
1301 const preview = lines.slice(0, 15).join("\n");
1302 let text = preview;
1303
1304 if (lines.length > 15) {
1305 text += `\n${theme.fg("dim", `... ${lines.length - 15} more lines (expand for full view)`)}`;
1306 }
1307
1308 return new Text(text, 0, 0);
1309}
1310
1311function renderDiff(details: GhDetails, expanded: boolean, theme: Theme): Text {
1312 const summary = details.output || "Diff fetched";
1313 if (expanded) {
1314 return new Text(summary, 0, 0);
1315 }
1316 return new Text(theme.fg("muted", summary), 0, 0);
1317}
1318
1319function renderCreated(details: GhDetails, theme: Theme, kind: string, number?: number, url?: string, parentNumber?: number): Text {
1320 let text = theme.fg("success", `✓ Created ${kind} `);
1321 if (number) text += theme.fg("accent", theme.bold(`#${number}`));
1322 if (url) text += theme.fg("dim", ` ${url}`);
1323 if (parentNumber) text += theme.fg("muted", ` (sub-issue of #${parentNumber})`);
1324 return new Text(text, 0, 0);
1325}