@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pi-github contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,103 @@
1
+ # pi-github
2
+
3
+ A [`pi`](https://pi.dev) extension that browses the current repo's open GitHub Pull Requests and Issues from a full-screen TUI inside `pi` — list, drill into details, and (for PRs) approve or merge — without leaving your terminal.
4
+
5
+ ## Prerequisites
6
+
7
+ - The [`gh` CLI](https://cli.github.com) installed and authenticated (`gh auth login`)
8
+ - The current directory must be a git repository with a GitHub remote
9
+
10
+ `/github` checks both of these on invocation and shows a clear error instead of opening the screen if either is missing.
11
+
12
+ ## Usage
13
+
14
+ Run `/github` inside `pi`. It detects `owner/repo` from your git remote and opens a full-screen browser for that repo's open Pull Requests and Issues, sorted most-recently-updated first.
15
+
16
+ ### Keybindings
17
+
18
+ **List view** (Pull Requests / Issues):
19
+
20
+ | Key | Action |
21
+ |-----|--------|
22
+ | `↑` / `↓` | Move selection |
23
+ | `Enter` | Open the selected item's detail view |
24
+ | `Tab` | Switch between Pull Requests and Issues |
25
+ | _(type)_ | Fuzzy-filter the visible list by title or number |
26
+ | `Backspace` | Remove the last filter character |
27
+ | `r` | Refresh the current section from GitHub |
28
+ | `Esc` | Clear the filter, then close the screen |
29
+
30
+ **Detail view** (Pull Request or Issue):
31
+
32
+ | Key | Action |
33
+ |-----|--------|
34
+ | `↑` / `↓` / `PageUp` / `PageDown` | Scroll |
35
+ | `r` | Refresh this item |
36
+ | `a` | Approve (Pull Requests only) |
37
+ | `m` | Merge (Pull Requests only) |
38
+ | `Esc` | Back to the list |
39
+
40
+ Issues are read-only — no approve/merge actions are shown on the Issue detail screen.
41
+
42
+ ### Approve
43
+
44
+ `a` on a PR detail screen shows a confirm dialog naming the PR number, title, and the GitHub account the action will run as. Confirming runs `gh pr review --approve` and refreshes the detail view.
45
+
46
+ ### Merge
47
+
48
+ `m` on a PR detail screen detects which merge methods the repository allows (squash / merge commit / rebase). If more than one is allowed, you're prompted to pick one first. It then shows a confirm dialog naming the PR, chosen method, and acting account before running `gh pr merge`. `pi-github` doesn't pre-check CI or review status — a blocked merge simply surfaces GitHub's own error.
49
+
50
+ ## Configuration
51
+
52
+ The number of PRs/Issues fetched per section is configurable (default: **20**). Set it in `.pi/settings.json` (project) or `~/.pi/agent/settings.json` (global) under a `pi-github` key — project settings take precedence:
53
+
54
+ ```json
55
+ {
56
+ "pi-github": {
57
+ "fetchLimit": 50
58
+ }
59
+ }
60
+ ```
61
+
62
+ ## Installing locally for development
63
+
64
+ Add a project-local extension entry to `.pi/settings.json`:
65
+
66
+ ```json
67
+ {
68
+ "extensions": ["/absolute/path/to/pi-github/src/index.ts"]
69
+ }
70
+ ```
71
+
72
+ Or symlink the package into `.pi/extensions/`:
73
+
74
+ ```bash
75
+ mkdir -p .pi/extensions
76
+ ln -s /absolute/path/to/pi-github .pi/extensions/pi-github
77
+ ```
78
+
79
+ Either way, run `npm install` in this directory first (needed for typechecking and tests; `pi` itself loads the TypeScript source directly via `jiti`, no build step required). After editing the source, run `/reload` inside `pi` to pick up changes without restarting.
80
+
81
+ Once this repository has a pushed remote, it can also be installed with:
82
+
83
+ ```bash
84
+ pi install git:github.com/hpstuff/pi-github
85
+ ```
86
+
87
+ Once published to npm, it can also be installed with:
88
+
89
+ ```bash
90
+ pi install npm:@rumen.rusanov/pi-github
91
+ ```
92
+
93
+ ## Development
94
+
95
+ ```bash
96
+ npm install
97
+ npm run typecheck
98
+ npm test
99
+ ```
100
+
101
+ ## License
102
+
103
+ MIT
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@rumen.rusanov/pi-github",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "description": "Browse and act on the current repo's open GitHub Pull Requests and Issues from a full-screen pi TUI.",
8
+ "keywords": ["pi-package"],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/hpstuff/pi-github.git"
12
+ },
13
+ "publishConfig": {
14
+ "access": "public"
15
+ },
16
+ "scripts": {
17
+ "typecheck": "tsc --noEmit",
18
+ "test": "vitest run"
19
+ },
20
+ "pi": {
21
+ "extensions": ["./src/index.ts"]
22
+ },
23
+ "peerDependencies": {
24
+ "@earendil-works/pi-coding-agent": "*",
25
+ "@earendil-works/pi-tui": "*",
26
+ "typebox": "*"
27
+ },
28
+ "devDependencies": {
29
+ "@earendil-works/pi-coding-agent": "^0.84.4",
30
+ "@earendil-works/pi-tui": "^0.84.4",
31
+ "typebox": "^1.3.24",
32
+ "typescript": "^5.7.0",
33
+ "vitest": "^2.1.0",
34
+ "@types/node": "^22.10.0"
35
+ }
36
+ }
package/src/format.ts ADDED
@@ -0,0 +1,22 @@
1
+ const UNITS: Array<{ limit: number; divisor: number; suffix: string }> = [
2
+ { limit: 60, divisor: 1, suffix: "s" },
3
+ { limit: 60 * 60, divisor: 60, suffix: "m" },
4
+ { limit: 60 * 60 * 24, divisor: 60 * 60, suffix: "h" },
5
+ { limit: 60 * 60 * 24 * 30, divisor: 60 * 60 * 24, suffix: "d" },
6
+ { limit: 60 * 60 * 24 * 365, divisor: 60 * 60 * 24 * 30, suffix: "mo" },
7
+ ];
8
+
9
+ /** Formats an ISO timestamp as a relative "Xs/m/h/d/mo/y ago" string, given the reference time. */
10
+ export function relativeTime(iso: string, now: Date = new Date()): string {
11
+ const deltaSeconds = Math.floor((now.getTime() - new Date(iso).getTime()) / 1000);
12
+ if (deltaSeconds < 1) return "just now";
13
+
14
+ for (const unit of UNITS) {
15
+ if (deltaSeconds < unit.limit) {
16
+ return `${Math.floor(deltaSeconds / unit.divisor)}${unit.suffix} ago`;
17
+ }
18
+ }
19
+
20
+ const years = Math.floor(deltaSeconds / (60 * 60 * 24 * 365));
21
+ return `${years}y ago`;
22
+ }
package/src/gh-cli.ts ADDED
@@ -0,0 +1,23 @@
1
+ import type { ExecFn } from "./types.ts";
2
+
3
+ export type CheckResult = { ok: true } | { ok: false; error: string };
4
+
5
+ export async function checkGhInstalled(exec: ExecFn): Promise<CheckResult> {
6
+ try {
7
+ const result = await exec("gh", ["--version"], { timeout: 5_000 });
8
+ if (result.code !== 0) {
9
+ return { ok: false, error: "The `gh` CLI is installed but `gh --version` failed. Install it from https://cli.github.com." };
10
+ }
11
+ return { ok: true };
12
+ } catch {
13
+ return { ok: false, error: "The `gh` CLI is not installed. Install it from https://cli.github.com." };
14
+ }
15
+ }
16
+
17
+ export async function checkGhAuthenticated(exec: ExecFn, cwd: string): Promise<CheckResult> {
18
+ const result = await exec("gh", ["auth", "status"], { cwd, timeout: 5_000 });
19
+ if (result.code !== 0) {
20
+ return { ok: false, error: "The `gh` CLI is not authenticated. Run `gh auth login` and try again." };
21
+ }
22
+ return { ok: true };
23
+ }
package/src/gh-data.ts ADDED
@@ -0,0 +1,393 @@
1
+ import type {
2
+ CheckRun,
3
+ CheckState,
4
+ Comment,
5
+ ExecFn,
6
+ FileChange,
7
+ IssueDetail,
8
+ IssueSummary,
9
+ MergeMethod,
10
+ PullRequestDetail,
11
+ PullRequestSummary,
12
+ Result,
13
+ Review,
14
+ ReviewState,
15
+ } from "./types.ts";
16
+
17
+ export type GhResult<T> = Result<T>;
18
+
19
+ interface RawActor {
20
+ login?: string;
21
+ name?: string;
22
+ }
23
+
24
+ interface RawLabel {
25
+ name: string;
26
+ }
27
+
28
+ interface RawComment {
29
+ author?: RawActor;
30
+ body?: string;
31
+ createdAt?: string;
32
+ }
33
+
34
+ interface RawCheckRun {
35
+ __typename?: string;
36
+ name?: string;
37
+ context?: string;
38
+ status?: string;
39
+ conclusion?: string | null;
40
+ state?: string;
41
+ }
42
+
43
+ interface RawReview {
44
+ author?: RawActor;
45
+ state?: string;
46
+ }
47
+
48
+ interface RawFile {
49
+ path: string;
50
+ additions: number;
51
+ deletions: number;
52
+ }
53
+
54
+ interface RawPrListItem {
55
+ number: number;
56
+ title: string;
57
+ author?: RawActor;
58
+ isDraft?: boolean;
59
+ updatedAt: string;
60
+ statusCheckRollup?: RawCheckRun[];
61
+ latestReviews?: RawReview[];
62
+ }
63
+
64
+ interface RawIssueListItem {
65
+ number: number;
66
+ title: string;
67
+ author?: RawActor;
68
+ updatedAt: string;
69
+ comments?: RawComment[];
70
+ labels?: RawLabel[];
71
+ }
72
+
73
+ interface RawPrDetail {
74
+ number: number;
75
+ title: string;
76
+ author?: RawActor;
77
+ state: string;
78
+ isDraft?: boolean;
79
+ labels?: RawLabel[];
80
+ assignees?: RawActor[];
81
+ createdAt: string;
82
+ updatedAt: string;
83
+ body?: string;
84
+ comments?: RawComment[];
85
+ baseRefName: string;
86
+ headRefName: string;
87
+ statusCheckRollup?: RawCheckRun[];
88
+ latestReviews?: RawReview[];
89
+ files?: RawFile[];
90
+ }
91
+
92
+ interface RawIssueDetail {
93
+ number: number;
94
+ title: string;
95
+ author?: RawActor;
96
+ state: string;
97
+ labels?: RawLabel[];
98
+ assignees?: RawActor[];
99
+ createdAt: string;
100
+ updatedAt: string;
101
+ body?: string;
102
+ comments?: RawComment[];
103
+ }
104
+
105
+ interface RawRepoView {
106
+ squashMergeAllowed?: boolean;
107
+ mergeCommitAllowed?: boolean;
108
+ rebaseMergeAllowed?: boolean;
109
+ }
110
+
111
+ function actorLogin(actor: RawActor | undefined): string {
112
+ return actor?.login ?? "unknown";
113
+ }
114
+
115
+ function mapCheck(check: RawCheckRun): CheckRun {
116
+ const name = check.name ?? check.context ?? "check";
117
+ if (check.status !== undefined) {
118
+ // CheckRun: status is QUEUED | IN_PROGRESS | COMPLETED; conclusion set once COMPLETED.
119
+ if (check.status !== "COMPLETED") return { name, state: "pending" };
120
+ const conclusion = (check.conclusion ?? "").toUpperCase();
121
+ if (conclusion === "SUCCESS" || conclusion === "NEUTRAL" || conclusion === "SKIPPED") {
122
+ return { name, state: "pass" };
123
+ }
124
+ return { name, state: "fail" };
125
+ }
126
+ // StatusContext: state is SUCCESS | PENDING | FAILURE | ERROR.
127
+ const state = (check.state ?? "").toUpperCase();
128
+ if (state === "SUCCESS") return { name, state: "pass" };
129
+ if (state === "PENDING" || state === "EXPECTED") return { name, state: "pending" };
130
+ return { name, state: "fail" };
131
+ }
132
+
133
+ function overallCheckState(rollup: RawCheckRun[] | undefined): CheckState | "none" {
134
+ if (!rollup || rollup.length === 0) return "none";
135
+ const mapped = rollup.map(mapCheck);
136
+ if (mapped.some((c) => c.state === "fail")) return "fail";
137
+ if (mapped.some((c) => c.state === "pending")) return "pending";
138
+ return "pass";
139
+ }
140
+
141
+ function reviewCounts(reviews: RawReview[] | undefined): { approvals: number; changesRequested: number } {
142
+ const list = reviews ?? [];
143
+ return {
144
+ approvals: list.filter((r) => r.state === "APPROVED").length,
145
+ changesRequested: list.filter((r) => r.state === "CHANGES_REQUESTED").length,
146
+ };
147
+ }
148
+
149
+ async function runGhJson<T>(exec: ExecFn, args: string[], cwd: string): Promise<GhResult<T>> {
150
+ const result = await exec("gh", args, { cwd, timeout: 15_000 });
151
+ if (result.code !== 0) {
152
+ return { ok: false, error: result.stderr.trim() || `gh exited with code ${result.code}` };
153
+ }
154
+ try {
155
+ return { ok: true, data: JSON.parse(result.stdout) as T };
156
+ } catch {
157
+ return { ok: false, error: "Failed to parse gh output as JSON." };
158
+ }
159
+ }
160
+
161
+ export async function fetchPullRequests(
162
+ exec: ExecFn,
163
+ options: { repo: string; cwd: string; limit: number },
164
+ ): Promise<GhResult<PullRequestSummary[]>> {
165
+ const result = await runGhJson<RawPrListItem[]>(
166
+ exec,
167
+ [
168
+ "pr",
169
+ "list",
170
+ "--repo",
171
+ options.repo,
172
+ "--state",
173
+ "open",
174
+ "--search",
175
+ "sort:updated-desc",
176
+ "--limit",
177
+ String(options.limit),
178
+ "--json",
179
+ "number,title,author,updatedAt,isDraft,statusCheckRollup,latestReviews",
180
+ ],
181
+ options.cwd,
182
+ );
183
+ if (!result.ok) return result;
184
+
185
+ const items: PullRequestSummary[] = result.data
186
+ .map((pr) => {
187
+ const { approvals, changesRequested } = reviewCounts(pr.latestReviews);
188
+ return {
189
+ number: pr.number,
190
+ title: pr.title,
191
+ author: actorLogin(pr.author),
192
+ isDraft: pr.isDraft ?? false,
193
+ checkState: overallCheckState(pr.statusCheckRollup),
194
+ approvals,
195
+ changesRequested,
196
+ updatedAt: pr.updatedAt,
197
+ };
198
+ })
199
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
200
+
201
+ return { ok: true, data: items };
202
+ }
203
+
204
+ export async function fetchIssues(
205
+ exec: ExecFn,
206
+ options: { repo: string; cwd: string; limit: number },
207
+ ): Promise<GhResult<IssueSummary[]>> {
208
+ const result = await runGhJson<RawIssueListItem[]>(
209
+ exec,
210
+ [
211
+ "issue",
212
+ "list",
213
+ "--repo",
214
+ options.repo,
215
+ "--state",
216
+ "open",
217
+ "--search",
218
+ "sort:updated-desc",
219
+ "--limit",
220
+ String(options.limit),
221
+ "--json",
222
+ "number,title,author,updatedAt,comments,labels",
223
+ ],
224
+ options.cwd,
225
+ );
226
+ if (!result.ok) return result;
227
+
228
+ const items: IssueSummary[] = result.data
229
+ .map((issue) => ({
230
+ number: issue.number,
231
+ title: issue.title,
232
+ author: actorLogin(issue.author),
233
+ commentCount: issue.comments?.length ?? 0,
234
+ labels: (issue.labels ?? []).map((l) => l.name),
235
+ updatedAt: issue.updatedAt,
236
+ }))
237
+ .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
238
+
239
+ return { ok: true, data: items };
240
+ }
241
+
242
+ function mapComment(comment: RawComment): Comment {
243
+ return {
244
+ author: actorLogin(comment.author),
245
+ body: comment.body ?? "",
246
+ createdAt: comment.createdAt ?? "",
247
+ };
248
+ }
249
+
250
+ export async function fetchPullRequestDetail(
251
+ exec: ExecFn,
252
+ options: { repo: string; cwd: string; number: number },
253
+ ): Promise<GhResult<PullRequestDetail>> {
254
+ const result = await runGhJson<RawPrDetail>(
255
+ exec,
256
+ [
257
+ "pr",
258
+ "view",
259
+ String(options.number),
260
+ "--repo",
261
+ options.repo,
262
+ "--json",
263
+ "number,title,author,state,isDraft,labels,assignees,createdAt,updatedAt,body,comments,baseRefName,headRefName,statusCheckRollup,latestReviews,files",
264
+ ],
265
+ options.cwd,
266
+ );
267
+ if (!result.ok) return result;
268
+
269
+ const pr = result.data;
270
+ const reviews: Review[] = (pr.latestReviews ?? [])
271
+ .filter((r) => r.state && r.state !== "PENDING" && r.state !== "COMMENTED")
272
+ .map((r) => ({ author: actorLogin(r.author), state: (r.state as ReviewState) ?? "PENDING" }));
273
+
274
+ return {
275
+ ok: true,
276
+ data: {
277
+ number: pr.number,
278
+ title: pr.title,
279
+ author: actorLogin(pr.author),
280
+ state: pr.state,
281
+ isDraft: pr.isDraft ?? false,
282
+ labels: (pr.labels ?? []).map((l) => l.name),
283
+ assignees: (pr.assignees ?? []).map(actorLogin),
284
+ createdAt: pr.createdAt,
285
+ updatedAt: pr.updatedAt,
286
+ body: pr.body ?? "",
287
+ comments: (pr.comments ?? []).map(mapComment),
288
+ baseRefName: pr.baseRefName,
289
+ headRefName: pr.headRefName,
290
+ checks: (pr.statusCheckRollup ?? []).map(mapCheck),
291
+ reviews,
292
+ files: (pr.files ?? []).map((f: FileChange) => ({ path: f.path, additions: f.additions, deletions: f.deletions })),
293
+ },
294
+ };
295
+ }
296
+
297
+ export async function fetchIssueDetail(
298
+ exec: ExecFn,
299
+ options: { repo: string; cwd: string; number: number },
300
+ ): Promise<GhResult<IssueDetail>> {
301
+ const result = await runGhJson<RawIssueDetail>(
302
+ exec,
303
+ [
304
+ "issue",
305
+ "view",
306
+ String(options.number),
307
+ "--repo",
308
+ options.repo,
309
+ "--json",
310
+ "number,title,author,state,labels,assignees,createdAt,updatedAt,body,comments",
311
+ ],
312
+ options.cwd,
313
+ );
314
+ if (!result.ok) return result;
315
+
316
+ const issue = result.data;
317
+ return {
318
+ ok: true,
319
+ data: {
320
+ number: issue.number,
321
+ title: issue.title,
322
+ author: actorLogin(issue.author),
323
+ state: issue.state,
324
+ labels: (issue.labels ?? []).map((l) => l.name),
325
+ assignees: (issue.assignees ?? []).map(actorLogin),
326
+ createdAt: issue.createdAt,
327
+ updatedAt: issue.updatedAt,
328
+ body: issue.body ?? "",
329
+ comments: (issue.comments ?? []).map(mapComment),
330
+ },
331
+ };
332
+ }
333
+
334
+ export async function getAllowedMergeMethods(
335
+ exec: ExecFn,
336
+ options: { repo: string; cwd: string },
337
+ ): Promise<GhResult<MergeMethod[]>> {
338
+ const result = await runGhJson<RawRepoView>(
339
+ exec,
340
+ ["repo", "view", options.repo, "--json", "squashMergeAllowed,mergeCommitAllowed,rebaseMergeAllowed"],
341
+ options.cwd,
342
+ );
343
+ if (!result.ok) return result;
344
+
345
+ const methods: MergeMethod[] = [];
346
+ if (result.data.squashMergeAllowed) methods.push("squash");
347
+ if (result.data.mergeCommitAllowed) methods.push("merge");
348
+ if (result.data.rebaseMergeAllowed) methods.push("rebase");
349
+
350
+ return { ok: true, data: methods };
351
+ }
352
+
353
+ export async function getCurrentAccount(exec: ExecFn, cwd: string): Promise<string | undefined> {
354
+ const result = await exec("gh", ["api", "user", "--jq", ".login"], { cwd, timeout: 10_000 });
355
+ if (result.code !== 0) return undefined;
356
+ const login = result.stdout.trim();
357
+ return login.length > 0 ? login : undefined;
358
+ }
359
+
360
+ export async function approvePullRequest(
361
+ exec: ExecFn,
362
+ options: { repo: string; cwd: string; number: number },
363
+ ): Promise<GhResult<true>> {
364
+ const result = await exec("gh", ["pr", "review", String(options.number), "--repo", options.repo, "--approve"], {
365
+ cwd: options.cwd,
366
+ timeout: 15_000,
367
+ });
368
+ if (result.code !== 0) {
369
+ return { ok: false, error: result.stderr.trim() || `gh exited with code ${result.code}` };
370
+ }
371
+ return { ok: true, data: true };
372
+ }
373
+
374
+ const MERGE_FLAG: Record<MergeMethod, string> = {
375
+ squash: "--squash",
376
+ merge: "--merge",
377
+ rebase: "--rebase",
378
+ };
379
+
380
+ export async function mergePullRequest(
381
+ exec: ExecFn,
382
+ options: { repo: string; cwd: string; number: number; method: MergeMethod },
383
+ ): Promise<GhResult<true>> {
384
+ const result = await exec(
385
+ "gh",
386
+ ["pr", "merge", String(options.number), "--repo", options.repo, MERGE_FLAG[options.method]],
387
+ { cwd: options.cwd, timeout: 30_000 },
388
+ );
389
+ if (result.code !== 0) {
390
+ return { ok: false, error: result.stderr.trim() || `gh exited with code ${result.code}` };
391
+ }
392
+ return { ok: true, data: true };
393
+ }
package/src/index.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { checkGhAuthenticated, checkGhInstalled } from "./gh-cli.ts";
3
+ import { detectRepo } from "./repo.ts";
4
+ import { getFetchLimit } from "./settings.ts";
5
+ import { GithubApp } from "./ui/app.ts";
6
+
7
+ export default function (pi: ExtensionAPI) {
8
+ pi.registerCommand("github", {
9
+ description: "Browse this repo's open GitHub Pull Requests and Issues",
10
+ handler: async (_args, ctx) => {
11
+ const exec = pi.exec;
12
+
13
+ const installed = await checkGhInstalled(exec);
14
+ if (!installed.ok) {
15
+ ctx.ui.notify(installed.error, "error");
16
+ return;
17
+ }
18
+
19
+ const authenticated = await checkGhAuthenticated(exec, ctx.cwd);
20
+ if (!authenticated.ok) {
21
+ ctx.ui.notify(authenticated.error, "error");
22
+ return;
23
+ }
24
+
25
+ const repoResult = await detectRepo(exec, ctx.cwd);
26
+ if (!repoResult.ok) {
27
+ ctx.ui.notify(repoResult.error, "error");
28
+ return;
29
+ }
30
+
31
+ const limit = await getFetchLimit({ cwd: ctx.cwd });
32
+
33
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
34
+ return new GithubApp({
35
+ repo: repoResult.repo,
36
+ cwd: ctx.cwd,
37
+ exec,
38
+ limit,
39
+ ui: ctx.ui,
40
+ theme,
41
+ tui,
42
+ done,
43
+ });
44
+ });
45
+ },
46
+ });
47
+ }
package/src/repo.ts ADDED
@@ -0,0 +1,54 @@
1
+ import type { ExecFn } from "./types.ts";
2
+
3
+ export type RepoResolution = { ok: true; repo: string } | { ok: false; error: string };
4
+
5
+ export function parseGitHubRemote(remoteUrl: string): string | undefined {
6
+ const sshMatch = remoteUrl.match(/^git@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/);
7
+ if (sshMatch) return sshMatch[1];
8
+
9
+ const sshProtoMatch = remoteUrl.match(/^ssh:\/\/git@github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
10
+ if (sshProtoMatch) return sshProtoMatch[1];
11
+
12
+ const httpsMatch = remoteUrl.match(/^https?:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/);
13
+ if (httpsMatch) return httpsMatch[1];
14
+
15
+ return undefined;
16
+ }
17
+
18
+ /** Picks the URL to check from `git remote -v` output: prefers "origin", else the first remote listed. */
19
+ export function pickRemoteUrl(remoteVOutput: string): string | undefined {
20
+ let firstUrl: string | undefined;
21
+ let originUrl: string | undefined;
22
+
23
+ for (const line of remoteVOutput.split("\n")) {
24
+ const columns = line.trim().split(/\s+/);
25
+ const name = columns[0];
26
+ const url = columns[1];
27
+ if (!name || !url) continue;
28
+ firstUrl ??= url;
29
+ if (name === "origin") {
30
+ originUrl ??= url;
31
+ }
32
+ }
33
+
34
+ return originUrl ?? firstUrl;
35
+ }
36
+
37
+ export async function detectRepo(exec: ExecFn, cwd: string): Promise<RepoResolution> {
38
+ const result = await exec("git", ["remote", "-v"], { cwd, timeout: 5_000 });
39
+ if (result.code !== 0) {
40
+ return { ok: false, error: "Not a git repository (or no remotes configured)." };
41
+ }
42
+
43
+ const remoteUrl = pickRemoteUrl(result.stdout);
44
+ if (!remoteUrl) {
45
+ return { ok: false, error: "This repository has no git remote configured." };
46
+ }
47
+
48
+ const repo = parseGitHubRemote(remoteUrl);
49
+ if (!repo) {
50
+ return { ok: false, error: "This repository's remote is not a GitHub repository." };
51
+ }
52
+
53
+ return { ok: true, repo };
54
+ }