@rumen.rusanov/pi-github 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/app.ts ADDED
@@ -0,0 +1,632 @@
1
+ import { getMarkdownTheme, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ decodeKittyPrintable,
4
+ Markdown,
5
+ matchesKey,
6
+ truncateToWidth,
7
+ visibleWidth,
8
+ type Component,
9
+ type TUI,
10
+ } from "@earendil-works/pi-tui";
11
+ import { relativeTime } from "../format.ts";
12
+ import {
13
+ approvePullRequest,
14
+ fetchIssueDetail,
15
+ fetchIssues,
16
+ fetchPullRequestDetail,
17
+ fetchPullRequests,
18
+ getAllowedMergeMethods,
19
+ getCurrentAccount,
20
+ mergePullRequest,
21
+ } from "../gh-data.ts";
22
+ import type {
23
+ ExecFn,
24
+ IssueDetail,
25
+ IssueSummary,
26
+ MergeMethod,
27
+ PullRequestDetail,
28
+ PullRequestSummary,
29
+ } from "../types.ts";
30
+ import { filterIssues, filterPullRequests } from "./filter.ts";
31
+ import { approveConfirmMessage, mergeConfirmMessage, mergeMethodLabel } from "./messages.ts";
32
+ import { buildIssueRowPlan, buildPrRowPlan, layoutIssueRowLines, layoutPrRowLines, prStatusColor } from "./rows.ts";
33
+ import { TwoLineList } from "./two-line-list.ts";
34
+
35
+ export interface AppUI {
36
+ confirm(title: string, message: string): Promise<boolean>;
37
+ select(title: string, options: string[]): Promise<string | undefined>;
38
+ notify(message: string, type?: "info" | "warning" | "error"): void;
39
+ }
40
+
41
+ export interface AppOptions {
42
+ repo: string;
43
+ cwd: string;
44
+ exec: ExecFn;
45
+ limit: number;
46
+ ui: AppUI;
47
+ theme: Theme;
48
+ tui: TUI;
49
+ done: (value: void) => void;
50
+ }
51
+
52
+ type Section = "pr" | "issue";
53
+
54
+ interface SectionState<TSummary> {
55
+ items: TSummary[] | null;
56
+ error: string | null;
57
+ filterQuery: string;
58
+ selectedNumber: number | null;
59
+ }
60
+
61
+ type Screen =
62
+ | { kind: "list" }
63
+ | { kind: "pr-detail"; number: number; data: PullRequestDetail | null; loading: boolean; error: string | null }
64
+ | { kind: "issue-detail"; number: number; data: IssueDetail | null; loading: boolean; error: string | null };
65
+
66
+ const LIST_OVERHEAD_LINES = 10; // tabs + blank + chips + blank + footer + margin
67
+ const MIN_VISIBLE_ROWS = 3;
68
+ /** Left inset for list rows, so the selection background doesn't sit flush against the text. */
69
+ const ROW_INSET = " ";
70
+
71
+ /** Decodes a single typed printable character from raw terminal input, or undefined for control/navigation keys. */
72
+ function decodePrintable(data: string): string | undefined {
73
+ if (data.length === 0) return undefined;
74
+ const kitty = decodeKittyPrintable(data);
75
+ if (kitty !== undefined) return kitty;
76
+ if (data.startsWith("\x1b")) return undefined;
77
+ const code = data.codePointAt(0) ?? 0;
78
+ if (code < 32 || code === 127) return undefined;
79
+ return data;
80
+ }
81
+
82
+ export class GithubApp implements Component {
83
+ private closed = false;
84
+ private section: Section = "pr";
85
+ private pr: SectionState<PullRequestSummary> = { items: null, error: null, filterQuery: "", selectedNumber: null };
86
+ private issue: SectionState<IssueSummary> = { items: null, error: null, filterQuery: "", selectedNumber: null };
87
+ private screen: Screen = { kind: "list" };
88
+ private list: TwoLineList<PullRequestSummary | IssueSummary> | null = null;
89
+ private account: string | undefined;
90
+ private scrollPos = 0;
91
+
92
+ constructor(private readonly opts: AppOptions) {
93
+ void this.loadSection("pr");
94
+ }
95
+
96
+ dispose(): void {
97
+ this.closed = true;
98
+ }
99
+
100
+ invalidate(): void { }
101
+
102
+ // --- Data loading -------------------------------------------------------
103
+
104
+ private async loadSection(section: Section): Promise<void> {
105
+ const state = section === "pr" ? this.pr : this.issue;
106
+ state.error = null;
107
+ state.items = null;
108
+ this.requestRender();
109
+
110
+ if (section === "pr") {
111
+ const result = await fetchPullRequests(this.opts.exec, { repo: this.opts.repo, cwd: this.opts.cwd, limit: this.opts.limit });
112
+ if (this.closed) return;
113
+ if (result.ok) {
114
+ this.pr.items = result.data;
115
+ } else {
116
+ this.pr.error = result.error;
117
+ }
118
+ } else {
119
+ const result = await fetchIssues(this.opts.exec, { repo: this.opts.repo, cwd: this.opts.cwd, limit: this.opts.limit });
120
+ if (this.closed) return;
121
+ if (result.ok) {
122
+ this.issue.items = result.data;
123
+ } else {
124
+ this.issue.error = result.error;
125
+ }
126
+ }
127
+ this.list = null;
128
+ this.requestRender();
129
+ }
130
+
131
+ private async ensureAccount(): Promise<string> {
132
+ if (this.account) return this.account;
133
+ const login = await getCurrentAccount(this.opts.exec, this.opts.cwd);
134
+ this.account = login ?? "unknown";
135
+ return this.account;
136
+ }
137
+
138
+ private async openPrDetail(number: number): Promise<void> {
139
+ this.screen = { kind: "pr-detail", number, data: null, loading: true, error: null };
140
+ this.scrollPos = 0;
141
+ this.requestRender();
142
+ await this.reloadPrDetail();
143
+ }
144
+
145
+ private async reloadPrDetail(): Promise<void> {
146
+ if (this.screen.kind !== "pr-detail") return;
147
+ const number = this.screen.number;
148
+ const result = await fetchPullRequestDetail(this.opts.exec, { repo: this.opts.repo, cwd: this.opts.cwd, number });
149
+ if (this.closed || this.screen.kind !== "pr-detail" || this.screen.number !== number) return;
150
+ if (result.ok) {
151
+ this.screen = { kind: "pr-detail", number, data: result.data, loading: false, error: null };
152
+ } else {
153
+ this.screen = { kind: "pr-detail", number, data: this.screen.data, loading: false, error: result.error };
154
+ }
155
+ this.requestRender();
156
+ }
157
+
158
+ private async openIssueDetail(number: number): Promise<void> {
159
+ this.screen = { kind: "issue-detail", number, data: null, loading: true, error: null };
160
+ this.scrollPos = 0;
161
+ this.requestRender();
162
+ await this.reloadIssueDetail();
163
+ }
164
+
165
+ private async reloadIssueDetail(): Promise<void> {
166
+ if (this.screen.kind !== "issue-detail") return;
167
+ const number = this.screen.number;
168
+ const result = await fetchIssueDetail(this.opts.exec, { repo: this.opts.repo, cwd: this.opts.cwd, number });
169
+ if (this.closed || this.screen.kind !== "issue-detail" || this.screen.number !== number) return;
170
+ if (result.ok) {
171
+ this.screen = { kind: "issue-detail", number, data: result.data, loading: false, error: null };
172
+ } else {
173
+ this.screen = { kind: "issue-detail", number, data: this.screen.data, loading: false, error: result.error };
174
+ }
175
+ this.requestRender();
176
+ }
177
+
178
+ private async approveCurrentPr(): Promise<void> {
179
+ if (this.screen.kind !== "pr-detail" || !this.screen.data) return;
180
+ const pr = this.screen.data;
181
+ const account = await this.ensureAccount();
182
+ const confirmed = await this.opts.ui.confirm("Approve Pull Request", approveConfirmMessage(pr, account));
183
+ if (!confirmed) return;
184
+
185
+ const result = await approvePullRequest(this.opts.exec, { repo: this.opts.repo, cwd: this.opts.cwd, number: pr.number });
186
+ if (result.ok) {
187
+ this.opts.ui.notify(`Approved #${pr.number}.`, "info");
188
+ await this.reloadPrDetail();
189
+ } else {
190
+ this.opts.ui.notify(`Approve failed: ${result.error}`, "error");
191
+ }
192
+ }
193
+
194
+ private async mergeCurrentPr(): Promise<void> {
195
+ if (this.screen.kind !== "pr-detail" || !this.screen.data) return;
196
+ const pr = this.screen.data;
197
+
198
+ const methodsResult = await getAllowedMergeMethods(this.opts.exec, { repo: this.opts.repo, cwd: this.opts.cwd });
199
+ if (!methodsResult.ok) {
200
+ this.opts.ui.notify(`Could not determine merge methods: ${methodsResult.error}`, "error");
201
+ return;
202
+ }
203
+ if (methodsResult.data.length === 0) {
204
+ this.opts.ui.notify("This repository has no merge methods enabled.", "error");
205
+ return;
206
+ }
207
+
208
+ let method: MergeMethod;
209
+ if (methodsResult.data.length === 1) {
210
+ const first = methodsResult.data[0];
211
+ if (!first) return;
212
+ method = first;
213
+ } else {
214
+ const labels = methodsResult.data.map(mergeMethodLabel);
215
+ const chosenLabel = await this.opts.ui.select("Merge method", labels);
216
+ if (!chosenLabel) return;
217
+ const index = labels.indexOf(chosenLabel);
218
+ const chosen = methodsResult.data[index];
219
+ if (!chosen) return;
220
+ method = chosen;
221
+ }
222
+
223
+ const account = await this.ensureAccount();
224
+ const confirmed = await this.opts.ui.confirm(mergeMethodLabel(method), mergeConfirmMessage(pr, method, account));
225
+ if (!confirmed) return;
226
+
227
+ const result = await mergePullRequest(this.opts.exec, { repo: this.opts.repo, cwd: this.opts.cwd, number: pr.number, method });
228
+ if (result.ok) {
229
+ this.opts.ui.notify(`Merged #${pr.number}.`, "info");
230
+ await this.reloadPrDetail();
231
+ } else {
232
+ this.opts.ui.notify(`Merge failed: ${result.error}`, "error");
233
+ }
234
+ }
235
+
236
+ private requestRender(): void {
237
+ if (this.closed) return;
238
+ this.opts.tui.requestRender();
239
+ }
240
+
241
+ // --- Input ---------------------------------------------------------------
242
+
243
+ handleInput(data: string): void {
244
+ if (this.screen.kind === "list") {
245
+ this.handleListInput(data);
246
+ } else {
247
+ this.handleDetailInput(data);
248
+ }
249
+ }
250
+
251
+ private currentSectionState(): SectionState<PullRequestSummary | IssueSummary> {
252
+ return this.section === "pr" ? this.pr : this.issue;
253
+ }
254
+
255
+ private handleListInput(data: string): void {
256
+ if (matchesKey(data, "escape")) {
257
+ const state = this.currentSectionState();
258
+ if (state.filterQuery !== "") {
259
+ state.filterQuery = "";
260
+ this.list = null;
261
+ this.requestRender();
262
+ } else {
263
+ this.closed = true;
264
+ this.opts.done(undefined);
265
+ }
266
+ return;
267
+ }
268
+
269
+ if (matchesKey(data, "tab")) {
270
+ this.section = this.section === "pr" ? "issue" : "pr";
271
+ this.list = null;
272
+ if (this.currentSectionState().items === null) {
273
+ void this.loadSection(this.section);
274
+ }
275
+ this.requestRender();
276
+ return;
277
+ }
278
+
279
+ const printable = decodePrintable(data);
280
+
281
+ if (printable === "r") {
282
+ void this.loadSection(this.section);
283
+ return;
284
+ }
285
+
286
+ if (matchesKey(data, "backspace")) {
287
+ const state = this.currentSectionState();
288
+ if (state.filterQuery.length > 0) {
289
+ state.filterQuery = state.filterQuery.slice(0, -1);
290
+ this.list = null;
291
+ this.requestRender();
292
+ }
293
+ return;
294
+ }
295
+
296
+ if (printable && printable.length === 1) {
297
+ const state = this.currentSectionState();
298
+ state.filterQuery += printable;
299
+ this.list = null;
300
+ this.requestRender();
301
+ return;
302
+ }
303
+
304
+ if (matchesKey(data, "enter")) {
305
+ const item = this.list?.getSelectedItem();
306
+ if (item) {
307
+ const number = Number(item.value);
308
+ if (this.section === "pr") void this.openPrDetail(number);
309
+ else void this.openIssueDetail(number);
310
+ }
311
+ return;
312
+ }
313
+
314
+ if (matchesKey(data, "up")) {
315
+ this.list?.moveUp();
316
+ this.requestRender();
317
+ return;
318
+ }
319
+
320
+ if (matchesKey(data, "down")) {
321
+ this.list?.moveDown();
322
+ this.requestRender();
323
+ return;
324
+ }
325
+ }
326
+
327
+ private handleDetailInput(data: string): void {
328
+ if (matchesKey(data, "escape")) {
329
+ this.screen = { kind: "list" };
330
+ this.requestRender();
331
+ return;
332
+ }
333
+
334
+ const printable = decodePrintable(data);
335
+
336
+ if (printable === "r") {
337
+ if (this.screen.kind === "pr-detail") void this.reloadPrDetail();
338
+ else void this.reloadIssueDetail();
339
+ return;
340
+ }
341
+
342
+ if (this.screen.kind === "pr-detail") {
343
+ if (printable === "a") {
344
+ void this.approveCurrentPr();
345
+ return;
346
+ }
347
+ if (printable === "m") {
348
+ void this.mergeCurrentPr();
349
+ return;
350
+ }
351
+ }
352
+
353
+ if (matchesKey(data, "up")) {
354
+ this.scrollPos = Math.max(0, this.scrollPos - 1);
355
+ this.requestRender();
356
+ return;
357
+ }
358
+ if (matchesKey(data, "down")) {
359
+ this.scrollPos += 1;
360
+ this.requestRender();
361
+ return;
362
+ }
363
+ if (matchesKey(data, "pageUp")) {
364
+ this.scrollPos = Math.max(0, this.scrollPos - this.visibleRows());
365
+ this.requestRender();
366
+ return;
367
+ }
368
+ if (matchesKey(data, "pageDown")) {
369
+ this.scrollPos += this.visibleRows();
370
+ this.requestRender();
371
+ return;
372
+ }
373
+ }
374
+
375
+ private visibleRows(): number {
376
+ return Math.max(MIN_VISIBLE_ROWS, (this.opts.tui.terminal.rows || 24) - LIST_OVERHEAD_LINES);
377
+ }
378
+
379
+ /** Each list row spans two terminal lines (title + meta), so halve the available rows. */
380
+ private visibleListItems(): number {
381
+ return Math.max(1, Math.floor(this.visibleRows() / 2));
382
+ }
383
+
384
+ // --- Rendering -------------------------------------------------------------
385
+
386
+ render(width: number): string[] {
387
+ const lines: string[] = [];
388
+
389
+ if (this.screen.kind === "list") {
390
+ lines.push(this.renderListTabs(width));
391
+ lines.push("");
392
+ lines.push(this.renderListChips(width));
393
+ lines.push("");
394
+ lines.push(...this.renderListBody(width));
395
+ lines.push("");
396
+ lines.push(this.renderListFooter());
397
+ } else if (this.screen.kind === "pr-detail") {
398
+ lines.push(...this.renderPrDetail(width));
399
+ } else {
400
+ lines.push(...this.renderIssueDetail(width));
401
+ }
402
+
403
+ return lines.map((line) => truncateToWidth(line, width, "", false));
404
+ }
405
+
406
+ private renderListTabs(width: number): string {
407
+ const theme = this.opts.theme;
408
+ const tab = (label: string, active: boolean) => {
409
+ const padded = ` ${label} `;
410
+ return active ? theme.bg("selectedBg", theme.bold(theme.fg("text", padded))) : theme.fg("dim", padded);
411
+ };
412
+ return truncateToWidth(`${tab("Pull Requests", this.section === "pr")} ${tab("Issues", this.section === "issue")}`, width);
413
+ }
414
+
415
+ private renderListChips(width: number): string {
416
+ const theme = this.opts.theme;
417
+ const repoChip = theme.bg("toolPendingBg", theme.fg("muted", ` repo: ${this.opts.repo} `));
418
+ return truncateToWidth(repoChip, width);
419
+ }
420
+
421
+ private renderListFooter(): string {
422
+ const theme = this.opts.theme;
423
+ const state = this.currentSectionState();
424
+ const escHint = state.filterQuery !== "" ? "esc clear filter" : "esc close";
425
+ return theme.fg("dim", `↑/↓ move · enter open · tab switch · type to filter · r refresh · ${escHint}`);
426
+ }
427
+
428
+ private renderListBody(width: number): string[] {
429
+ const theme = this.opts.theme;
430
+ const state = this.currentSectionState();
431
+
432
+ if (state.error) {
433
+ return [theme.fg("error", `Failed to load: ${state.error}`), theme.fg("dim", "Press r to retry.")];
434
+ }
435
+ if (state.items === null) {
436
+ return [theme.fg("dim", "Loading…")];
437
+ }
438
+ if (state.items.length === 0) {
439
+ return [theme.fg("dim", this.section === "pr" ? "No open pull requests." : "No open issues.")];
440
+ }
441
+
442
+ const filtered = this.section === "pr" ? filterPullRequests(this.pr.items as PullRequestSummary[], state.filterQuery) : filterIssues(this.issue.items as IssueSummary[], state.filterQuery);
443
+
444
+ const lines: string[] = [];
445
+ if (state.filterQuery !== "") {
446
+ lines.push(theme.fg("accent", `Filter: ${state.filterQuery}`));
447
+ }
448
+
449
+ if (filtered.length === 0) {
450
+ lines.push(theme.fg("warning", `No matches for "${state.filterQuery}".`));
451
+ this.list = null;
452
+ return lines;
453
+ }
454
+
455
+ if (!this.list) {
456
+ this.list = this.buildList(filtered);
457
+ }
458
+
459
+ lines.push(...this.list.render(width));
460
+ return lines;
461
+ }
462
+
463
+ private buildList(items: (PullRequestSummary | IssueSummary)[]): TwoLineList<PullRequestSummary | IssueSummary> {
464
+ const now = new Date();
465
+ const theme = this.opts.theme;
466
+ const section = this.section;
467
+
468
+ const listItems = items.map((item) => ({ value: String(item.number), data: item }));
469
+
470
+ const renderRow = (item: PullRequestSummary | IssueSummary, isSelected: boolean, width: number): [string, string] => {
471
+ // Meta line needs to read against the row highlight when selected, so it can't stay as dim there.
472
+ const metaColor = isSelected ? "muted" : "dim";
473
+ const innerWidth = Math.max(1, width - ROW_INSET.length);
474
+ if (section === "pr") {
475
+ const plan = buildPrRowPlan(item as PullRequestSummary, now);
476
+ const { symbol, title, meta } = layoutPrRowLines(plan, innerWidth);
477
+ const line1 = `${ROW_INSET}${theme.fg(prStatusColor(plan), symbol)} ${theme.bold(theme.fg("text", title))}`;
478
+ const line2 = theme.fg(metaColor, `${ROW_INSET} ${meta}`);
479
+ return [line1, line2];
480
+ }
481
+ const plan = buildIssueRowPlan(item as IssueSummary, now);
482
+ const { title, meta } = layoutIssueRowLines(plan, innerWidth);
483
+ const line1 = `${ROW_INSET}${theme.bold(theme.fg("text", title))}`;
484
+ const line2 = theme.fg(metaColor, `${ROW_INSET} ${meta}`);
485
+ return [line1, line2];
486
+ };
487
+
488
+ const highlightRow = (line: string, width: number): string => {
489
+ const pad = Math.max(0, width - visibleWidth(line));
490
+ return theme.bg("selectedBg", line + " ".repeat(pad));
491
+ };
492
+
493
+ const list = new TwoLineList(listItems, this.visibleListItems(), {
494
+ renderRow,
495
+ highlightRow,
496
+ scrollInfo: (t) => theme.fg("dim", t),
497
+ });
498
+
499
+ const state = this.currentSectionState();
500
+ if (state.selectedNumber !== null) {
501
+ const index = listItems.findIndex((item) => item.value === String(state.selectedNumber));
502
+ if (index >= 0) list.setSelectedIndex(index);
503
+ }
504
+ list.onSelectionChange = (item) => {
505
+ state.selectedNumber = Number(item.value);
506
+ };
507
+ const initial = list.getSelectedItem();
508
+ if (initial) state.selectedNumber = Number(initial.value);
509
+
510
+ return list;
511
+ }
512
+
513
+ private renderPrDetail(width: number): string[] {
514
+ if (this.screen.kind !== "pr-detail") return [];
515
+ const theme = this.opts.theme;
516
+ const { number, data, loading, error } = this.screen;
517
+
518
+ const status = this.detailStatusLines(number, data, loading, error);
519
+ if (status.done) return this.withDetailChrome(status.lines, "");
520
+ if (!data) return this.withDetailChrome(status.lines, "");
521
+
522
+ const content: string[] = [
523
+ ...status.lines,
524
+ ...this.renderDetailHeader(data, data.isDraft ? "draft" : undefined),
525
+ theme.fg("dim", `${data.baseRefName} ← ${data.headRefName}`),
526
+ ];
527
+
528
+ if (data.checks.length > 0) {
529
+ content.push("");
530
+ content.push(theme.bold("Checks"));
531
+ for (const check of data.checks) {
532
+ const color = check.state === "pass" ? "success" : check.state === "fail" ? "error" : "warning";
533
+ const symbol = check.state === "pass" ? "✓" : check.state === "fail" ? "✗" : "●";
534
+ content.push(theme.fg(color, ` ${symbol} ${check.name}`));
535
+ }
536
+ }
537
+
538
+ if (data.reviews.length > 0) {
539
+ content.push("");
540
+ content.push(theme.bold("Reviews"));
541
+ for (const review of data.reviews) {
542
+ const approved = review.state === "APPROVED";
543
+ content.push(theme.fg(approved ? "success" : "warning", ` ${approved ? "✓" : "✗"} @${review.author} ${review.state}`));
544
+ }
545
+ }
546
+
547
+ if (data.files.length > 0) {
548
+ content.push("");
549
+ content.push(theme.bold(`Files changed (${data.files.length})`));
550
+ for (const file of data.files) {
551
+ content.push(` ${file.path} ${theme.fg("success", `+${file.additions}`)} ${theme.fg("error", `-${file.deletions}`)}`);
552
+ }
553
+ }
554
+
555
+ content.push(...this.renderBodyAndComments(data.body, data.comments, width));
556
+
557
+ const footer = theme.fg("dim", "↑/↓ scroll · a approve · m merge · r refresh · esc back");
558
+ return this.withDetailChrome(content, footer);
559
+ }
560
+
561
+ private renderIssueDetail(width: number): string[] {
562
+ if (this.screen.kind !== "issue-detail") return [];
563
+ const theme = this.opts.theme;
564
+ const { number, data, loading, error } = this.screen;
565
+
566
+ const status = this.detailStatusLines(number, data, loading, error);
567
+ if (status.done) return this.withDetailChrome(status.lines, "");
568
+ if (!data) return this.withDetailChrome(status.lines, "");
569
+
570
+ const content: string[] = [...status.lines, ...this.renderDetailHeader(data), ...this.renderBodyAndComments(data.body, data.comments, width)];
571
+
572
+ const footer = theme.fg("dim", "↑/↓ scroll · r refresh · esc back");
573
+ return this.withDetailChrome(content, footer);
574
+ }
575
+
576
+ /** Loading/error preamble shared by both detail screens. `done: true` means the caller should return immediately with just `lines`. */
577
+ private detailStatusLines(number: number, data: unknown, loading: boolean, error: string | null): { lines: string[]; done: boolean } {
578
+ const theme = this.opts.theme;
579
+ if (loading && !data) {
580
+ return { lines: [theme.fg("dim", `Loading #${number}…`)], done: true };
581
+ }
582
+ const lines: string[] = [];
583
+ if (error) lines.push(theme.fg("error", `Failed to load #${number}: ${error}`));
584
+ if (!data) return { lines, done: true };
585
+ return { lines, done: false };
586
+ }
587
+
588
+ private renderDetailHeader(
589
+ data: { number: number; title: string; state: string; author: string; createdAt: string; updatedAt: string; labels: string[]; assignees: string[] },
590
+ extra?: string,
591
+ ): string[] {
592
+ const theme = this.opts.theme;
593
+ const lines: string[] = [
594
+ theme.bold(theme.fg("text", `#${data.number} ${data.title}`)),
595
+ theme.fg(
596
+ "dim",
597
+ `${data.state}${extra ? ` · ${extra}` : ""} · @${data.author} · opened ${relativeTime(data.createdAt)} · updated ${relativeTime(data.updatedAt)}`,
598
+ ),
599
+ ];
600
+ if (data.labels.length > 0) lines.push(theme.fg("dim", `Labels: ${data.labels.join(", ")}`));
601
+ if (data.assignees.length > 0) lines.push(theme.fg("dim", `Assignees: ${data.assignees.join(", ")}`));
602
+ return lines;
603
+ }
604
+
605
+ private renderBodyAndComments(body: string, comments: { author: string; body: string; createdAt: string }[], width: number): string[] {
606
+ const theme = this.opts.theme;
607
+ const lines: string[] = ["", theme.bold("Description"), ...new Markdown(body || "_No description._", 0, 0, getMarkdownTheme()).render(width)];
608
+
609
+ lines.push("", theme.bold(`Comments (${comments.length})`));
610
+ for (const comment of comments) {
611
+ lines.push(theme.fg("accent", `@${comment.author} · ${relativeTime(comment.createdAt)}`));
612
+ lines.push(...new Markdown(comment.body, 0, 0, getMarkdownTheme()).render(width));
613
+ lines.push("");
614
+ }
615
+ return lines;
616
+ }
617
+
618
+ private withDetailChrome(content: string[], footer: string): string[] {
619
+ const visible = this.visibleRows();
620
+ const maxScroll = Math.max(0, content.length - visible);
621
+ this.scrollPos = Math.max(0, Math.min(this.scrollPos, maxScroll));
622
+ const windowed = content.slice(this.scrollPos, this.scrollPos + visible);
623
+
624
+ const lines = [...windowed];
625
+ if (content.length > visible) {
626
+ lines.push(this.opts.theme.fg("dim", `-- ${this.scrollPos + 1}-${Math.min(this.scrollPos + visible, content.length)} of ${content.length} --`));
627
+ }
628
+ lines.push("");
629
+ if (footer) lines.push(footer);
630
+ return lines;
631
+ }
632
+ }
@@ -0,0 +1,12 @@
1
+ import { fuzzyFilter } from "@earendil-works/pi-tui";
2
+ import type { IssueSummary, PullRequestSummary } from "../types.ts";
3
+
4
+ export function filterPullRequests(items: PullRequestSummary[], query: string): PullRequestSummary[] {
5
+ if (!query.trim()) return items;
6
+ return fuzzyFilter(items, query, (pr) => `${pr.number} ${pr.title}`);
7
+ }
8
+
9
+ export function filterIssues(items: IssueSummary[], query: string): IssueSummary[] {
10
+ if (!query.trim()) return items;
11
+ return fuzzyFilter(items, query, (issue) => `${issue.number} ${issue.title}`);
12
+ }
@@ -0,0 +1,24 @@
1
+ import type { MergeMethod } from "../types.ts";
2
+
3
+ export function approveConfirmMessage(pr: { number: number; title: string }, account: string): string {
4
+ return `Approve #${pr.number} '${pr.title}' as @${account}?`;
5
+ }
6
+
7
+ export function mergeMethodLabel(method: MergeMethod): string {
8
+ switch (method) {
9
+ case "squash":
10
+ return "Squash and merge";
11
+ case "merge":
12
+ return "Merge";
13
+ case "rebase":
14
+ return "Rebase and merge";
15
+ }
16
+ }
17
+
18
+ export function mergeConfirmMessage(
19
+ pr: { number: number; title: string; baseRefName: string },
20
+ method: MergeMethod,
21
+ account: string,
22
+ ): string {
23
+ return `${mergeMethodLabel(method)} #${pr.number} '${pr.title}' into ${pr.baseRefName} as @${account}?`;
24
+ }