@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/rows.ts ADDED
@@ -0,0 +1,98 @@
1
+ import { truncateToWidth } from "@earendil-works/pi-tui";
2
+ import { relativeTime } from "../format.ts";
3
+ import type { CheckState, IssueSummary, PullRequestSummary } from "../types.ts";
4
+
5
+ export interface PrRowPlan {
6
+ number: number;
7
+ title: string;
8
+ author: string;
9
+ isDraft: boolean;
10
+ checkState: CheckState | "none";
11
+ approvals: number;
12
+ changesRequested: number;
13
+ updated: string;
14
+ }
15
+
16
+ export interface IssueRowPlan {
17
+ number: number;
18
+ title: string;
19
+ author: string;
20
+ commentCount: number;
21
+ labels: string[];
22
+ updated: string;
23
+ }
24
+
25
+ export interface PrRowLines {
26
+ symbol: string;
27
+ title: string;
28
+ meta: string;
29
+ }
30
+
31
+ export interface IssueRowLines {
32
+ title: string;
33
+ meta: string;
34
+ }
35
+
36
+ const CHECK_SYMBOL: Record<CheckState | "none", string> = {
37
+ pass: "✓",
38
+ fail: "✗",
39
+ pending: "●",
40
+ none: "●",
41
+ };
42
+
43
+ const CHECK_COLOR: Record<CheckState | "none", "success" | "error" | "warning" | "dim"> = {
44
+ pass: "success",
45
+ fail: "error",
46
+ pending: "warning",
47
+ none: "warning",
48
+ };
49
+
50
+ export function buildPrRowPlan(pr: PullRequestSummary, now: Date = new Date()): PrRowPlan {
51
+ return {
52
+ number: pr.number,
53
+ title: pr.title,
54
+ author: pr.author,
55
+ isDraft: pr.isDraft,
56
+ checkState: pr.checkState,
57
+ approvals: pr.approvals,
58
+ changesRequested: pr.changesRequested,
59
+ updated: relativeTime(pr.updatedAt, now),
60
+ };
61
+ }
62
+
63
+ export function buildIssueRowPlan(issue: IssueSummary, now: Date = new Date()): IssueRowPlan {
64
+ return {
65
+ number: issue.number,
66
+ title: issue.title,
67
+ author: issue.author,
68
+ commentCount: issue.commentCount,
69
+ labels: issue.labels,
70
+ updated: relativeTime(issue.updatedAt, now),
71
+ };
72
+ }
73
+
74
+ export function prStatusColor(plan: PrRowPlan): "success" | "error" | "warning" | "dim" {
75
+ return CHECK_COLOR[plan.checkState];
76
+ }
77
+
78
+ /** Row indent used for the meta line, matching the "<symbol> " prefix width on the title line. */
79
+ const ROW_INDENT = 2;
80
+
81
+ /** Lays out a PR as two plain-text lines: title (with status symbol) and a dim meta line below it. Neither line exceeds `width`. */
82
+ export function layoutPrRowLines(plan: PrRowPlan, width: number): PrRowLines {
83
+ const symbol = CHECK_SYMBOL[plan.checkState];
84
+ const titleRaw = `${plan.title}${plan.isDraft ? " [draft]" : ""}`;
85
+ const title = truncateToWidth(titleRaw, Math.max(1, width - ROW_INDENT));
86
+ const metaRaw = `#${plan.number} · @${plan.author} · ✓${plan.approvals}/✗${plan.changesRequested} · ${plan.updated}`;
87
+ const meta = truncateToWidth(metaRaw, Math.max(1, width - ROW_INDENT));
88
+ return { symbol, title, meta };
89
+ }
90
+
91
+ /** Lays out an Issue as two plain-text lines: title and a dim meta line below it. Neither line exceeds `width`. */
92
+ export function layoutIssueRowLines(plan: IssueRowPlan, width: number): IssueRowLines {
93
+ const title = truncateToWidth(plan.title, width);
94
+ const labels = plan.labels.length > 0 ? ` · ${plan.labels.join(",")}` : "";
95
+ const metaRaw = `#${plan.number} · @${plan.author} · 💬${plan.commentCount}${labels} · ${plan.updated}`;
96
+ const meta = truncateToWidth(metaRaw, width);
97
+ return { title, meta };
98
+ }
@@ -0,0 +1,78 @@
1
+ export interface TwoLineListItem<T> {
2
+ value: string;
3
+ data: T;
4
+ }
5
+
6
+ export interface TwoLineListOptions<T> {
7
+ /** Renders an item's two lines. Output should already be themed but not padded/highlighted. */
8
+ renderRow: (item: T, isSelected: boolean, width: number) => [string, string];
9
+ /** Pads a line to `width` and applies the selection background. */
10
+ highlightRow: (line: string, width: number) => string;
11
+ /** Styles the "(i/n)" indicator shown when the list is scrolled. */
12
+ scrollInfo?: (text: string) => string;
13
+ }
14
+
15
+ /** A `SelectList`-like widget where each item renders as two lines (title + meta) instead of one. */
16
+ export class TwoLineList<T> {
17
+ private selectedIndex = 0;
18
+
19
+ onSelectionChange?: (item: TwoLineListItem<T>) => void;
20
+
21
+ constructor(
22
+ private readonly items: TwoLineListItem<T>[],
23
+ private readonly maxVisible: number,
24
+ private readonly options: TwoLineListOptions<T>,
25
+ ) {}
26
+
27
+ setSelectedIndex(index: number): void {
28
+ this.selectedIndex = Math.max(0, Math.min(index, this.items.length - 1));
29
+ }
30
+
31
+ getSelectedItem(): TwoLineListItem<T> | null {
32
+ return this.items[this.selectedIndex] ?? null;
33
+ }
34
+
35
+ moveUp(): void {
36
+ if (this.items.length === 0) return;
37
+ this.selectedIndex = this.selectedIndex === 0 ? this.items.length - 1 : this.selectedIndex - 1;
38
+ this.notifySelectionChange();
39
+ }
40
+
41
+ moveDown(): void {
42
+ if (this.items.length === 0) return;
43
+ this.selectedIndex = this.selectedIndex === this.items.length - 1 ? 0 : this.selectedIndex + 1;
44
+ this.notifySelectionChange();
45
+ }
46
+
47
+ render(width: number): string[] {
48
+ if (this.items.length === 0) return [];
49
+
50
+ const maxVisible = Math.max(1, this.maxVisible);
51
+ const startIndex = Math.max(0, Math.min(this.selectedIndex - Math.floor(maxVisible / 2), Math.max(0, this.items.length - maxVisible)));
52
+ const endIndex = Math.min(startIndex + maxVisible, this.items.length);
53
+
54
+ const lines: string[] = [];
55
+ for (let i = startIndex; i < endIndex; i++) {
56
+ const item = this.items[i];
57
+ if (!item) continue;
58
+ const isSelected = i === this.selectedIndex;
59
+ const [line1, line2] = this.options.renderRow(item.data, isSelected, width);
60
+ if (isSelected) {
61
+ lines.push(this.options.highlightRow(line1, width), this.options.highlightRow(line2, width));
62
+ } else {
63
+ lines.push(line1, line2);
64
+ }
65
+ }
66
+
67
+ if ((startIndex > 0 || endIndex < this.items.length) && this.options.scrollInfo) {
68
+ lines.push(this.options.scrollInfo(` (${this.selectedIndex + 1}/${this.items.length})`));
69
+ }
70
+
71
+ return lines;
72
+ }
73
+
74
+ private notifySelectionChange(): void {
75
+ const item = this.getSelectedItem();
76
+ if (item && this.onSelectionChange) this.onSelectionChange(item);
77
+ }
78
+ }
@@ -0,0 +1,235 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { GithubApp, type AppOptions, type AppUI } from "../src/ui/app.ts";
3
+ import type { ExecFn } from "../src/types.ts";
4
+
5
+ function flush(): Promise<void> {
6
+ return new Promise((resolve) => setTimeout(resolve, 0));
7
+ }
8
+
9
+ const fakeTheme = {
10
+ fg: (_c: string, t: string) => t,
11
+ bg: (_c: string, t: string) => t,
12
+ bold: (t: string) => t,
13
+ italic: (t: string) => t,
14
+ underline: (t: string) => t,
15
+ inverse: (t: string) => t,
16
+ strikethrough: (t: string) => t,
17
+ } as unknown as AppOptions["theme"];
18
+
19
+ function makeApp(overrides: {
20
+ exec?: ExecFn;
21
+ ui?: Partial<AppUI>;
22
+ } = {}) {
23
+ const done = vi.fn();
24
+ const requestRender = vi.fn();
25
+ const tui = { requestRender, terminal: { rows: 40, columns: 100 } } as unknown as AppOptions["tui"];
26
+
27
+ const prList = [
28
+ {
29
+ number: 1,
30
+ title: "Fix the login bug",
31
+ author: { login: "alice" },
32
+ isDraft: false,
33
+ updatedAt: "2026-08-01T00:00:00Z",
34
+ statusCheckRollup: [],
35
+ latestReviews: [],
36
+ },
37
+ ];
38
+ const issueList = [
39
+ {
40
+ number: 5,
41
+ title: "Crash on startup",
42
+ author: { login: "bob" },
43
+ updatedAt: "2026-08-01T00:00:00Z",
44
+ comments: [],
45
+ labels: [],
46
+ },
47
+ ];
48
+ const prDetail = {
49
+ number: 1,
50
+ title: "Fix the login bug",
51
+ author: { login: "alice" },
52
+ state: "OPEN",
53
+ isDraft: false,
54
+ labels: [],
55
+ assignees: [],
56
+ createdAt: "2026-08-01T00:00:00Z",
57
+ updatedAt: "2026-08-01T00:00:00Z",
58
+ body: "This fixes login.",
59
+ comments: [],
60
+ baseRefName: "main",
61
+ headRefName: "fix-login",
62
+ statusCheckRollup: [],
63
+ latestReviews: [],
64
+ files: [],
65
+ };
66
+
67
+ const defaultExec: ExecFn = async (command, args) => {
68
+ if (command === "gh" && args[0] === "pr" && args[1] === "list") {
69
+ return { stdout: JSON.stringify(prList), stderr: "", code: 0, killed: false };
70
+ }
71
+ if (command === "gh" && args[0] === "issue" && args[1] === "list") {
72
+ return { stdout: JSON.stringify(issueList), stderr: "", code: 0, killed: false };
73
+ }
74
+ if (command === "gh" && args[0] === "pr" && args[1] === "view") {
75
+ return { stdout: JSON.stringify(prDetail), stderr: "", code: 0, killed: false };
76
+ }
77
+ if (command === "gh" && args[0] === "pr" && args[1] === "review") {
78
+ return { stdout: "", stderr: "", code: 0, killed: false };
79
+ }
80
+ if (command === "gh" && args[0] === "api") {
81
+ return { stdout: "octocat\n", stderr: "", code: 0, killed: false };
82
+ }
83
+ return { stdout: "", stderr: `unexpected command: ${command} ${args.join(" ")}`, code: 1, killed: false };
84
+ };
85
+
86
+ const ui: AppUI = {
87
+ confirm: vi.fn(async () => true),
88
+ select: vi.fn(async () => undefined),
89
+ notify: vi.fn(),
90
+ ...overrides.ui,
91
+ };
92
+
93
+ const app = new GithubApp({
94
+ repo: "owner/repo",
95
+ cwd: "/repo",
96
+ exec: overrides.exec ?? defaultExec,
97
+ limit: 20,
98
+ ui,
99
+ theme: fakeTheme,
100
+ tui,
101
+ done,
102
+ });
103
+
104
+ return { app, done, ui, tui };
105
+ }
106
+
107
+ describe("GithubApp", () => {
108
+ it("renders without throwing before data has loaded", () => {
109
+ const { app } = makeApp();
110
+ const lines = app.render(80);
111
+ expect(lines.length).toBeGreaterThan(0);
112
+ for (const line of lines) {
113
+ expect(line.length).toBeLessThanOrEqual(80 + 20); // allow for ANSI codes in fake theme (no-ops here, so exact)
114
+ }
115
+ });
116
+
117
+ it("shows PR rows after the list loads", async () => {
118
+ const { app } = makeApp();
119
+ await flush();
120
+ const text = app.render(120).join("\n");
121
+ expect(text).toContain("owner/repo");
122
+ expect(text).toContain("#1");
123
+ expect(text).toContain("Fix the login bug");
124
+ });
125
+
126
+ it("switches to Issues on Tab and loads issue rows", async () => {
127
+ const { app } = makeApp();
128
+ await flush();
129
+ app.handleInput("\t");
130
+ await flush();
131
+ const text = app.render(120).join("\n");
132
+ expect(text).toContain("#5");
133
+ expect(text).toContain("Crash on startup");
134
+ });
135
+
136
+ it("filters the list by typing, and clearing with escape restores it", async () => {
137
+ const { app } = makeApp();
138
+ await flush();
139
+ app.handleInput("z");
140
+ app.handleInput("z");
141
+ app.handleInput("z");
142
+ let text = app.render(120).join("\n");
143
+ expect(text).toContain('No matches for "zzz"');
144
+
145
+ app.handleInput("\x1b"); // escape clears the filter
146
+ text = app.render(120).join("\n");
147
+ expect(text).toContain("Fix the login bug");
148
+ });
149
+
150
+ it("closes on escape when the list is not filtered", async () => {
151
+ const { app, done } = makeApp();
152
+ await flush();
153
+ app.handleInput("\x1b");
154
+ expect(done).toHaveBeenCalled();
155
+ });
156
+
157
+ it("opens PR detail on enter and shows the body", async () => {
158
+ const { app } = makeApp();
159
+ await flush();
160
+ app.render(120);
161
+ app.handleInput("\r");
162
+ await flush();
163
+ const text = app.render(120).join("\n");
164
+ expect(text).toContain("Fix the login bug");
165
+ expect(text).toContain("This fixes login.");
166
+ expect(text).toContain("approve");
167
+ });
168
+
169
+ it("returns to the list from detail on escape", async () => {
170
+ const { app } = makeApp();
171
+ await flush();
172
+ app.render(120);
173
+ app.handleInput("\r");
174
+ await flush();
175
+ app.handleInput("\x1b");
176
+ const text = app.render(120).join("\n");
177
+ expect(text).toContain("owner/repo");
178
+ });
179
+
180
+ it("approves the PR after confirmation", async () => {
181
+ const exec = vi.fn(async (command: string, args: string[]) => {
182
+ if (command === "gh" && args[0] === "pr" && args[1] === "list") {
183
+ return {
184
+ stdout: JSON.stringify([
185
+ { number: 1, title: "Fix the login bug", author: { login: "alice" }, isDraft: false, updatedAt: "2026-08-01T00:00:00Z" },
186
+ ]),
187
+ stderr: "",
188
+ code: 0,
189
+ killed: false,
190
+ };
191
+ }
192
+ if (command === "gh" && args[0] === "pr" && args[1] === "view") {
193
+ return {
194
+ stdout: JSON.stringify({
195
+ number: 1,
196
+ title: "Fix the login bug",
197
+ author: { login: "alice" },
198
+ state: "OPEN",
199
+ createdAt: "2026-08-01T00:00:00Z",
200
+ updatedAt: "2026-08-01T00:00:00Z",
201
+ body: "Body",
202
+ baseRefName: "main",
203
+ headRefName: "fix-login",
204
+ }),
205
+ stderr: "",
206
+ code: 0,
207
+ killed: false,
208
+ };
209
+ }
210
+ if (command === "gh" && args[0] === "api") {
211
+ return { stdout: "octocat\n", stderr: "", code: 0, killed: false };
212
+ }
213
+ if (command === "gh" && args[0] === "pr" && args[1] === "review") {
214
+ return { stdout: "", stderr: "", code: 0, killed: false };
215
+ }
216
+ return { stdout: "", stderr: "unexpected", code: 1, killed: false };
217
+ });
218
+
219
+ const confirm = vi.fn(async () => true);
220
+ const { app } = makeApp({ exec, ui: { confirm } });
221
+ await flush();
222
+ app.render(120);
223
+ app.handleInput("\r"); // open PR detail
224
+ await flush();
225
+ app.handleInput("a"); // approve
226
+ await flush();
227
+
228
+ expect(confirm).toHaveBeenCalledWith("Approve Pull Request", "Approve #1 'Fix the login bug' as @octocat?");
229
+ expect(exec).toHaveBeenCalledWith(
230
+ "gh",
231
+ ["pr", "review", "1", "--repo", "owner/repo", "--approve"],
232
+ expect.objectContaining({ cwd: "/repo" }),
233
+ );
234
+ });
235
+ });
@@ -0,0 +1,41 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { filterIssues, filterPullRequests } from "../src/ui/filter.ts";
3
+ import type { IssueSummary, PullRequestSummary } from "../src/types.ts";
4
+
5
+ const prs: PullRequestSummary[] = [
6
+ { number: 1, title: "Fix login bug", author: "a", isDraft: false, checkState: "pass", approvals: 0, changesRequested: 0, updatedAt: "2026-08-01T00:00:00Z" },
7
+ { number: 2, title: "Add dark mode toggle", author: "b", isDraft: false, checkState: "pass", approvals: 0, changesRequested: 0, updatedAt: "2026-08-01T00:00:00Z" },
8
+ ];
9
+
10
+ const issues: IssueSummary[] = [
11
+ { number: 10, title: "Crash on startup", author: "a", commentCount: 0, labels: [], updatedAt: "2026-08-01T00:00:00Z" },
12
+ { number: 20, title: "Typo in README", author: "b", commentCount: 0, labels: [], updatedAt: "2026-08-01T00:00:00Z" },
13
+ ];
14
+
15
+ describe("filterPullRequests", () => {
16
+ it("returns everything for an empty query", () => {
17
+ expect(filterPullRequests(prs, "")).toEqual(prs);
18
+ });
19
+
20
+ it("fuzzy-matches by title", () => {
21
+ expect(filterPullRequests(prs, "dark").map((p) => p.number)).toEqual([2]);
22
+ });
23
+
24
+ it("matches by number", () => {
25
+ expect(filterPullRequests(prs, "1").map((p) => p.number)).toEqual([1]);
26
+ });
27
+
28
+ it("returns nothing when nothing matches", () => {
29
+ expect(filterPullRequests(prs, "zzzzz")).toEqual([]);
30
+ });
31
+ });
32
+
33
+ describe("filterIssues", () => {
34
+ it("fuzzy-matches by title", () => {
35
+ expect(filterIssues(issues, "readme").map((i) => i.number)).toEqual([20]);
36
+ });
37
+
38
+ it("matches by number", () => {
39
+ expect(filterIssues(issues, "10").map((i) => i.number)).toEqual([10]);
40
+ });
41
+ });
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { relativeTime } from "../src/format.ts";
3
+
4
+ describe("relativeTime", () => {
5
+ const now = new Date("2026-09-01T12:00:00Z");
6
+
7
+ it("formats seconds", () => {
8
+ expect(relativeTime("2026-09-01T11:59:30Z", now)).toBe("30s ago");
9
+ });
10
+
11
+ it("formats minutes", () => {
12
+ expect(relativeTime("2026-09-01T11:45:00Z", now)).toBe("15m ago");
13
+ });
14
+
15
+ it("formats hours", () => {
16
+ expect(relativeTime("2026-09-01T09:00:00Z", now)).toBe("3h ago");
17
+ });
18
+
19
+ it("formats days", () => {
20
+ expect(relativeTime("2026-08-29T12:00:00Z", now)).toBe("3d ago");
21
+ });
22
+
23
+ it("formats months", () => {
24
+ expect(relativeTime("2026-06-01T12:00:00Z", now)).toBe("3mo ago");
25
+ });
26
+
27
+ it("formats years", () => {
28
+ expect(relativeTime("2024-09-01T12:00:00Z", now)).toBe("2y ago");
29
+ });
30
+
31
+ it("formats just now for sub-second differences", () => {
32
+ expect(relativeTime("2026-09-01T12:00:00Z", now)).toBe("just now");
33
+ });
34
+ });
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { checkGhAuthenticated, checkGhInstalled } from "../src/gh-cli.ts";
3
+ import type { ExecFn } from "../src/types.ts";
4
+
5
+ describe("checkGhInstalled", () => {
6
+ it("returns ok when gh --version succeeds", async () => {
7
+ const exec: ExecFn = async () => ({ stdout: "gh version 2.98.0", stderr: "", code: 0, killed: false });
8
+ expect(await checkGhInstalled(exec)).toEqual({ ok: true });
9
+ });
10
+
11
+ it("returns an error when gh is not found", async () => {
12
+ const exec: ExecFn = async () => {
13
+ throw new Error("spawn gh ENOENT");
14
+ };
15
+ const result = await checkGhInstalled(exec);
16
+ expect(result.ok).toBe(false);
17
+ });
18
+ });
19
+
20
+ describe("checkGhAuthenticated", () => {
21
+ it("returns ok when gh auth status exits 0", async () => {
22
+ const exec: ExecFn = async () => ({ stdout: "Logged in", stderr: "", code: 0, killed: false });
23
+ expect(await checkGhAuthenticated(exec, "/cwd")).toEqual({ ok: true });
24
+ });
25
+
26
+ it("returns an error when gh auth status exits non-zero", async () => {
27
+ const exec: ExecFn = async () => ({ stdout: "", stderr: "not logged in", code: 1, killed: false });
28
+ const result = await checkGhAuthenticated(exec, "/cwd");
29
+ expect(result.ok).toBe(false);
30
+ });
31
+ });