@oscbacon/tungsten 0.0.1

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.
@@ -0,0 +1,121 @@
1
+ import { expect, test } from "bun:test"
2
+ import { BUCKETS, buildNumbersQuery, buildQuery, localBranch, parseSearch, relativeTime, searchQuery, toBuckets, toNumbersBucket } from "./github"
3
+ import { parseRepo } from "./repo"
4
+
5
+ const now = new Date("2026-09-15T15:00:00Z")
6
+
7
+ // Shaped like a real node from `gh api graphql` against cli/cli.
8
+ const node = (over: Record<string, unknown> = {}) => ({
9
+ number: 14456,
10
+ title: "Refreshable tokens (7/7): guided end-to-end verification scripts",
11
+ url: "https://github.com/cli/cli/pull/14456",
12
+ isDraft: false,
13
+ state: "OPEN",
14
+ additions: 1118,
15
+ deletions: 0,
16
+ updatedAt: "2026-09-15T01:49:02Z",
17
+ reviewDecision: "REVIEW_REQUIRED",
18
+ author: { login: "babakks" },
19
+ commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] },
20
+ headRefName: "babakks/refreshable-tokens-7",
21
+ isCrossRepository: false,
22
+ headRepositoryOwner: { login: "cli" },
23
+ baseRepository: { defaultBranchRef: { name: "trunk" } },
24
+ ...over,
25
+ })
26
+ const rollup = (state: string | null) => ({ nodes: [{ commit: { statusCheckRollup: state && { state } } }] })
27
+ const search = (...nodes: object[]) => ({ issueCount: 318, nodes })
28
+
29
+ test("buildQuery searches every bucket in one request", () => {
30
+ const q = buildQuery()
31
+ expect(q).toContain("fragment pr on PullRequest")
32
+ BUCKETS.forEach((_, i) => expect(q).toContain(`b${i}: search(query: $q${i}, type: ISSUE, first: 25)`))
33
+ expect(searchQuery(parseRepo("cli/cli"), 0)).toBe("repo:cli/cli is:pr sort:updated-desc is:open user-review-requested:@me")
34
+ })
35
+
36
+ test("toBuckets maps search results onto PR rows", () => {
37
+ const buckets = toBuckets({
38
+ data: {
39
+ b0: search(node(), {}), // {} is a non-PR search hit
40
+ b1: search(node({ reviewDecision: "CHANGES_REQUESTED", author: null, commits: rollup("FAILURE") })),
41
+ b2: search(node({ reviewDecision: "APPROVED", commits: { nodes: [] } })),
42
+ b3: search(node({ reviewDecision: null, updatedAt: "2026-08-26T15:00:00Z", commits: rollup("PENDING") })),
43
+ b4: search(node({ isDraft: true, commits: rollup("EXPECTED") })),
44
+ b5: search(node({ author: { login: "someone-else" } })),
45
+ b6: search(node({ state: "MERGED", reviewDecision: "APPROVED", updatedAt: "2025-01-01T00:00:00Z" })),
46
+ },
47
+ }, now)
48
+
49
+ expect(buckets.map(b => b.name)).toEqual(BUCKETS.map(b => b.name))
50
+ expect(buckets[0]).toEqual({
51
+ name: "Needs your review",
52
+ total: 318,
53
+ prs: [{
54
+ number: 14456, title: "Refreshable tokens (7/7): guided end-to-end verification scripts", author: "babakks",
55
+ state: "review", ci: "pass", review: "pending", additions: 1118, deletions: 0, updated: "13h", stale: false,
56
+ url: "https://github.com/cli/cli/pull/14456", branch: "babakks/refreshable-tokens-7",
57
+ }],
58
+ })
59
+ const row = (i: number) => buckets[i]!.prs[0]!
60
+ expect(row(1)).toMatchObject({ state: "author", ci: "fail", review: "fail", author: "ghost" })
61
+ expect(row(2)).toMatchObject({ state: "approved", ci: "pending", review: "pass" })
62
+ expect(row(3)).toMatchObject({ state: "review", ci: "running", review: "pending", updated: "20d", stale: true })
63
+ expect(row(4)).toMatchObject({ state: "draft", ci: "pending" })
64
+ expect(buckets[5]!.name).toBe("Open")
65
+ expect(row(5)).toMatchObject({ state: "review", author: "someone-else" })
66
+ expect(row(6)).toMatchObject({ state: "merged", ci: "pass", review: "merged", updated: "1y", stale: false })
67
+ })
68
+
69
+ test("toBuckets: missing searches are empty, GraphQL errors throw", () => {
70
+ expect(toBuckets({ data: {} }, now).every(b => b.total === 0 && b.prs.length === 0)).toBe(true)
71
+ expect(() => toBuckets({ errors: [{ message: "Field 'bogus' doesn't exist on type 'Query'" }] }, now)).toThrow("Field 'bogus'")
72
+ })
73
+
74
+ test("relativeTime", () => {
75
+ const ago = (s: number) => relativeTime(new Date(now.getTime() - s * 1000).toISOString(), now)
76
+ expect([ago(5), ago(59 * 60), ago(3600), ago(23 * 3600), ago(86400), ago(400 * 86400), ago(-30)])
77
+ .toEqual(["now", "59m", "1h", "23h", "1d", "1y", "now"])
78
+ })
79
+
80
+ test("localBranch follows gh: a fork's branch named like the default branch gets the owner as a prefix", () => {
81
+ const pr = (over: object) => ({ headRefName: "fix", isCrossRepository: false, headRepositoryOwner: { login: "cli" }, baseRepository: { defaultBranchRef: { name: "trunk" } }, ...over })
82
+ expect(localBranch(pr({}))).toBe("fix")
83
+ expect(localBranch(pr({ isCrossRepository: true, headRepositoryOwner: { login: "octo" } }))).toBe("fix")
84
+ expect(localBranch(pr({ isCrossRepository: true, headRefName: "trunk", headRepositoryOwner: { login: "octo" } }))).toBe("octo/trunk")
85
+ expect(localBranch(pr({ isCrossRepository: true, headRefName: "trunk", headRepositoryOwner: null }))).toBe("ghost/trunk")
86
+ })
87
+
88
+ test("parseSearch splits words, @authors and #numbers; searchQuery adds them to every bucket", () => {
89
+ const s = parseSearch(" fix @babakks #123 token# ")
90
+ expect(s).toEqual({ words: ["fix", "token#"], authors: ["babakks"], numbers: [123] })
91
+ expect(parseSearch("")).toEqual({ words: [], authors: [], numbers: [] })
92
+
93
+ const repo = parseRepo("cli/cli")
94
+ expect(searchQuery(repo, 5, s)).toBe("repo:cli/cli is:pr sort:updated-desc is:open fix token# in:title author:babakks")
95
+ expect(searchQuery(repo, 5, parseSearch("@babakks"))).toBe("repo:cli/cli is:pr sort:updated-desc is:open author:babakks")
96
+ // GitHub would OR a second author: into author:@me, so your own buckets leave it out.
97
+ expect(searchQuery(repo, 6, s)).toBe("repo:cli/cli is:pr sort:updated-desc is:merged author:@me fix token# in:title")
98
+ })
99
+
100
+ test("an @author search empties your own buckets unless the author is you", () => {
101
+ const res = { data: { viewer: { login: "OscBacon" }, ...Object.fromEntries(BUCKETS.map((_, i) => [`b${i}`, search(node())])) } }
102
+ const totals = (q: string) => toBuckets(res, now, parseSearch(q)).map(b => b.total)
103
+ expect(totals("fix")).toEqual([318, 318, 318, 318, 318, 318, 318])
104
+ expect(totals("@babakks")).toEqual([318, 0, 0, 0, 0, 318, 0])
105
+ expect(totals("@babakks @oscbacon")).toEqual([318, 318, 318, 318, 318, 318, 318])
106
+ expect(buildQuery()).toContain("viewer { login }")
107
+ })
108
+
109
+ test("#numbers are looked up directly and narrowed by the other terms", () => {
110
+ expect(buildNumbersQuery([12, 34])).toContain("repository(owner: $owner, name: $name) { n12: pullRequest(number: 12) { ...pr } n34: pullRequest(number: 34) { ...pr } }")
111
+
112
+ const res = {
113
+ data: { repository: { n1: node({ number: 1, title: "Fix tokens" }), n2: node({ number: 2, title: "Docs" }), n3: null } },
114
+ errors: [{ type: "NOT_FOUND", message: "Could not resolve to a PullRequest with the number of 3." }],
115
+ }
116
+ expect(toNumbersBucket(res, parseSearch("#1 #2 #3"), now).prs.map(p => p.number)).toEqual([1, 2])
117
+ expect(toNumbersBucket(res, parseSearch("#1 #2 fix"), now).prs.map(p => p.number)).toEqual([1])
118
+ expect(toNumbersBucket(res, parseSearch("#1 @someone"), now)).toEqual({ name: "Search results", prs: [] })
119
+ expect(toNumbersBucket(res, parseSearch("#1 @someone @BABAKKS"), now).prs.map(p => p.number)).toEqual([1])
120
+ expect(() => toNumbersBucket({ errors: [{ type: "FORBIDDEN", message: "nope" }] }, parseSearch("#1"), now)).toThrow("nope")
121
+ })
package/src/github.ts ADDED
@@ -0,0 +1,222 @@
1
+ import type { Bucket, PR, State, Status } from "./data"
2
+ import type { Repo } from "./repo"
3
+ import { run } from "./run"
4
+
5
+ export const PAGE_SIZE = 25
6
+ const STALE_DAYS = 14
7
+
8
+ // One GitHub search per bucket; searchQuery() prepends the repo and sort.
9
+ export const BUCKETS = [
10
+ { name: "Needs your review", query: "is:open user-review-requested:@me" },
11
+ { name: "Returned to you", query: "is:open author:@me review:changes_requested" },
12
+ { name: "Approved", query: "is:open author:@me review:approved" },
13
+ // Not `review:required`: that never matches in repos without required reviews.
14
+ { name: "Waiting for reviewers", query: "is:open author:@me -is:draft -review:approved -review:changes_requested" },
15
+ { name: "Drafts", query: "is:open author:@me draft:true" },
16
+ { name: "Open", query: "is:open" }, // everyone's, not just yours
17
+ { name: "Recently merged", query: "is:merged author:@me" },
18
+ ]
19
+
20
+ // What the user typed after "/": words match titles, @login an author, #123 a PR number.
21
+ // Like GitHub's author: qualifiers, several authors mean any of them.
22
+ export interface Search {
23
+ words: string[]
24
+ authors: string[]
25
+ numbers: number[]
26
+ }
27
+
28
+ export function parseSearch(input: string): Search {
29
+ const search: Search = { words: [], authors: [], numbers: [] }
30
+ for (const term of input.trim().split(/\s+/).filter(Boolean)) {
31
+ if (/^@[^@\s]+$/.test(term)) search.authors.push(term.slice(1))
32
+ else if (/^#\d+$/.test(term)) search.numbers.push(Number(term.slice(1)))
33
+ else search.words.push(term)
34
+ }
35
+ return search
36
+ }
37
+
38
+ // The same test done locally, for --demo and for PRs looked up by number.
39
+ export function matches(pr: PR, s: Search): boolean {
40
+ const title = pr.title.toLowerCase(), author = pr.author.toLowerCase()
41
+ return (s.numbers.length === 0 || s.numbers.includes(pr.number))
42
+ && s.words.every(w => title.includes(w.toLowerCase()))
43
+ && (s.authors.length === 0 || s.authors.some(a => author === a.toLowerCase()))
44
+ }
45
+
46
+ // Buckets of your own PRs. GitHub ORs author: qualifiers, so these can't take the search's authors;
47
+ // toBuckets() keeps them only when one of those authors is you.
48
+ const isMine = (i: number) => BUCKETS[i]!.query.includes("author:@me")
49
+
50
+ // GitHub search terms for the words and authors; numbers can't be searched, see buildNumbersQuery().
51
+ function searchTerms(s: Search, mine: boolean): string {
52
+ const authors = mine ? [] : s.authors.map(a => `author:${a}`)
53
+ return [...s.words, ...(s.words.length ? ["in:title"] : []), ...authors].join(" ")
54
+ }
55
+
56
+ export const searchQuery = (repo: Repo, i: number, search?: Search) =>
57
+ [`repo:${repo.owner}/${repo.name} is:pr sort:updated-desc ${BUCKETS[i]!.query}`, search && searchTerms(search, isMine(i))].filter(Boolean).join(" ")
58
+
59
+ const FIELDS = `number title url isDraft state additions deletions updatedAt reviewDecision
60
+ author { login } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
61
+ headRefName isCrossRepository headRepositoryOwner { login } baseRepository { defaultBranchRef { name } }`
62
+
63
+ // Searches are passed as variables, so repo names never need escaping in the query text.
64
+ export function buildQuery(): string {
65
+ const vars = BUCKETS.map((_, i) => `$q${i}: String!`).join(", ")
66
+ const searches = BUCKETS.map((_, i) => `b${i}: search(query: $q${i}, type: ISSUE, first: ${PAGE_SIZE}) { issueCount nodes { ...pr } }`)
67
+ return `fragment pr on PullRequest { ${FIELDS} }\nquery(${vars}) {\n viewer { login }\n ${searches.join("\n ")}\n}`
68
+ }
69
+
70
+ // Search doesn't match PR numbers, so #123 looks each one up directly.
71
+ export function buildNumbersQuery(numbers: number[]): string {
72
+ const prs = numbers.map(n => `n${n}: pullRequest(number: ${n}) { ...pr }`)
73
+ return `fragment pr on PullRequest { ${FIELDS} }\nquery($owner: String!, $name: String!) {\n repository(owner: $owner, name: $name) { ${prs.join(" ")} }\n}`
74
+ }
75
+
76
+ interface PrNode {
77
+ number: number
78
+ title: string
79
+ url: string
80
+ isDraft: boolean
81
+ state: string // OPEN | CLOSED | MERGED
82
+ additions: number
83
+ deletions: number
84
+ updatedAt: string
85
+ reviewDecision: string | null // APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED
86
+ author: { login: string } | null // null for deleted accounts
87
+ commits: { nodes: ({ commit: { statusCheckRollup: { state: string } | null } } | null)[] }
88
+ headRefName: string
89
+ isCrossRepository: boolean
90
+ headRepositoryOwner: { login: string } | null // null once the fork is deleted
91
+ baseRepository: { defaultBranchRef: { name: string } | null } | null
92
+ }
93
+
94
+ interface SearchResult {
95
+ issueCount: number
96
+ nodes: unknown[]
97
+ }
98
+
99
+ export interface SearchResponse {
100
+ data?: Record<string, SearchResult | { login: string } | null> // b0..b6, plus viewer
101
+ errors?: { message: string }[]
102
+ }
103
+
104
+ export interface NumbersResponse {
105
+ data?: { repository: Record<string, unknown> | null } | null
106
+ errors?: { message: string; type?: string }[]
107
+ }
108
+
109
+ // Non-PR search hits come back as empty objects.
110
+ const isPrNode = (n: unknown): n is PrNode => typeof n === "object" && n !== null && "number" in n
111
+
112
+ const CI: Record<string, Status> = { SUCCESS: "pass", FAILURE: "fail", ERROR: "fail", PENDING: "running" }
113
+
114
+ export function relativeTime(iso: string, now: Date): string {
115
+ const s = Math.max(0, (now.getTime() - Date.parse(iso)) / 1000)
116
+ if (s < 60) return "now"
117
+ if (s < 3600) return `${Math.floor(s / 60)}m`
118
+ if (s < 86400) return `${Math.floor(s / 3600)}h`
119
+ if (s < 365 * 86400) return `${Math.floor(s / 86400)}d`
120
+ return `${Math.floor(s / (365 * 86400))}y`
121
+ }
122
+
123
+ // Like gh pr checkout: a fork's branch named after the default branch would clash with it,
124
+ // so it gets the fork owner as a prefix.
125
+ export function localBranch(n: Pick<PrNode, "headRefName" | "isCrossRepository" | "headRepositoryOwner" | "baseRepository">): string {
126
+ const clashes = n.isCrossRepository && n.headRefName === n.baseRepository?.defaultBranchRef?.name
127
+ return clashes ? `${n.headRepositoryOwner?.login ?? "ghost"}/${n.headRefName}` : n.headRefName
128
+ }
129
+
130
+ function toPR(n: PrNode, now: Date): PR {
131
+ const merged = n.state === "MERGED"
132
+ const state: State =
133
+ merged ? "merged" :
134
+ n.isDraft ? "draft" :
135
+ n.reviewDecision === "APPROVED" ? "approved" :
136
+ n.reviewDecision === "CHANGES_REQUESTED" ? "author" :
137
+ "review"
138
+ const review: Status =
139
+ merged ? "merged" :
140
+ n.reviewDecision === "APPROVED" ? "pass" :
141
+ n.reviewDecision === "CHANGES_REQUESTED" ? "fail" :
142
+ "pending"
143
+ const rollup = n.commits.nodes[0]?.commit.statusCheckRollup?.state ?? ""
144
+
145
+ return {
146
+ number: n.number,
147
+ title: n.title,
148
+ author: n.author?.login ?? "ghost",
149
+ state,
150
+ ci: CI[rollup] ?? "pending",
151
+ review,
152
+ additions: n.additions,
153
+ deletions: n.deletions,
154
+ updated: relativeTime(n.updatedAt, now),
155
+ stale: !merged && now.getTime() - Date.parse(n.updatedAt) > STALE_DAYS * 86400_000,
156
+ url: n.url,
157
+ branch: localBranch(n),
158
+ }
159
+ }
160
+
161
+ export function toBuckets(res: SearchResponse, now = new Date(), search?: Search): Bucket[] {
162
+ if (res.errors?.length) throw new Error(res.errors.map(e => e.message).join("; "))
163
+ const viewer = (res.data?.viewer as { login: string } | undefined)?.login.toLowerCase()
164
+ const notMe = !!search?.authors.length && !search.authors.some(a => a.toLowerCase() === viewer)
165
+ return BUCKETS.map((b, i) => {
166
+ if (notMe && isMine(i)) return { name: b.name, total: 0, prs: [] }
167
+ const result = res.data?.[`b${i}`] as SearchResult | null | undefined
168
+ return { name: b.name, total: result?.issueCount ?? 0, prs: (result?.nodes ?? []).filter(isPrNode).map(n => toPR(n, now)) }
169
+ })
170
+ }
171
+
172
+ // One "Search results" bucket with the PRs that exist, narrowed by the other terms.
173
+ export function toNumbersBucket(res: NumbersResponse, search: Search, now = new Date()): Bucket {
174
+ // A missing number is a NOT_FOUND error next to the PRs that were found.
175
+ const errors = res.errors?.filter(e => e.type !== "NOT_FOUND") ?? []
176
+ if (errors.length) throw new Error(errors.map(e => e.message).join("; "))
177
+ const prs = search.numbers
178
+ .map(n => res.data?.repository?.[`n${n}`])
179
+ .filter(isPrNode)
180
+ .map(n => toPR(n, now))
181
+ .filter(pr => matches(pr, search))
182
+ return { name: "Search results", prs }
183
+ }
184
+
185
+ async function gh(args: string[]) {
186
+ try {
187
+ return await run("gh", args)
188
+ } catch (e) {
189
+ if ((e as NodeJS.ErrnoException).code === "ENOENT") throw new Error("gh is not installed (https://cli.github.com); run with --demo to try the UI")
190
+ throw e
191
+ }
192
+ }
193
+
194
+ // gh handles auth and the API host, so no token ever passes through here.
195
+ async function graphql<T extends { errors?: unknown[] }>(repo: Repo, fields: Record<string, string>): Promise<T> {
196
+ const { code, stdout, stderr } = await gh([
197
+ "api", "graphql", "--hostname", repo.host,
198
+ ...Object.entries(fields).flatMap(([k, v]) => ["-f", `${k}=${v}`]),
199
+ ])
200
+
201
+ let res: T | undefined
202
+ try {
203
+ res = JSON.parse(stdout)
204
+ } catch {
205
+ // Not JSON: gh failed before querying (not logged in, network, ...).
206
+ }
207
+ // GraphQL errors exit non-zero but still print the response, which carries the useful message.
208
+ if (res && (code === 0 || res.errors?.length)) return res
209
+ throw new Error(stderr.trim().split("\n")[0] || `gh exited with code ${code}`)
210
+ }
211
+
212
+ export async function fetchBuckets(repo: Repo, search?: Search): Promise<Bucket[]> {
213
+ if (search?.numbers.length) {
214
+ const res = await graphql<NumbersResponse>(repo, { query: buildNumbersQuery(search.numbers), owner: repo.owner, name: repo.name })
215
+ return [toNumbersBucket(res, search)]
216
+ }
217
+ const res = await graphql<SearchResponse>(repo, {
218
+ query: buildQuery(),
219
+ ...Object.fromEntries(BUCKETS.map((_, i) => [`q${i}`, searchQuery(repo, i, search)])),
220
+ })
221
+ return toBuckets(res, new Date(), search)
222
+ }
package/src/index.tsx ADDED
@@ -0,0 +1,95 @@
1
+ #!/usr/bin/env bun
2
+ import { createCliRenderer } from "@opentui/core"
3
+ import { createRoot } from "@opentui/react"
4
+ import { parseArgs } from "node:util"
5
+ import { App, type Actions } from "./app"
6
+ import { checkoutPr, needsPrompt, openWorktree } from "./checkout"
7
+ import { demoBuckets } from "./data"
8
+ import { fetchBuckets, matches, parseSearch } from "./github"
9
+ import { detectRepo, ghHosts, parseRepo, repoLabel, type Repo } from "./repo"
10
+ import { run, type Run } from "./run"
11
+
12
+ const USAGE = `Usage: tungsten [flags]
13
+
14
+ Flags:
15
+ -R, --repo [HOST/]OWNER/REPO Select another repository using the [HOST/]OWNER/REPO format
16
+ --demo Show sample data instead of fetching from GitHub
17
+ --show-git-prompt Let c and w show git's prompts (ssh passphrase, credentials);
18
+ the app is hidden while they run
19
+ -h, --help Show help`
20
+
21
+ let repo: Repo
22
+ let demo: boolean
23
+ let showGitPrompt: boolean
24
+ let notLocal: string | undefined // why c and w can't work from here
25
+ try {
26
+ const { values } = parseArgs({
27
+ options: {
28
+ repo: { type: "string", short: "R" },
29
+ demo: { type: "boolean" },
30
+ "show-git-prompt": { type: "boolean" },
31
+ help: { type: "boolean", short: "h" },
32
+ },
33
+ })
34
+ if (values.help) {
35
+ console.log(USAGE)
36
+ process.exit(0)
37
+ }
38
+ demo = values.demo ?? false
39
+ showGitPrompt = values["show-git-prompt"] ?? false
40
+ // Like gh: OWNER/REPO uses gh's default host, and no flag means the current folder's git remotes.
41
+ // --demo shouldn't need a clone, so it falls back to a sample repo.
42
+ const hosts = ghHosts()
43
+ repo =
44
+ values.repo ? parseRepo(values.repo, hosts.defaultHost) :
45
+ demo ? parseRepo("withgraphite/monologue") :
46
+ detectRepo(process.cwd(), hosts)
47
+ // Checkouts happen in the current folder, so it has to be a clone of the repo on screen.
48
+ if (demo) notLocal = "not available with --demo"
49
+ else if (values.repo) {
50
+ const key = (r: Repo) => `${r.host}/${r.owner}/${r.name}`.toLowerCase()
51
+ let here: Repo | undefined
52
+ try { here = detectRepo(process.cwd(), hosts) } catch {}
53
+ if (!here || key(here) !== key(repo)) {
54
+ notLocal = `run tungsten from a clone of ${repoLabel(repo)} to check out its PRs`
55
+ }
56
+ }
57
+ } catch (e) {
58
+ console.error(`${(e as Error).message}\n\n${USAGE}`)
59
+ process.exit(1)
60
+ }
61
+
62
+ const load = async (search?: string) => {
63
+ const s = search ? parseSearch(search) : undefined
64
+ if (!demo) return fetchBuckets(repo, s)
65
+ const buckets = demoBuckets(repo)
66
+ return s ? buckets.map(b => ({ name: b.name, prs: b.prs.filter(pr => matches(pr, s)) })) : buckets
67
+ }
68
+
69
+ const renderer = await createCliRenderer({ exitOnCtrlC: true })
70
+
71
+ // Commands that may prompt fail instead, so the app stays on screen;
72
+ // --show-git-prompt hands them the real terminal, hiding the app while they run.
73
+ const runGit: Run = async (cmd, args, opts) => {
74
+ if (!opts?.interactive) return run(cmd, args)
75
+ if (!showGitPrompt) return run(cmd, args, { noPrompts: true })
76
+ renderer.suspend()
77
+ try {
78
+ return await run(cmd, args, { terminal: true })
79
+ } finally {
80
+ renderer.resume()
81
+ }
82
+ }
83
+
84
+ const withHint = (p: Promise<string>) => p.catch((e: Error) => {
85
+ throw showGitPrompt || !needsPrompt(e.message) ? e : new Error(`${e.message} (run with --show-git-prompt to answer git's prompts)`)
86
+ })
87
+
88
+ const actions: Actions = notLocal
89
+ ? { checkout: () => Promise.reject(new Error(notLocal)), worktree: () => Promise.reject(new Error(notLocal)) }
90
+ : {
91
+ checkout: pr => withHint(checkoutPr(pr, repo, runGit)),
92
+ worktree: pr => withHint(openWorktree(pr, repo, { run: runGit, tmux: !!process.env.TMUX })),
93
+ }
94
+
95
+ createRoot(renderer).render(<App repo={repo} load={load} actions={actions} />)
@@ -0,0 +1,91 @@
1
+ import { expect, test } from "bun:test"
2
+ import { execFileSync } from "node:child_process"
3
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
4
+ import { tmpdir } from "node:os"
5
+ import { join } from "node:path"
6
+ import { detectRepo, ghHosts, parseRemoteUrl, parseRepo, resolveRepo, type GhHosts, type Remote } from "./repo"
7
+
8
+ const repo = (owner: string, name: string, host = "github.com") => ({ host, owner, name })
9
+ const hosts = (over: Partial<GhHosts> = {}): GhHosts => ({ known: ["github.com"], defaultHost: "github.com", source: "default", ...over })
10
+ const ghe: Partial<GhHosts> = { known: ["github.com", "ghe.example.com"], defaultHost: "ghe.example.com" }
11
+
12
+ const withTmp = (fn: (dir: string) => void) => {
13
+ const dir = mkdtempSync(join(tmpdir(), "inbox-tui-"))
14
+ try { fn(dir) } finally { rmSync(dir, { recursive: true, force: true }) }
15
+ }
16
+
17
+ test("parseRepo accepts [HOST/]OWNER/REPO", () => {
18
+ expect(parseRepo("cli/cli")).toEqual(repo("cli", "cli"))
19
+ expect(parseRepo("cli/cli", "ghe.example.com")).toEqual(repo("cli", "cli", "ghe.example.com"))
20
+ expect(parseRepo("GHE.example.com/acme/app")).toEqual(repo("acme", "app", "ghe.example.com"))
21
+ for (const bad of ["cli", "a/b/c/d", "/cli", "cli/", ""]) expect(() => parseRepo(bad)).toThrow()
22
+ })
23
+
24
+ test("parseRemoteUrl follows gh's URL handling", () => {
25
+ expect(parseRemoteUrl("https://github.com/cli/cli.git")).toEqual(repo("cli", "cli"))
26
+ expect(parseRemoteUrl("https://github.com/cli/cli/")).toEqual(repo("cli", "cli"))
27
+ expect(parseRemoteUrl("git+https://www.GitHub.com/cli/cli")).toEqual(repo("cli", "cli"))
28
+ expect(parseRemoteUrl("git@github.com:OscBacon/personal-website.git")).toEqual(repo("OscBacon", "personal-website"))
29
+ expect(parseRemoteUrl("git+ssh://git@github.com/cli/cli.git")).toEqual(repo("cli", "cli"))
30
+ expect(parseRemoteUrl("ssh://git@ssh.github.com:443/cli/cli.git")).toEqual(repo("cli", "cli"))
31
+ expect(parseRemoteUrl("https://user@GHE.example.com/acme/app.git")).toEqual(repo("acme", "app", "ghe.example.com"))
32
+
33
+ const alias = (h: string) => (h === "work" ? "github.com" : h)
34
+ expect(parseRemoteUrl("git@work:acme/app.git", alias)).toEqual(repo("acme", "app"))
35
+ expect(parseRemoteUrl("https://work/acme/app", alias)).toEqual(repo("acme", "app", "work")) // aliases are ssh-only
36
+
37
+ for (const bad of ["/srv/git/app.git", "../local", "file:///srv/a/b", "C:\\repos\\app", "https://github.com/cli", "https://github.com/cli/cli/pulls"]) {
38
+ expect(parseRemoteUrl(bad)).toBeNull()
39
+ }
40
+ })
41
+
42
+ test("resolveRepo: gh-resolved first, then upstream > github > origin > others", () => {
43
+ const fork = "git@github.com:me/app.git", parent = "https://github.com/acme/app.git"
44
+ const r = (name: string, fetchUrl: string, extra: Partial<Remote> = {}): Remote => ({ name, fetchUrl, ...extra })
45
+
46
+ expect(resolveRepo([r("mine", fork), r("origin", fork), r("Upstream", parent)], hosts())).toEqual(repo("acme", "app"))
47
+ expect(resolveRepo([r("zzz", parent), r("origin", fork)], hosts())).toEqual(repo("me", "app"))
48
+ expect(resolveRepo([r("origin", fork, { resolved: "base" }), r("upstream", parent)], hosts())).toEqual(repo("me", "app"))
49
+ expect(resolveRepo([r("origin", fork, { resolved: "other/thing" })], hosts({ ...ghe, source: "hosts" }))).toEqual(repo("other", "thing", "ghe.example.com"))
50
+ expect(resolveRepo([r("origin", "/srv/git/app.git", { pushUrl: parent })], hosts())).toEqual(repo("acme", "app"))
51
+ expect(() => resolveRepo([], hosts())).toThrow(/no git remotes/)
52
+ })
53
+
54
+ test("resolveRepo only considers known hosts, preferring the default host", () => {
55
+ const gitlab = { name: "origin", fetchUrl: "https://gitlab.com/me/app" }
56
+ const dotcom = { name: "mirror", fetchUrl: "https://github.com/acme/app" }
57
+ const enterprise = { name: "zzz", fetchUrl: "https://ghe.example.com/acme/app" }
58
+
59
+ expect(resolveRepo([gitlab, dotcom], hosts())).toEqual(repo("acme", "app"))
60
+ expect(() => resolveRepo([gitlab], hosts())).toThrow(/known GitHub host/)
61
+ // A single hosts.yml entry wins when a remote matches it, and falls back otherwise.
62
+ expect(resolveRepo([dotcom, enterprise], hosts({ ...ghe, source: "hosts" }))).toEqual(repo("acme", "app", "ghe.example.com"))
63
+ expect(resolveRepo([dotcom], hosts({ ...ghe, source: "hosts" }))).toEqual(repo("acme", "app"))
64
+ // GH_HOST never falls back.
65
+ expect(() => resolveRepo([dotcom], hosts({ ...ghe, source: "GH_HOST" }))).toThrow(/GH_HOST/)
66
+ })
67
+
68
+ test("ghHosts reads host keys from gh's config dir and GH_HOST", () => {
69
+ withTmp(dir => {
70
+ writeFileSync(join(dir, "hosts.yml"), "github.com:\n user: me\n oauth_token: secret\nghe.example.com:\n user: me\n")
71
+ expect(ghHosts({ GH_CONFIG_DIR: dir })).toEqual({ known: ["github.com", "ghe.example.com"], defaultHost: "github.com", source: "default" })
72
+ expect(ghHosts({ GH_CONFIG_DIR: dir, GH_HOST: "GHE.example.com" })).toMatchObject({ defaultHost: "ghe.example.com", source: "GH_HOST" })
73
+
74
+ writeFileSync(join(dir, "hosts.yml"), "ghe.example.com:\n user: me\n")
75
+ expect(ghHosts({ GH_CONFIG_DIR: dir })).toEqual({ known: ["ghe.example.com", "github.com"], defaultHost: "ghe.example.com", source: "hosts" })
76
+ })
77
+ })
78
+
79
+ test("detectRepo reads a real clone's remotes, insteadOf rewrites and gh-resolved", () => {
80
+ withTmp(dir => {
81
+ const git = (...args: string[]) => execFileSync("git", ["-C", dir, ...args])
82
+ git("init", "-q")
83
+ git("config", "url.https://github.com/.insteadOf", "gh:")
84
+ git("remote", "add", "origin", "gh:me/app")
85
+ git("remote", "add", "upstream", "gh:acme/app")
86
+ expect(detectRepo(dir, hosts())).toEqual(repo("acme", "app"))
87
+ git("config", "remote.origin.gh-resolved", "base")
88
+ expect(detectRepo(dir, hosts())).toEqual(repo("me", "app"))
89
+ })
90
+ withTmp(dir => expect(() => detectRepo(dir, hosts())).toThrow(/no git remotes/))
91
+ })