@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/LICENSE +21 -0
- package/README.md +103 -0
- package/package.json +36 -0
- package/src/format.ts +22 -0
- package/src/gh-cli.ts +23 -0
- package/src/gh-data.ts +393 -0
- package/src/index.ts +47 -0
- package/src/repo.ts +54 -0
- package/src/settings.ts +58 -0
- package/src/types.ts +106 -0
- package/src/ui/app.ts +632 -0
- package/src/ui/filter.ts +12 -0
- package/src/ui/messages.ts +24 -0
- package/src/ui/rows.ts +98 -0
- package/src/ui/two-line-list.ts +78 -0
- package/tests/app.test.ts +235 -0
- package/tests/filter.test.ts +41 -0
- package/tests/format.test.ts +34 -0
- package/tests/gh-cli.test.ts +31 -0
- package/tests/gh-data.test.ts +259 -0
- package/tests/messages.test.ts +24 -0
- package/tests/repo.test.ts +76 -0
- package/tests/rows.test.ts +90 -0
- package/tests/settings.test.ts +54 -0
- package/tests/two-line-list.test.ts +62 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
approvePullRequest,
|
|
4
|
+
fetchIssueDetail,
|
|
5
|
+
fetchIssues,
|
|
6
|
+
fetchPullRequestDetail,
|
|
7
|
+
fetchPullRequests,
|
|
8
|
+
getAllowedMergeMethods,
|
|
9
|
+
getCurrentAccount,
|
|
10
|
+
mergePullRequest,
|
|
11
|
+
} from "../src/gh-data.ts";
|
|
12
|
+
import type { ExecFn, ExecResult } from "../src/types.ts";
|
|
13
|
+
|
|
14
|
+
function jsonExec(stdout: unknown): ExecFn {
|
|
15
|
+
return async () => ({ stdout: JSON.stringify(stdout), stderr: "", code: 0, killed: false });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function failingExec(stderr: string, code = 1): ExecFn {
|
|
19
|
+
return async () => ({ stdout: "", stderr, code, killed: false });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const ok = (over: Partial<ExecResult> = {}): ExecResult => ({ stdout: "", stderr: "", code: 0, killed: false, ...over });
|
|
23
|
+
|
|
24
|
+
describe("fetchPullRequests", () => {
|
|
25
|
+
it("maps fields, computes check state, review counts, and sorts most-recently-updated first", async () => {
|
|
26
|
+
const exec = jsonExec([
|
|
27
|
+
{
|
|
28
|
+
number: 1,
|
|
29
|
+
title: "Older PR",
|
|
30
|
+
author: { login: "alice" },
|
|
31
|
+
isDraft: false,
|
|
32
|
+
updatedAt: "2026-08-01T00:00:00Z",
|
|
33
|
+
statusCheckRollup: [{ __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "build" }],
|
|
34
|
+
latestReviews: [{ author: { login: "bob" }, state: "APPROVED" }],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
number: 2,
|
|
38
|
+
title: "Newer PR",
|
|
39
|
+
author: { login: "carol" },
|
|
40
|
+
isDraft: true,
|
|
41
|
+
updatedAt: "2026-08-15T00:00:00Z",
|
|
42
|
+
statusCheckRollup: [{ __typename: "CheckRun", status: "IN_PROGRESS", name: "build" }],
|
|
43
|
+
latestReviews: [{ author: { login: "dave" }, state: "CHANGES_REQUESTED" }],
|
|
44
|
+
},
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
const result = await fetchPullRequests(exec, { repo: "owner/repo", cwd: "/repo", limit: 20 });
|
|
48
|
+
expect(result.ok).toBe(true);
|
|
49
|
+
if (!result.ok) return;
|
|
50
|
+
|
|
51
|
+
expect(result.data.map((pr) => pr.number)).toEqual([2, 1]);
|
|
52
|
+
expect(result.data[0]).toMatchObject({
|
|
53
|
+
number: 2,
|
|
54
|
+
title: "Newer PR",
|
|
55
|
+
author: "carol",
|
|
56
|
+
isDraft: true,
|
|
57
|
+
checkState: "pending",
|
|
58
|
+
changesRequested: 1,
|
|
59
|
+
approvals: 0,
|
|
60
|
+
});
|
|
61
|
+
expect(result.data[1]).toMatchObject({
|
|
62
|
+
checkState: "pass",
|
|
63
|
+
approvals: 1,
|
|
64
|
+
changesRequested: 0,
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("reports checkState 'none' when there are no checks", async () => {
|
|
69
|
+
const exec = jsonExec([
|
|
70
|
+
{ number: 1, title: "PR", author: { login: "a" }, updatedAt: "2026-08-01T00:00:00Z", statusCheckRollup: [] },
|
|
71
|
+
]);
|
|
72
|
+
const result = await fetchPullRequests(exec, { repo: "owner/repo", cwd: "/repo", limit: 20 });
|
|
73
|
+
expect(result.ok).toBe(true);
|
|
74
|
+
if (result.ok) expect(result.data[0]?.checkState).toBe("none");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("reports checkState 'fail' when any check failed", async () => {
|
|
78
|
+
const exec = jsonExec([
|
|
79
|
+
{
|
|
80
|
+
number: 1,
|
|
81
|
+
title: "PR",
|
|
82
|
+
author: { login: "a" },
|
|
83
|
+
updatedAt: "2026-08-01T00:00:00Z",
|
|
84
|
+
statusCheckRollup: [
|
|
85
|
+
{ __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "lint" },
|
|
86
|
+
{ __typename: "CheckRun", status: "COMPLETED", conclusion: "FAILURE", name: "test" },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
]);
|
|
90
|
+
const result = await fetchPullRequests(exec, { repo: "owner/repo", cwd: "/repo", limit: 20 });
|
|
91
|
+
expect(result.ok).toBe(true);
|
|
92
|
+
if (result.ok) expect(result.data[0]?.checkState).toBe("fail");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("returns an error when gh fails", async () => {
|
|
96
|
+
const result = await fetchPullRequests(failingExec("boom"), { repo: "owner/repo", cwd: "/repo", limit: 20 });
|
|
97
|
+
expect(result).toEqual({ ok: false, error: "boom" });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns an empty list for a repo with zero open PRs", async () => {
|
|
101
|
+
const result = await fetchPullRequests(jsonExec([]), { repo: "owner/repo", cwd: "/repo", limit: 20 });
|
|
102
|
+
expect(result).toEqual({ ok: true, data: [] });
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("fetchIssues", () => {
|
|
107
|
+
it("maps fields including comment count and labels", async () => {
|
|
108
|
+
const exec = jsonExec([
|
|
109
|
+
{
|
|
110
|
+
number: 5,
|
|
111
|
+
title: "Bug",
|
|
112
|
+
author: { login: "eve" },
|
|
113
|
+
updatedAt: "2026-08-20T00:00:00Z",
|
|
114
|
+
comments: [{ author: { login: "x" }, body: "hi", createdAt: "2026-08-20T01:00:00Z" }],
|
|
115
|
+
labels: [{ name: "bug" }, { name: "p1" }],
|
|
116
|
+
},
|
|
117
|
+
]);
|
|
118
|
+
const result = await fetchIssues(exec, { repo: "owner/repo", cwd: "/repo", limit: 20 });
|
|
119
|
+
expect(result.ok).toBe(true);
|
|
120
|
+
if (result.ok) {
|
|
121
|
+
expect(result.data[0]).toMatchObject({
|
|
122
|
+
number: 5,
|
|
123
|
+
title: "Bug",
|
|
124
|
+
author: "eve",
|
|
125
|
+
commentCount: 1,
|
|
126
|
+
labels: ["bug", "p1"],
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("fetchPullRequestDetail", () => {
|
|
133
|
+
it("maps full detail including checks, reviews, files, and comments", async () => {
|
|
134
|
+
const exec = jsonExec({
|
|
135
|
+
number: 9,
|
|
136
|
+
title: "Fix login bug",
|
|
137
|
+
author: { login: "alice" },
|
|
138
|
+
state: "OPEN",
|
|
139
|
+
isDraft: false,
|
|
140
|
+
labels: [{ name: "bug" }],
|
|
141
|
+
assignees: [{ login: "bob" }],
|
|
142
|
+
createdAt: "2026-08-01T00:00:00Z",
|
|
143
|
+
updatedAt: "2026-08-02T00:00:00Z",
|
|
144
|
+
body: "Fixes the thing.",
|
|
145
|
+
comments: [{ author: { login: "carol" }, body: "LGTM", createdAt: "2026-08-02T01:00:00Z" }],
|
|
146
|
+
baseRefName: "main",
|
|
147
|
+
headRefName: "fix-login",
|
|
148
|
+
statusCheckRollup: [{ __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS", name: "ci" }],
|
|
149
|
+
latestReviews: [{ author: { login: "dave" }, state: "APPROVED" }],
|
|
150
|
+
files: [{ path: "src/login.ts", additions: 10, deletions: 2 }],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const result = await fetchPullRequestDetail(exec, { repo: "owner/repo", cwd: "/repo", number: 9 });
|
|
154
|
+
expect(result.ok).toBe(true);
|
|
155
|
+
if (!result.ok) return;
|
|
156
|
+
|
|
157
|
+
expect(result.data).toMatchObject({
|
|
158
|
+
number: 9,
|
|
159
|
+
title: "Fix login bug",
|
|
160
|
+
author: "alice",
|
|
161
|
+
baseRefName: "main",
|
|
162
|
+
headRefName: "fix-login",
|
|
163
|
+
checks: [{ name: "ci", state: "pass" }],
|
|
164
|
+
reviews: [{ author: "dave", state: "APPROVED" }],
|
|
165
|
+
files: [{ path: "src/login.ts", additions: 10, deletions: 2 }],
|
|
166
|
+
comments: [{ author: "carol", body: "LGTM", createdAt: "2026-08-02T01:00:00Z" }],
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("fetchIssueDetail", () => {
|
|
172
|
+
it("maps full detail without PR-specific fields", async () => {
|
|
173
|
+
const exec = jsonExec({
|
|
174
|
+
number: 3,
|
|
175
|
+
title: "Crash on start",
|
|
176
|
+
author: { login: "alice" },
|
|
177
|
+
state: "OPEN",
|
|
178
|
+
labels: [{ name: "bug" }],
|
|
179
|
+
assignees: [],
|
|
180
|
+
createdAt: "2026-08-01T00:00:00Z",
|
|
181
|
+
updatedAt: "2026-08-02T00:00:00Z",
|
|
182
|
+
body: "It crashes.",
|
|
183
|
+
comments: [{ author: { login: "bob" }, body: "Repro?", createdAt: "2026-08-02T01:00:00Z" }],
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const result = await fetchIssueDetail(exec, { repo: "owner/repo", cwd: "/repo", number: 3 });
|
|
187
|
+
expect(result.ok).toBe(true);
|
|
188
|
+
if (result.ok) {
|
|
189
|
+
expect(result.data.title).toBe("Crash on start");
|
|
190
|
+
expect(result.data.comments).toEqual([{ author: "bob", body: "Repro?", createdAt: "2026-08-02T01:00:00Z" }]);
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("getAllowedMergeMethods", () => {
|
|
196
|
+
it("returns only the allowed methods, in squash/merge/rebase order", async () => {
|
|
197
|
+
const exec = jsonExec({ squashMergeAllowed: true, mergeCommitAllowed: false, rebaseMergeAllowed: true });
|
|
198
|
+
const result = await getAllowedMergeMethods(exec, { repo: "owner/repo", cwd: "/repo" });
|
|
199
|
+
expect(result).toEqual({ ok: true, data: ["squash", "rebase"] });
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe("approvePullRequest", () => {
|
|
204
|
+
it("calls gh pr review --approve and succeeds", async () => {
|
|
205
|
+
const exec = vi.fn(async () => ok());
|
|
206
|
+
const result = await approvePullRequest(exec, { repo: "owner/repo", cwd: "/repo", number: 42 });
|
|
207
|
+
expect(result).toEqual({ ok: true, data: true });
|
|
208
|
+
expect(exec).toHaveBeenCalledWith(
|
|
209
|
+
"gh",
|
|
210
|
+
["pr", "review", "42", "--repo", "owner/repo", "--approve"],
|
|
211
|
+
expect.objectContaining({ cwd: "/repo" }),
|
|
212
|
+
);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("surfaces gh's error on failure", async () => {
|
|
216
|
+
const result = await approvePullRequest(failingExec("review required"), { repo: "owner/repo", cwd: "/repo", number: 42 });
|
|
217
|
+
expect(result).toEqual({ ok: false, error: "review required" });
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe("mergePullRequest", () => {
|
|
222
|
+
it("passes the correct flag for each merge method", async () => {
|
|
223
|
+
const exec = vi.fn(async () => ok());
|
|
224
|
+
await mergePullRequest(exec, { repo: "owner/repo", cwd: "/repo", number: 7, method: "squash" });
|
|
225
|
+
expect(exec).toHaveBeenLastCalledWith(
|
|
226
|
+
"gh",
|
|
227
|
+
["pr", "merge", "7", "--repo", "owner/repo", "--squash"],
|
|
228
|
+
expect.objectContaining({ cwd: "/repo" }),
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
await mergePullRequest(exec, { repo: "owner/repo", cwd: "/repo", number: 7, method: "rebase" });
|
|
232
|
+
expect(exec).toHaveBeenLastCalledWith(
|
|
233
|
+
"gh",
|
|
234
|
+
["pr", "merge", "7", "--repo", "owner/repo", "--rebase"],
|
|
235
|
+
expect.objectContaining({ cwd: "/repo" }),
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("surfaces gh's error on a blocked merge", async () => {
|
|
240
|
+
const result = await mergePullRequest(failingExec("required checks pending"), {
|
|
241
|
+
repo: "owner/repo",
|
|
242
|
+
cwd: "/repo",
|
|
243
|
+
number: 7,
|
|
244
|
+
method: "merge",
|
|
245
|
+
});
|
|
246
|
+
expect(result).toEqual({ ok: false, error: "required checks pending" });
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
describe("getCurrentAccount", () => {
|
|
251
|
+
it("returns the trimmed login on success", async () => {
|
|
252
|
+
const exec: ExecFn = async () => ({ stdout: "octocat\n", stderr: "", code: 0, killed: false });
|
|
253
|
+
expect(await getCurrentAccount(exec, "/repo")).toBe("octocat");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("returns undefined on failure", async () => {
|
|
257
|
+
expect(await getCurrentAccount(failingExec("not logged in"), "/repo")).toBeUndefined();
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { approveConfirmMessage, mergeConfirmMessage, mergeMethodLabel } from "../src/ui/messages.ts";
|
|
3
|
+
|
|
4
|
+
describe("approveConfirmMessage", () => {
|
|
5
|
+
it("names the PR number, title, and acting account", () => {
|
|
6
|
+
const message = approveConfirmMessage({ number: 123, title: "Fix login bug" }, "octocat");
|
|
7
|
+
expect(message).toBe("Approve #123 'Fix login bug' as @octocat?");
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("mergeMethodLabel", () => {
|
|
12
|
+
it("labels each merge method", () => {
|
|
13
|
+
expect(mergeMethodLabel("squash")).toBe("Squash and merge");
|
|
14
|
+
expect(mergeMethodLabel("merge")).toBe("Merge");
|
|
15
|
+
expect(mergeMethodLabel("rebase")).toBe("Rebase and merge");
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("mergeConfirmMessage", () => {
|
|
20
|
+
it("names the PR number, title, method, base branch, and acting account", () => {
|
|
21
|
+
const message = mergeConfirmMessage({ number: 123, title: "Fix login bug", baseRefName: "main" }, "squash", "octocat");
|
|
22
|
+
expect(message).toBe("Squash and merge #123 'Fix login bug' into main as @octocat?");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { detectRepo, parseGitHubRemote, pickRemoteUrl } from "../src/repo.ts";
|
|
3
|
+
import type { ExecFn } from "../src/types.ts";
|
|
4
|
+
|
|
5
|
+
describe("parseGitHubRemote", () => {
|
|
6
|
+
it("parses an https remote", () => {
|
|
7
|
+
expect(parseGitHubRemote("https://github.com/owner/repo.git")).toBe("owner/repo");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("parses an https remote without .git suffix", () => {
|
|
11
|
+
expect(parseGitHubRemote("https://github.com/owner/repo")).toBe("owner/repo");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("parses an ssh remote", () => {
|
|
15
|
+
expect(parseGitHubRemote("git@github.com:owner/repo.git")).toBe("owner/repo");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("parses an ssh:// remote", () => {
|
|
19
|
+
expect(parseGitHubRemote("ssh://git@github.com/owner/repo.git")).toBe("owner/repo");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns undefined for a non-GitHub remote", () => {
|
|
23
|
+
expect(parseGitHubRemote("https://gitlab.com/owner/repo.git")).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("pickRemoteUrl", () => {
|
|
28
|
+
it("prefers origin when multiple remotes are present", () => {
|
|
29
|
+
const output = [
|
|
30
|
+
"upstream\thttps://github.com/upstream-owner/repo.git (fetch)",
|
|
31
|
+
"upstream\thttps://github.com/upstream-owner/repo.git (push)",
|
|
32
|
+
"origin\thttps://github.com/owner/repo.git (fetch)",
|
|
33
|
+
"origin\thttps://github.com/owner/repo.git (push)",
|
|
34
|
+
].join("\n");
|
|
35
|
+
expect(pickRemoteUrl(output)).toBe("https://github.com/owner/repo.git");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("falls back to the first remote when there is no origin", () => {
|
|
39
|
+
const output = ["upstream\thttps://github.com/owner/repo.git (fetch)"].join("\n");
|
|
40
|
+
expect(pickRemoteUrl(output)).toBe("https://github.com/owner/repo.git");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns undefined for empty output", () => {
|
|
44
|
+
expect(pickRemoteUrl("")).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("detectRepo", () => {
|
|
49
|
+
it("returns the repo when git remote resolves to GitHub", async () => {
|
|
50
|
+
const exec: ExecFn = async () => ({
|
|
51
|
+
stdout: "origin\thttps://github.com/owner/repo.git (fetch)\norigin\thttps://github.com/owner/repo.git (push)\n",
|
|
52
|
+
stderr: "",
|
|
53
|
+
code: 0,
|
|
54
|
+
killed: false,
|
|
55
|
+
});
|
|
56
|
+
const result = await detectRepo(exec, "/some/dir");
|
|
57
|
+
expect(result).toEqual({ ok: true, repo: "owner/repo" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("fails when cwd is not a git repository", async () => {
|
|
61
|
+
const exec: ExecFn = async () => ({ stdout: "", stderr: "not a git repository", code: 128, killed: false });
|
|
62
|
+
const result = await detectRepo(exec, "/some/dir");
|
|
63
|
+
expect(result.ok).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("fails when there is no GitHub remote", async () => {
|
|
67
|
+
const exec: ExecFn = async () => ({
|
|
68
|
+
stdout: "origin\thttps://gitlab.com/owner/repo.git (fetch)\n",
|
|
69
|
+
stderr: "",
|
|
70
|
+
code: 0,
|
|
71
|
+
killed: false,
|
|
72
|
+
});
|
|
73
|
+
const result = await detectRepo(exec, "/some/dir");
|
|
74
|
+
expect(result.ok).toBe(false);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
buildIssueRowPlan,
|
|
5
|
+
buildPrRowPlan,
|
|
6
|
+
layoutIssueRowLines,
|
|
7
|
+
layoutPrRowLines,
|
|
8
|
+
prStatusColor,
|
|
9
|
+
} from "../src/ui/rows.ts";
|
|
10
|
+
import type { IssueSummary, PullRequestSummary } from "../src/types.ts";
|
|
11
|
+
|
|
12
|
+
const now = new Date("2026-09-01T12:00:00Z");
|
|
13
|
+
|
|
14
|
+
const basePr: PullRequestSummary = {
|
|
15
|
+
number: 42,
|
|
16
|
+
title: "Fix the login bug that was very very long indeed",
|
|
17
|
+
author: "alice",
|
|
18
|
+
isDraft: false,
|
|
19
|
+
checkState: "pass",
|
|
20
|
+
approvals: 2,
|
|
21
|
+
changesRequested: 0,
|
|
22
|
+
updatedAt: "2026-09-01T09:00:00Z",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const baseIssue: IssueSummary = {
|
|
26
|
+
number: 7,
|
|
27
|
+
title: "Crash on startup when config is missing entirely somehow",
|
|
28
|
+
author: "bob",
|
|
29
|
+
commentCount: 3,
|
|
30
|
+
labels: ["bug", "p1"],
|
|
31
|
+
updatedAt: "2026-09-01T10:00:00Z",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("buildPrRowPlan / layoutPrRowLines", () => {
|
|
35
|
+
it("puts the check symbol and title on the first line, and number/author/time on the second", () => {
|
|
36
|
+
const plan = buildPrRowPlan(basePr, now);
|
|
37
|
+
const { symbol, title, meta } = layoutPrRowLines(plan, 100);
|
|
38
|
+
expect(symbol).toBe("✓");
|
|
39
|
+
expect(title).toContain("Fix the login bug");
|
|
40
|
+
expect(meta).toContain("#42");
|
|
41
|
+
expect(meta).toContain("@alice");
|
|
42
|
+
expect(meta).toContain("3h ago");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("shows a draft indicator on the title only when the PR is a draft", () => {
|
|
46
|
+
const draft = layoutPrRowLines(buildPrRowPlan({ ...basePr, isDraft: true }, now), 100);
|
|
47
|
+
expect(draft.title).toContain("draft");
|
|
48
|
+
|
|
49
|
+
const ready = layoutPrRowLines(buildPrRowPlan({ ...basePr, isDraft: false }, now), 100);
|
|
50
|
+
expect(ready.title).not.toContain("draft");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("shows review counts in the meta line", () => {
|
|
54
|
+
const { meta } = layoutPrRowLines(buildPrRowPlan({ ...basePr, approvals: 2, changesRequested: 1 }, now), 100);
|
|
55
|
+
expect(meta).toContain("2");
|
|
56
|
+
expect(meta).toContain("1");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("never lets the title or meta line exceed the requested width", () => {
|
|
60
|
+
const { title, meta } = layoutPrRowLines(buildPrRowPlan(basePr, now), 40);
|
|
61
|
+
expect(visibleWidth(title)).toBeLessThanOrEqual(40);
|
|
62
|
+
expect(visibleWidth(meta)).toBeLessThanOrEqual(40);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("maps check state to a status color key", () => {
|
|
66
|
+
expect(prStatusColor(buildPrRowPlan({ ...basePr, checkState: "fail" }, now))).toBe("error");
|
|
67
|
+
expect(prStatusColor(buildPrRowPlan({ ...basePr, checkState: "pending" }, now))).toBe("warning");
|
|
68
|
+
expect(prStatusColor(buildPrRowPlan({ ...basePr, checkState: "pass" }, now))).toBe("success");
|
|
69
|
+
expect(prStatusColor(buildPrRowPlan({ ...basePr, checkState: "none" }, now))).toBe("warning");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("buildIssueRowPlan / layoutIssueRowLines", () => {
|
|
74
|
+
it("puts the title on the first line, and number/author/comments/labels/time on the second", () => {
|
|
75
|
+
const { title, meta } = layoutIssueRowLines(buildIssueRowPlan(baseIssue, now), 100);
|
|
76
|
+
expect(title).toContain("Crash on startup");
|
|
77
|
+
expect(meta).toContain("#7");
|
|
78
|
+
expect(meta).toContain("@bob");
|
|
79
|
+
expect(meta).toContain("3");
|
|
80
|
+
expect(meta).toContain("bug");
|
|
81
|
+
expect(meta).toContain("p1");
|
|
82
|
+
expect(meta).toContain("2h ago");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("never lets the title or meta line exceed the requested width", () => {
|
|
86
|
+
const { title, meta } = layoutIssueRowLines(buildIssueRowPlan(baseIssue, now), 30);
|
|
87
|
+
expect(visibleWidth(title)).toBeLessThanOrEqual(30);
|
|
88
|
+
expect(visibleWidth(meta)).toBeLessThanOrEqual(30);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { DEFAULT_FETCH_LIMIT, getFetchLimit } from "../src/settings.ts";
|
|
3
|
+
|
|
4
|
+
function readFileFrom(files: Record<string, string>) {
|
|
5
|
+
return async (path: string) => {
|
|
6
|
+
const content = files[path];
|
|
7
|
+
if (content === undefined) {
|
|
8
|
+
const error = new Error(`ENOENT: ${path}`) as NodeJS.ErrnoException;
|
|
9
|
+
error.code = "ENOENT";
|
|
10
|
+
throw error;
|
|
11
|
+
}
|
|
12
|
+
return content;
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("getFetchLimit", () => {
|
|
17
|
+
it("falls back to the default when no settings files exist", async () => {
|
|
18
|
+
const limit = await getFetchLimit({ cwd: "/repo", home: "/home/user", readFile: readFileFrom({}) });
|
|
19
|
+
expect(limit).toBe(DEFAULT_FETCH_LIMIT);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("reads the fetch limit from global settings", async () => {
|
|
23
|
+
const readFile = readFileFrom({
|
|
24
|
+
"/home/user/.pi/agent/settings.json": JSON.stringify({ "pi-github": { fetchLimit: 50 } }),
|
|
25
|
+
});
|
|
26
|
+
const limit = await getFetchLimit({ cwd: "/repo", home: "/home/user", readFile });
|
|
27
|
+
expect(limit).toBe(50);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("prefers project settings over global settings", async () => {
|
|
31
|
+
const readFile = readFileFrom({
|
|
32
|
+
"/home/user/.pi/agent/settings.json": JSON.stringify({ "pi-github": { fetchLimit: 50 } }),
|
|
33
|
+
"/repo/.pi/settings.json": JSON.stringify({ "pi-github": { fetchLimit: 5 } }),
|
|
34
|
+
});
|
|
35
|
+
const limit = await getFetchLimit({ cwd: "/repo", home: "/home/user", readFile });
|
|
36
|
+
expect(limit).toBe(5);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("ignores malformed JSON and falls back to the default", async () => {
|
|
40
|
+
const readFile = readFileFrom({
|
|
41
|
+
"/repo/.pi/settings.json": "{ not json",
|
|
42
|
+
});
|
|
43
|
+
const limit = await getFetchLimit({ cwd: "/repo", home: "/home/user", readFile });
|
|
44
|
+
expect(limit).toBe(DEFAULT_FETCH_LIMIT);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("ignores a non-positive-integer fetchLimit and falls back to the default", async () => {
|
|
48
|
+
const readFile = readFileFrom({
|
|
49
|
+
"/repo/.pi/settings.json": JSON.stringify({ "pi-github": { fetchLimit: -3 } }),
|
|
50
|
+
});
|
|
51
|
+
const limit = await getFetchLimit({ cwd: "/repo", home: "/home/user", readFile });
|
|
52
|
+
expect(limit).toBe(DEFAULT_FETCH_LIMIT);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { TwoLineList } from "../src/ui/two-line-list.ts";
|
|
3
|
+
|
|
4
|
+
function makeList(count: number, maxVisible: number) {
|
|
5
|
+
const items = Array.from({ length: count }, (_, i) => ({ value: String(i), data: `item-${i}` }));
|
|
6
|
+
return new TwoLineList(items, maxVisible, {
|
|
7
|
+
renderRow: (data, isSelected) => [`${isSelected ? "SEL:" : ""}${data}:line1`, `${isSelected ? "SEL:" : ""}${data}:line2`],
|
|
8
|
+
highlightRow: (line) => `[HL]${line}[/HL]`,
|
|
9
|
+
scrollInfo: (t) => `[SCROLL]${t}`,
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("TwoLineList", () => {
|
|
14
|
+
it("starts selection at the first item", () => {
|
|
15
|
+
const list = makeList(3, 3);
|
|
16
|
+
expect(list.getSelectedItem()?.value).toBe("0");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("moveDown/moveUp wrap around and fire onSelectionChange", () => {
|
|
20
|
+
const list = makeList(3, 3);
|
|
21
|
+
const seen: string[] = [];
|
|
22
|
+
list.onSelectionChange = (item) => seen.push(item.value);
|
|
23
|
+
|
|
24
|
+
list.moveDown();
|
|
25
|
+
list.moveDown();
|
|
26
|
+
list.moveDown(); // wraps back to 0
|
|
27
|
+
expect(seen).toEqual(["1", "2", "0"]);
|
|
28
|
+
|
|
29
|
+
list.moveUp(); // wraps to last
|
|
30
|
+
expect(seen).toEqual(["1", "2", "0", "2"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("renders two lines per visible item, marking only the selected item", () => {
|
|
34
|
+
const list = makeList(2, 2);
|
|
35
|
+
list.setSelectedIndex(1);
|
|
36
|
+
const lines = list.render(80);
|
|
37
|
+
expect(lines).toEqual([
|
|
38
|
+
"item-0:line1",
|
|
39
|
+
"item-0:line2",
|
|
40
|
+
"[HL]SEL:item-1:line1[/HL]",
|
|
41
|
+
"[HL]SEL:item-1:line2[/HL]",
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("shows a scroll indicator once items exceed maxVisible", () => {
|
|
46
|
+
const list = makeList(5, 2);
|
|
47
|
+
const noScroll = new TwoLineList(
|
|
48
|
+
Array.from({ length: 2 }, (_, i) => ({ value: String(i), data: `item-${i}` })),
|
|
49
|
+
2,
|
|
50
|
+
{ renderRow: (d, s) => [`${s}${d}`, `${s}${d}`], highlightRow: (l) => l, scrollInfo: (t) => `[SCROLL]${t}` },
|
|
51
|
+
);
|
|
52
|
+
expect(noScroll.render(80).some((line) => line.includes("SCROLL"))).toBe(false);
|
|
53
|
+
|
|
54
|
+
expect(list.render(80).some((line) => line.includes("[SCROLL]"))).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns no lines for an empty list", () => {
|
|
58
|
+
const list = makeList(0, 3);
|
|
59
|
+
expect(list.render(80)).toEqual([]);
|
|
60
|
+
expect(list.getSelectedItem()).toBeNull();
|
|
61
|
+
});
|
|
62
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"resolveJsonModule": true,
|
|
12
|
+
"noEmit": true,
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"types": ["node"]
|
|
15
|
+
},
|
|
16
|
+
"include": ["src", "tests"]
|
|
17
|
+
}
|