@oscbacon/tungsten 0.0.1 → 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/README.md CHANGED
@@ -15,17 +15,30 @@ Lists a repo's PRs by bucket, and allow you to open the PR in your browser
15
15
 
16
16
  ## Install
17
17
 
18
- git clone git@github.com:OscBacon/tungsten.git
19
- cd tungsten
20
- bun install
21
- bun link # links `tungsten` into ~/.bun/bin; make sure that's on your PATH
18
+ ```bash
19
+ npm i -g @oscbacon/tungsten
20
+ ```
21
+
22
+ Or from the source:
23
+
24
+ ```bash
25
+ git clone git@github.com:OscBacon/tungsten.git
26
+ cd tungsten
27
+ bun install
28
+ bun link # links `tungsten` into ~/.bun/bin; make sure that's on your PATH
29
+ ```
30
+
22
31
 
23
32
  ## Usage
24
33
 
25
- tungsten # repo from the current clone's git remotes, like gh
26
- tungsten -R cli/cli # or pick one: [HOST/]OWNER/REPO
27
- tungsten --demo # sample data, no clone or network
28
- tungsten --show-git-prompt # let c and w ask for ssh passphrases or credentials
34
+ ```bash
35
+ tungsten # repo from the current clone's git remotes, like gh
36
+ tungsten -R cli/cli # or pick one: [HOST/]OWNER/REPO
37
+ tungsten -R cli/cli,cli/go-gh # or several, listed together (repeating -R works too)
38
+ tungsten --demo # sample data, no clone or network
39
+ tungsten --demo-multiple # sample data from several repos
40
+ tungsten --show-git-prompt # let c and w ask for ssh passphrases or credentials
41
+ ```
29
42
 
30
43
  ## Buckets
31
44
 
@@ -37,7 +50,8 @@ Lists a repo's PRs by bucket, and allow you to open the PR in your browser
37
50
  - Open
38
51
  - Recently merged
39
52
 
40
- Each shows up to 25 PRs and the total count.
53
+ Each shows up to 25 PRs and the total count. With several repos, each bucket holds all of
54
+ their PRs, newest first, and each row names its repo next to the author.
41
55
 
42
56
  ## Keys
43
57
 
@@ -59,11 +73,12 @@ PRs from before it without refetching them (`r` refreshes them).
59
73
 
60
74
  fix token words in the title
61
75
  @login PRs by that author (several @logins: any of them)
62
- #123 that PR, looked up directly
76
+ #123 that PR, looked up directly (in every repo shown)
77
+ go-gh#123 that PR in one repo; cli/go-gh#123 works too
63
78
 
64
79
  ## Checking out
65
80
 
66
- Both keys work from a clone of the repo on screen, and hand the PR to `gh pr checkout`,
81
+ Both keys work from a clone of the PR's repo, and hand the PR to `gh pr checkout`,
67
82
  so forks and remotes behave as they do there.
68
83
 
69
84
  - `c` checks the PR out in the current clone.
package/package.json CHANGED
@@ -1,10 +1,36 @@
1
1
  {
2
2
  "name": "@oscbacon/tungsten",
3
3
  "type": "module",
4
- "version": "0.0.1",
4
+ "version": "0.1.0",
5
+ "description": "A terminal inbox for a GitHub repo's pull requests",
6
+ "keywords": [
7
+ "github",
8
+ "pull-requests",
9
+ "tui",
10
+ "terminal",
11
+ "cli"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Oscar Baracos",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/OscBacon/tungsten.git"
18
+ },
19
+ "homepage": "https://github.com/OscBacon/tungsten#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/OscBacon/tungsten/issues"
22
+ },
5
23
  "bin": {
6
24
  "tungsten": "src/index.tsx"
7
25
  },
26
+ "files": [
27
+ "src",
28
+ "!src/**/*.test.*",
29
+ "tsconfig.json"
30
+ ],
31
+ "engines": {
32
+ "bun": ">=1.0.0"
33
+ },
8
34
  "devDependencies": {
9
35
  "@types/bun": "^1.4.2",
10
36
  "@types/react": "^19.3.0"
package/src/app.tsx CHANGED
@@ -28,7 +28,9 @@ const bucketId = (i: number) => `bucket-${i}`
28
28
  const bucketBoxId = (i: number) => `bucket-box-${i}`
29
29
  // Scoped to the bucket: one PR can sit in two buckets (say review-requested and open),
30
30
  // and each copy needs its own id to be selectable and scrollable on its own.
31
- const prId = (bucket: number, pr: PR) => `pr-${bucket}-${pr.number}`
31
+ // The repo too: with several repos, two PRs can share a number.
32
+ const prKey = (pr: PR) => `${repoLabel(pr.repo)}#${pr.number}`
33
+ const prId = (bucket: number, pr: PR) => `pr-${bucket}-${prKey(pr)}`
32
34
 
33
35
  function openInBrowser(url: string) {
34
36
  const [cmd, args] =
@@ -42,10 +44,18 @@ function openInBrowser(url: string) {
42
44
  const fit = (s: string, n: number) => (s.length > n ? s.slice(0, Math.max(0, n - 1)) + "…" : s.padEnd(n))
43
45
 
44
46
  // Fixed-width columns are padded strings, so rows line up without per-cell layout.
45
- const AUTHOR_W = 20, CHANGES_W = 11, UPDATED_W = 4
46
- const FIXED = 7 + 2 + AUTHOR_W + 2 + 1 + 2 + 1 + 2 + CHANGES_W + 2 + UPDATED_W
47
+ // With several repos the author column also names the PR's repo, so it's wider.
48
+ const AUTHOR_W = 20, AUTHOR_REPO_W = 36, CHANGES_W = 11, UPDATED_W = 4
49
+ const fixed = (authorW: number) => 7 + 2 + authorW + 2 + 1 + 2 + 1 + 2 + CHANGES_W + 2 + UPDATED_W
47
50
  const CHROME = 2 + 2 + 1 // bucket border, row padding, scrollbar
48
51
 
52
+ // How a row names its repo: just REPO, unless two of the repos share that name.
53
+ function repoTags(repos: Repo[]): (r: Repo) => string {
54
+ const name = (r: Repo) => r.name.toLowerCase()
55
+ const clash = (r: Repo) => repos.filter(o => name(o) === name(r)).length > 1
56
+ return r => (clash(r) ? `${r.owner}/${r.name}` : r.name)
57
+ }
58
+
49
59
  // Each resolves to a success message or rejects with the error to show.
50
60
  export interface Actions {
51
61
  checkout(pr: PR): Promise<string>
@@ -54,7 +64,7 @@ export interface Actions {
54
64
 
55
65
  const printable = (s: string) => s.length > 0 && !/[\x00-\x1f\x7f]/.test(s)
56
66
 
57
- export function App({ repo, load, actions }: { repo: Repo; load: (search?: string) => Promise<Bucket[]>; actions: Actions }) {
67
+ export function App({ repos, load, actions }: { repos: Repo[]; load: (search?: string) => Promise<Bucket[]>; actions: Actions }) {
58
68
  const renderer = useRenderer()
59
69
  const { width } = useTerminalDimensions()
60
70
  const scroll = useRef<ScrollBoxRenderable>(null)
@@ -201,13 +211,18 @@ export function App({ repo, load, actions }: { repo: Repo; load: (search?: strin
201
211
  }
202
212
  })
203
213
 
204
- const titleW = Math.max(12, width - FIXED - CHROME)
214
+ const multi = repos.length > 1
215
+ const authorW = multi ? AUTHOR_REPO_W : AUTHOR_W
216
+ const tag = useMemo(() => repoTags(repos), [repos])
217
+ const titleW = Math.max(12, width - fixed(authorW) - CHROME)
218
+ const all = repos.map(repoLabel).join(", ")
219
+ const label = all.length <= width / 2 ? all : `${repos.length} repos`
205
220
 
206
221
  return (
207
222
  <box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: C.bg }}>
208
223
  <box style={{ height: 1, paddingLeft: 1, flexDirection: "row", justifyContent: "space-between", paddingRight: 1 }}>
209
- <text fg={C.text}><b>Inbox</b>{search && <span fg={C.muted}>{fit(` / ${search}`, Math.max(0, width - repoLabel(repo).length - 20))}</span>}</text>
210
- <text fg={C.muted}>{loading && <span fg={C.faint}>loading… </span>}{repoLabel(repo)}</text>
224
+ <text fg={C.text}><b>Inbox</b>{search && <span fg={C.muted}>{fit(` / ${search}`, Math.max(0, width - label.length - 20))}</span>}</text>
225
+ <text fg={C.muted}>{loading && <span fg={C.faint}>loading… </span>}{label}</text>
211
226
  </box>
212
227
 
213
228
  {(error || notice) && (
@@ -242,7 +257,7 @@ export function App({ repo, load, actions }: { repo: Repo; load: (search?: strin
242
257
  </text>
243
258
  </box>
244
259
  {open && b.prs.map(pr => (
245
- <PrRow key={pr.number} id={prId(i, pr)} pr={pr} titleW={titleW} selected={current?.id === prId(i, pr)} />
260
+ <PrRow key={prKey(pr)} id={prId(i, pr)} pr={pr} repoTag={multi ? tag(pr.repo) : undefined} authorW={authorW} titleW={titleW} selected={current?.id === prId(i, pr)} />
246
261
  ))}
247
262
  </box>
248
263
  )
@@ -264,7 +279,7 @@ export function App({ repo, load, actions }: { repo: Repo; load: (search?: strin
264
279
  )
265
280
  }
266
281
 
267
- function PrRow({ id, pr, titleW, selected }: { id: string; pr: PR; titleW: number; selected: boolean }) {
282
+ function PrRow({ id, pr, repoTag, authorW, titleW, selected }: { id: string; pr: PR; repoTag?: string; authorW: number; titleW: number; selected: boolean }) {
268
283
  const [stateGlyph, stateColor] = STATE[pr.state]
269
284
  const [ciGlyph, ciColor] = STATUS[pr.ci]
270
285
  const [revGlyph, revColor] = STATUS[pr.review]
@@ -276,7 +291,7 @@ function PrRow({ id, pr, titleW, selected }: { id: string; pr: PR; titleW: numbe
276
291
  <text fg={C.text}>
277
292
  <span fg={C.blue}>{selected ? "▌" : " "}</span> <span fg={C.blue}>•</span> <span fg={stateColor}>{stateGlyph}</span>
278
293
  {" "}{selected ? <b>{fit(pr.title, titleW)}</b> : fit(pr.title, titleW)}
279
- {" "}<span fg={C.muted}>{fit(`${pr.author} #${pr.number}`, AUTHOR_W)}</span>
294
+ {" "}<span fg={C.muted}>{fit(`${pr.author} ${repoTag ?? ""}#${pr.number}`, authorW)}</span>
280
295
  {" "}<span fg={ciColor}>{ciGlyph}</span>
281
296
  {" "}<span fg={revColor}>{revGlyph}</span>
282
297
  {" "}{changesPad}<span fg={C.green}>{add}</span><span fg={C.muted}>/</span><span fg={C.red}>{del}</span>
package/src/data.ts CHANGED
@@ -15,6 +15,7 @@ export interface PR {
15
15
  updated: string
16
16
  stale?: boolean
17
17
  url: string
18
+ repo: Repo // the repo it was opened against
18
19
  branch: string // local branch name for checkouts; see localBranch() in github.ts
19
20
  }
20
21
 
@@ -25,10 +26,15 @@ export interface Bucket {
25
26
  }
26
27
 
27
28
  // Sample data for --demo and tests; the UI only depends on these shapes.
28
- export function demoBuckets(repo: Repo): Bucket[] {
29
+ // With several repos, the PRs are dealt out across them in turn.
30
+ export function demoBuckets(...repos: Repo[]): Bucket[] {
31
+ let next = 0
29
32
  // Swap for your Graphite PR link format if you'd rather open PRs there.
30
- const url = (n: number) => `https://${repo.host}/${repo.owner}/${repo.name}/pull/${n}`
31
- const pr = (p: Omit<PR, "url" | "branch">): PR => ({ ...p, url: url(p.number), branch: `${p.author}/pr-${p.number}` })
33
+ const url = (r: Repo, n: number) => `https://${r.host}/${r.owner}/${r.name}/pull/${n}`
34
+ const pr = (p: Omit<PR, "url" | "repo" | "branch">): PR => {
35
+ const repo = repos[next++ % repos.length]!
36
+ return { ...p, url: url(repo, p.number), repo, branch: `${p.author}/pr-${p.number}` }
37
+ }
32
38
 
33
39
  return [
34
40
  { name: "Needs your review", prs: [
package/src/github.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Bucket, PR, State, Status } from "./data"
2
- import type { Repo } from "./repo"
2
+ import { normalizeHost, type Repo } from "./repo"
3
3
  import { run } from "./run"
4
4
 
5
5
  export const PAGE_SIZE = 25
@@ -22,23 +22,38 @@ export const BUCKETS = [
22
22
  export interface Search {
23
23
  words: string[]
24
24
  authors: string[]
25
- numbers: number[]
25
+ numbers: PrRef[]
26
+ }
27
+
28
+ // #123 is that number in any repo shown; REPO#123 or OWNER/REPO#123 only in that one.
29
+ export interface PrRef {
30
+ number: number
31
+ repo?: string
26
32
  }
27
33
 
28
34
  export function parseSearch(input: string): Search {
29
35
  const search: Search = { words: [], authors: [], numbers: [] }
30
36
  for (const term of input.trim().split(/\s+/).filter(Boolean)) {
37
+ const ref = term.match(/^([\w.-]+(?:\/[\w.-]+)?)?#(\d+)$/)
31
38
  if (/^@[^@\s]+$/.test(term)) search.authors.push(term.slice(1))
32
- else if (/^#\d+$/.test(term)) search.numbers.push(Number(term.slice(1)))
39
+ else if (ref) search.numbers.push(ref[1] ? { number: Number(ref[2]), repo: ref[1] } : { number: Number(ref[2]) })
33
40
  else search.words.push(term)
34
41
  }
35
42
  return search
36
43
  }
37
44
 
45
+ // Whether a REPO or OWNER/REPO typed in a search names this repo.
46
+ export function repoMatches(repo: Repo, typed: string): boolean {
47
+ const t = typed.toLowerCase()
48
+ return t === (t.includes("/") ? `${repo.owner}/${repo.name}` : repo.name).toLowerCase()
49
+ }
50
+
51
+ const refMatches = (ref: PrRef, repo: Repo, number: number) => ref.number === number && (!ref.repo || repoMatches(repo, ref.repo))
52
+
38
53
  // The same test done locally, for --demo and for PRs looked up by number.
39
54
  export function matches(pr: PR, s: Search): boolean {
40
55
  const title = pr.title.toLowerCase(), author = pr.author.toLowerCase()
41
- return (s.numbers.length === 0 || s.numbers.includes(pr.number))
56
+ return (s.numbers.length === 0 || s.numbers.some(ref => refMatches(ref, pr.repo, pr.number)))
42
57
  && s.words.every(w => title.includes(w.toLowerCase()))
43
58
  && (s.authors.length === 0 || s.authors.some(a => author === a.toLowerCase()))
44
59
  }
@@ -53,10 +68,11 @@ function searchTerms(s: Search, mine: boolean): string {
53
68
  return [...s.words, ...(s.words.length ? ["in:title"] : []), ...authors].join(" ")
54
69
  }
55
70
 
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(" ")
71
+ // GitHub ORs repo: qualifiers, so one search covers every repo, sorted and counted together.
72
+ export const searchQuery = (repos: Repo[], i: number, search?: Search) =>
73
+ [`${repos.map(r => `repo:${r.owner}/${r.name}`).join(" ")} is:pr sort:updated-desc ${BUCKETS[i]!.query}`, search && searchTerms(search, isMine(i))].filter(Boolean).join(" ")
58
74
 
59
- const FIELDS = `number title url isDraft state additions deletions updatedAt reviewDecision
75
+ const FIELDS = `number title url repository { name owner { login } } isDraft state additions deletions updatedAt reviewDecision
60
76
  author { login } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
61
77
  headRefName isCrossRepository headRepositoryOwner { login } baseRepository { defaultBranchRef { name } }`
62
78
 
@@ -67,16 +83,26 @@ export function buildQuery(): string {
67
83
  return `fragment pr on PullRequest { ${FIELDS} }\nquery(${vars}) {\n viewer { login }\n ${searches.join("\n ")}\n}`
68
84
  }
69
85
 
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}`
86
+ // The numbers to look up in each repo; repos with none are left out.
87
+ export function numberLookups(repos: Repo[], refs: PrRef[]): { repo: Repo; numbers: number[] }[] {
88
+ return repos
89
+ .map(repo => ({ repo, numbers: [...new Set(refs.filter(r => !r.repo || repoMatches(repo, r.repo)).map(r => r.number))] }))
90
+ .filter(l => l.numbers.length > 0)
91
+ }
92
+
93
+ // Search doesn't match PR numbers, so #123 looks each one up directly, in every repo it may be in.
94
+ export function buildNumbersQuery(lookups: { numbers: number[] }[]): string {
95
+ const vars = lookups.map((_, i) => `$owner${i}: String!, $name${i}: String!`).join(", ")
96
+ const repos = lookups.map(({ numbers }, i) =>
97
+ `r${i}: repository(owner: $owner${i}, name: $name${i}) { ${numbers.map(n => `n${n}: pullRequest(number: ${n}) { ...pr }`).join(" ")} }`)
98
+ return `fragment pr on PullRequest { ${FIELDS} }\nquery(${vars}) {\n ${repos.join("\n ")}\n}`
74
99
  }
75
100
 
76
101
  interface PrNode {
77
102
  number: number
78
103
  title: string
79
104
  url: string
105
+ repository: { name: string; owner: { login: string } }
80
106
  isDraft: boolean
81
107
  state: string // OPEN | CLOSED | MERGED
82
108
  additions: number
@@ -102,7 +128,7 @@ export interface SearchResponse {
102
128
  }
103
129
 
104
130
  export interface NumbersResponse {
105
- data?: { repository: Record<string, unknown> | null } | null
131
+ data?: Record<string, Record<string, unknown> | null> | null // r0, r1, ... holding n<number>
106
132
  errors?: { message: string; type?: string }[]
107
133
  }
108
134
 
@@ -154,6 +180,8 @@ function toPR(n: PrNode, now: Date): PR {
154
180
  updated: relativeTime(n.updatedAt, now),
155
181
  stale: !merged && now.getTime() - Date.parse(n.updatedAt) > STALE_DAYS * 86400_000,
156
182
  url: n.url,
183
+ // The API host isn't in the response, but it's the one in the PR's url.
184
+ repo: { host: normalizeHost(new URL(n.url).hostname), owner: n.repository.owner.login, name: n.repository.name },
157
185
  branch: localBranch(n),
158
186
  }
159
187
  }
@@ -174,8 +202,8 @@ export function toNumbersBucket(res: NumbersResponse, search: Search, now = new
174
202
  // A missing number is a NOT_FOUND error next to the PRs that were found.
175
203
  const errors = res.errors?.filter(e => e.type !== "NOT_FOUND") ?? []
176
204
  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}`])
205
+ const prs = Object.values(res.data ?? {})
206
+ .flatMap(repo => Object.values(repo ?? {}))
179
207
  .filter(isPrNode)
180
208
  .map(n => toPR(n, now))
181
209
  .filter(pr => matches(pr, search))
@@ -192,9 +220,9 @@ async function gh(args: string[]) {
192
220
  }
193
221
 
194
222
  // 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> {
223
+ async function graphql<T extends { errors?: unknown[] }>(host: string, fields: Record<string, string>): Promise<T> {
196
224
  const { code, stdout, stderr } = await gh([
197
- "api", "graphql", "--hostname", repo.host,
225
+ "api", "graphql", "--hostname", host,
198
226
  ...Object.entries(fields).flatMap(([k, v]) => ["-f", `${k}=${v}`]),
199
227
  ])
200
228
 
@@ -209,14 +237,21 @@ async function graphql<T extends { errors?: unknown[] }>(repo: Repo, fields: Rec
209
237
  throw new Error(stderr.trim().split("\n")[0] || `gh exited with code ${code}`)
210
238
  }
211
239
 
212
- export async function fetchBuckets(repo: Repo, search?: Search): Promise<Bucket[]> {
240
+ // The repos all share a host (see index.tsx), so one request covers them.
241
+ export async function fetchBuckets(repos: Repo[], search?: Search): Promise<Bucket[]> {
242
+ const host = repos[0]!.host
213
243
  if (search?.numbers.length) {
214
- const res = await graphql<NumbersResponse>(repo, { query: buildNumbersQuery(search.numbers), owner: repo.owner, name: repo.name })
244
+ const lookups = numberLookups(repos, search.numbers)
245
+ if (lookups.length === 0) return [{ name: "Search results", prs: [] }]
246
+ const res = await graphql<NumbersResponse>(host, {
247
+ query: buildNumbersQuery(lookups),
248
+ ...Object.fromEntries(lookups.flatMap(({ repo }, i) => [[`owner${i}`, repo.owner], [`name${i}`, repo.name]])),
249
+ })
215
250
  return [toNumbersBucket(res, search)]
216
251
  }
217
- const res = await graphql<SearchResponse>(repo, {
252
+ const res = await graphql<SearchResponse>(host, {
218
253
  query: buildQuery(),
219
- ...Object.fromEntries(BUCKETS.map((_, i) => [`q${i}`, searchQuery(repo, i, search)])),
254
+ ...Object.fromEntries(BUCKETS.map((_, i) => [`q${i}`, searchQuery(repos, i, search)])),
220
255
  })
221
256
  return toBuckets(res, new Date(), search)
222
257
  }
package/src/index.tsx CHANGED
@@ -4,7 +4,7 @@ import { createRoot } from "@opentui/react"
4
4
  import { parseArgs } from "node:util"
5
5
  import { App, type Actions } from "./app"
6
6
  import { checkoutPr, needsPrompt, openWorktree } from "./checkout"
7
- import { demoBuckets } from "./data"
7
+ import { demoBuckets, type PR } from "./data"
8
8
  import { fetchBuckets, matches, parseSearch } from "./github"
9
9
  import { detectRepo, ghHosts, parseRepo, repoLabel, type Repo } from "./repo"
10
10
  import { run, type Run } from "./run"
@@ -12,21 +12,26 @@ import { run, type Run } from "./run"
12
12
  const USAGE = `Usage: tungsten [flags]
13
13
 
14
14
  Flags:
15
- -R, --repo [HOST/]OWNER/REPO Select another repository using the [HOST/]OWNER/REPO format
15
+ -R, --repo [HOST/]OWNER/REPO Select another repository using the [HOST/]OWNER/REPO format;
16
+ repeat it or separate repos with commas to list them together
16
17
  --demo Show sample data instead of fetching from GitHub
18
+ --demo-multiple Like --demo, with PRs from several repos
17
19
  --show-git-prompt Let c and w show git's prompts (ssh passphrase, credentials);
18
20
  the app is hidden while they run
19
21
  -h, --help Show help`
20
22
 
21
- let repo: Repo
23
+ const key = (r: Repo) => `${r.host}/${r.owner}/${r.name}`.toLowerCase()
24
+
25
+ let repos: Repo[]
22
26
  let demo: boolean
23
27
  let showGitPrompt: boolean
24
- let notLocal: string | undefined // why c and w can't work from here
28
+ let here: Repo | undefined // the repo cloned in the current folder, where c and w work
25
29
  try {
26
30
  const { values } = parseArgs({
27
31
  options: {
28
- repo: { type: "string", short: "R" },
32
+ repo: { type: "string", short: "R", multiple: true },
29
33
  demo: { type: "boolean" },
34
+ "demo-multiple": { type: "boolean" },
30
35
  "show-git-prompt": { type: "boolean" },
31
36
  help: { type: "boolean", short: "h" },
32
37
  },
@@ -35,24 +40,23 @@ try {
35
40
  console.log(USAGE)
36
41
  process.exit(0)
37
42
  }
38
- demo = values.demo ?? false
43
+ const demoMultiple = values["demo-multiple"] ?? false
44
+ demo = (values.demo ?? false) || demoMultiple
39
45
  showGitPrompt = values["show-git-prompt"] ?? false
40
46
  // 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.
47
+ // --demo shouldn't need a clone, so it falls back to sample repos.
42
48
  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
- }
49
+ const named = (values.repo ?? []).flatMap(v => v.split(",")).map(v => v.trim()).filter(Boolean)
50
+ repos =
51
+ named.length ? named.map(r => parseRepo(r, hosts.defaultHost)).filter((r, i, all) => all.findIndex(o => key(o) === key(r)) === i) :
52
+ demoMultiple ? ["withgraphite/monologue", "withgraphite/graphite-cli", "withgraphite/retype"].map(r => parseRepo(r)) :
53
+ demo ? [parseRepo("withgraphite/monologue")] :
54
+ [detectRepo(process.cwd(), hosts)]
55
+ // One GraphQL request serves every repo, and it goes to a single host.
56
+ if (new Set(repos.map(r => r.host)).size > 1) throw new Error("all repos must be on the same GitHub host")
57
+ if (!demo) {
58
+ if (!named.length) here = repos[0]
59
+ else try { here = detectRepo(process.cwd(), hosts) } catch {}
56
60
  }
57
61
  } catch (e) {
58
62
  console.error(`${(e as Error).message}\n\n${USAGE}`)
@@ -61,8 +65,8 @@ try {
61
65
 
62
66
  const load = async (search?: string) => {
63
67
  const s = search ? parseSearch(search) : undefined
64
- if (!demo) return fetchBuckets(repo, s)
65
- const buckets = demoBuckets(repo)
68
+ if (!demo) return fetchBuckets(repos, s)
69
+ const buckets = demoBuckets(...repos)
66
70
  return s ? buckets.map(b => ({ name: b.name, prs: b.prs.filter(pr => matches(pr, s)) })) : buckets
67
71
  }
68
72
 
@@ -85,11 +89,15 @@ const withHint = (p: Promise<string>) => p.catch((e: Error) => {
85
89
  throw showGitPrompt || !needsPrompt(e.message) ? e : new Error(`${e.message} (run with --show-git-prompt to answer git's prompts)`)
86
90
  })
87
91
 
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
- }
92
+ // Checkouts happen in the current folder, so it has to be a clone of the PR's repo.
93
+ const local = (pr: PR, fn: () => Promise<string>) =>
94
+ demo ? Promise.reject(new Error("not available with --demo")) :
95
+ !here || key(here) !== key(pr.repo) ? Promise.reject(new Error(`run tungsten from a clone of ${repoLabel(pr.repo)} to check out its PRs`)) :
96
+ withHint(fn())
97
+
98
+ const actions: Actions = {
99
+ checkout: pr => local(pr, () => checkoutPr(pr, pr.repo, runGit)),
100
+ worktree: pr => local(pr, () => openWorktree(pr, pr.repo, { run: runGit, tmux: !!process.env.TMUX })),
101
+ }
94
102
 
95
- createRoot(renderer).render(<App repo={repo} load={load} actions={actions} />)
103
+ createRoot(renderer).render(<App repos={repos} load={load} actions={actions} />)
package/src/repo.ts CHANGED
@@ -15,7 +15,7 @@ export interface Repo {
15
15
 
16
16
  export const DEFAULT_HOST = "github.com"
17
17
 
18
- const normalizeHost = (h: string) => h.toLowerCase().replace(/^www\./, "")
18
+ export const normalizeHost = (h: string) => h.toLowerCase().replace(/^www\./, "")
19
19
 
20
20
  // Accepts gh's [HOST/]OWNER/REPO format.
21
21
  export function parseRepo(s: string, fallbackHost = DEFAULT_HOST): Repo {
package/bun.lock DELETED
@@ -1,85 +0,0 @@
1
- {
2
- "lockfileVersion": 1,
3
- "configVersion": 1,
4
- "workspaces": {
5
- "": {
6
- "name": "tungsten",
7
- "dependencies": {
8
- "@opentui/core": "^0.5.11",
9
- "@opentui/react": "^0.5.11",
10
- "react": "^19.3.0",
11
- },
12
- "devDependencies": {
13
- "@types/bun": "^1.4.2",
14
- "@types/react": "^19.3.0",
15
- },
16
- },
17
- },
18
- "packages": {
19
- "@opentui/core": ["@opentui/core@0.5.11", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.11", "@opentui/core-darwin-x64": "0.5.11", "@opentui/core-linux-arm64": "0.5.11", "@opentui/core-linux-arm64-musl": "0.5.11", "@opentui/core-linux-x64": "0.5.11", "@opentui/core-linux-x64-musl": "0.5.11", "@opentui/core-win32-arm64": "0.5.11", "@opentui/core-win32-x64": "0.5.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-pImMfjCNx7JUp9Df1LRZBDLisWqgzOLXZKXO+hh3jA9ujBFPQnZOqbG+/N5uVAAX8IZGaqhYSQRwSZIFBmcfbQ=="],
20
-
21
- "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DRXY5ioq+n1ZNAMAcaFaBunr0cmi2gqucjbTW7lgFp8t9uN3fNZnLTDOqkCTQtT2XhrU4GXqySaPSQ4qFG/EAQ=="],
22
-
23
- "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-yP/8GliJDiJNm8YYJKvgWuy6xyCEd8d4GwBVOIzCFOI7ZIbGP8GOTvmwIjRW4Paw20pTttWMWyRoQiCvOXvH2g=="],
24
-
25
- "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-zBIsRFHlLUYFNhapRSNt9dz4mC8gZ4Wxcfy3A+2AwqsgCipcr2FkIuAXYqN08q+IvqFX7DfqgIFGWDNedHTPUg=="],
26
-
27
- "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-x+xeR2LYibvIi/qQetRjJR008sFRve60QuDcO8ItxUwzFeKTDzl5CEiZpBXfm5I4FhRNuyuj0TSPIFadMvrjFQ=="],
28
-
29
- "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-pSOXqOADrv+zINOgR3FDFA9zVRaim3zl8/yhtO+X9rEJ6f34z3gDund0Gf88hNJSMpZK5xWtipEVm28RY5VF8w=="],
30
-
31
- "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-MyqOnSs8pTYG2xmFr1xt6xZIuHu2Xu4pkle9my9JdE+WClmusHf0YN9Eas6jQLAq5XUi34r22wMeK0juk93zyw=="],
32
-
33
- "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-MGGRXIDJ//HyaqC5ndSr7/CUl+ICdYEAMcjbA00UWsthh4ZO/rYhPkyqOaTn5ck6+G4ca3/+M76J+jImMYTjNg=="],
34
-
35
- "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.11", "", { "os": "win32", "cpu": "x64" }, "sha512-sMEGX9rhiPd1gBa190jzj5uIdzKCMImxHmr22hJLxIRWqejxWvqGnRBkW68wT8NfMViDiMtZWavgWL7GgmYCAQ=="],
36
-
37
- "@opentui/react": ["@opentui/react@0.5.11", "", { "dependencies": { "@opentui/core": "0.5.11", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-ACH6Kpl3/O1zdNL0sZtzrnxfw0+auJu4eefIbPTAPuNEessyuhd2xvV4agrdWRJCeogywS7er5k7uS/8YRWnZg=="],
38
-
39
- "@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
40
-
41
- "@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="],
42
-
43
- "@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
44
-
45
- "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
46
-
47
- "bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
48
-
49
- "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
50
-
51
- "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
52
-
53
- "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
54
-
55
- "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
56
-
57
- "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
58
-
59
- "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
60
-
61
- "react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
62
-
63
- "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
64
-
65
- "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
66
-
67
- "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
68
-
69
- "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="],
70
-
71
- "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
72
-
73
- "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
74
-
75
- "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
76
-
77
- "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
78
-
79
- "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
80
-
81
- "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
82
-
83
- "react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
84
- }
85
- }
package/src/app.test.tsx DELETED
@@ -1,287 +0,0 @@
1
- import { expect, test } from "bun:test"
2
- import { testRender } from "@opentui/react/test-utils"
3
- import { act } from "react"
4
- import { App, type Actions } from "./app"
5
- import { demoBuckets, type Bucket } from "./data"
6
- import { parseRepo } from "./repo"
7
-
8
- const press = async (s: Awaited<ReturnType<typeof testRender>>, fn: () => unknown = () => {}) => {
9
- await act(async () => { await fn(); await new Promise(r => setTimeout(r, 10)) })
10
- await s.renderOnce()
11
- }
12
-
13
- // A lone ESC is only reported once the input parser gives up waiting for a longer sequence.
14
- const escape = async (s: Awaited<ReturnType<typeof testRender>>) => {
15
- await press(s, () => s.mockInput.pressEscape())
16
- await press(s)
17
- }
18
-
19
- const repo = parseRepo("withgraphite/monologue")
20
- const noActions: Actions = { checkout: async () => "", worktree: async () => "" }
21
-
22
- test("arrows move, t collapses the current bucket, cursor lands on its header", async () => {
23
- const s = await testRender(<App repo={repo} load={async () => demoBuckets(repo)} actions={noActions} />, { width: 110, height: 20 })
24
- try {
25
- await press(s) // let the load resolve
26
- await press(s, () => s.mockInput.pressArrow("down"))
27
- await press(s, () => s.mockInput.pressArrow("down"))
28
- expect(s.captureCharFrame()).toMatch(/▌ • ◷ refactor/)
29
- await press(s, () => s.mockInput.pressKey("t"))
30
- let frame = s.captureCharFrame()
31
- expect(frame).toContain("▸ Needs your review")
32
- expect(frame).not.toContain("refactor")
33
- await press(s, () => s.mockInput.pressArrow("down")) // header -> "Returned to you"
34
- await press(s, () => s.mockInput.pressArrow("down")) // -> "Approved" header
35
- await press(s, () => s.mockInput.pressArrow("down")) // -> first approved PR
36
- expect(s.captureCharFrame()).toMatch(/▌ • ✓ \[Snyk\]/)
37
- // scroll: walk to the bottom of a short viewport
38
- for (let i = 0; i < 12; i++) await press(s, () => s.mockInput.pressArrow("down"))
39
- frame = s.captureCharFrame()
40
- console.log(frame)
41
- expect(frame).toMatch(/▌ • ◆ fix: throw/)
42
- expect(frame.trimEnd().split("\n").at(-2)).toMatch(/^╰/) // last bucket fully visible
43
- } finally { s.renderer.destroy() }
44
- })
45
-
46
- test("r refreshes: loading state, cursor follows its PR, a failed refresh keeps the last data", async () => {
47
- const withoutFirstPr = (): Bucket[] => {
48
- const b = demoBuckets(repo)
49
- b[0]!.prs.shift()
50
- return b
51
- }
52
- let release!: (b: Bucket[]) => void
53
- const loads: (() => Promise<Bucket[]>)[] = [
54
- () => new Promise(r => (release = r)),
55
- async () => withoutFirstPr(),
56
- async () => { throw new Error("HTTP 401: Bad credentials") },
57
- ]
58
- let calls = 0
59
- const load = () => loads[calls++]!()
60
-
61
- const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 20 })
62
- try {
63
- await press(s)
64
- expect(s.captureCharFrame()).toContain("Loading pull requests…")
65
- await press(s, () => release(demoBuckets(repo)))
66
- await press(s, () => s.mockInput.pressArrow("down"))
67
- await press(s, () => s.mockInput.pressArrow("down"))
68
- expect(s.captureCharFrame()).toMatch(/▌ • ◷ refactor/)
69
-
70
- await press(s, () => s.mockInput.pressKey("r"))
71
- let frame = s.captureCharFrame()
72
- expect(frame).not.toContain("only find upstack")
73
- expect(frame).toMatch(/▌ • ◷ refactor/) // same PR, now the first row
74
-
75
- await press(s, () => s.mockInput.pressKey("r"))
76
- frame = s.captureCharFrame()
77
- expect(frame).toContain("✗ HTTP 401: Bad credentials")
78
- expect(frame).toMatch(/▌ • ◷ refactor/)
79
- expect(calls).toBe(3)
80
- } finally { s.renderer.destroy() }
81
- })
82
-
83
- test("a PR listed in two buckets selects and moves from the copy the cursor is on", async () => {
84
- const dup = demoBuckets(repo)[0]!.prs[0]!
85
- const buckets: Bucket[] = [
86
- { name: "Needs your review", prs: [dup] },
87
- { name: "Open", prs: [dup, { ...dup, number: 999, title: "second open pull request" }] },
88
- ]
89
- const s = await testRender(<App repo={repo} load={async () => buckets} actions={noActions} />, { width: 110, height: 20 })
90
- try {
91
- await press(s)
92
- // header -> the copy in "Needs your review" -> "Open" header -> the copy in "Open"
93
- for (let i = 0; i < 3; i++) await press(s, () => s.mockInput.pressArrow("down"))
94
- const lines = s.captureCharFrame().split("\n")
95
- const openHeader = lines.findIndex(l => l.includes("▾ Open"))
96
- expect(lines[openHeader + 1]).toContain("▌") // the copy under Open, not the one above
97
- expect(lines.slice(0, openHeader).join("\n")).not.toContain("▌")
98
-
99
- await press(s, () => s.mockInput.pressArrow("down"))
100
- expect(s.captureCharFrame()).toMatch(/▌ • ◷ second open pull request/)
101
- } finally { s.renderer.destroy() }
102
- })
103
-
104
- test("footer lists / c w between enter and r", async () => {
105
- const s = await testRender(<App repo={repo} load={async () => demoBuckets(repo)} actions={noActions} />, { width: 110, height: 20 })
106
- try {
107
- await press(s)
108
- expect(s.captureCharFrame().trimEnd().split("\n").at(-1)).toMatch(/enter open PR {3}\/ search {3}c checkout {3}w worktree {3}r refresh {3}q quit/)
109
- } finally { s.renderer.destroy() }
110
- })
111
-
112
- test("/ types a search that only loads on enter; esc clears it", async () => {
113
- const searches: (string | undefined)[] = []
114
- const load = async (search?: string) => {
115
- searches.push(search)
116
- return search ? [{ name: "Open", prs: demoBuckets(repo)[0]!.prs.slice(1, 2) }] : demoBuckets(repo)
117
- }
118
- const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 20 })
119
- try {
120
- await press(s)
121
- await press(s, () => s.mockInput.pressKey("/"))
122
- await press(s, () => s.mockInput.typeText("refactor rtq"))
123
- await press(s, () => s.mockInput.pressBackspace())
124
- let frame = s.captureCharFrame()
125
- expect(frame).toContain("/ refactor rt█")
126
- expect(frame).toContain("only find upstack") // nothing filtered while typing
127
- expect(searches).toEqual([undefined]) // and r, t, q were text, not keys
128
-
129
- await press(s, () => s.mockInput.pressEnter())
130
- frame = s.captureCharFrame()
131
- expect(searches).toEqual([undefined, "refactor rt"])
132
- expect(frame).toMatch(/Inbox {2}\/ refactor rt/)
133
- expect(frame).not.toContain("only find upstack")
134
- expect(frame).toContain("refactor: add logic")
135
- // The footer is back, with a way out in place of "/ search".
136
- expect(frame.trimEnd().split("\n").at(-1)).toMatch(/^ esc clear search {3}↑↓ move .* enter open PR {3}c checkout .* q quit/)
137
-
138
- await press(s, () => s.mockInput.pressKey("r")) // refresh keeps the search
139
- expect(searches.at(-1)).toBe("refactor rt")
140
-
141
- await press(s, () => s.mockInput.pressEscape())
142
- await press(s)
143
- expect(searches).toEqual([undefined, "refactor rt", "refactor rt"]) // cleared without a fetch
144
- frame = s.captureCharFrame()
145
- expect(frame).toContain("only find upstack")
146
- expect(frame.trimEnd().split("\n").at(-1)).toMatch(/^ ↑↓ move .* enter open PR {3}\/ search {3}c checkout/)
147
-
148
- // esc in the prompt also clears an applied search
149
- await press(s, () => s.mockInput.pressKey("/"))
150
- await press(s, () => s.mockInput.typeText("@someone"))
151
- await press(s, () => s.mockInput.pressEnter())
152
- expect(searches.at(-1)).toBe("@someone")
153
- await press(s, () => s.mockInput.pressKey("/"))
154
- expect(s.captureCharFrame()).toContain("/ @someone█") // reopens with the current search
155
- await press(s, () => s.mockInput.pressEscape())
156
- await press(s)
157
- expect(searches.at(-1)).toBe("@someone")
158
- expect(s.captureCharFrame()).toContain("only find upstack")
159
- } finally { s.renderer.destroy() }
160
- })
161
-
162
- // A load per call that the test resolves by hand, recording what each was for.
163
- function manualLoads() {
164
- const calls: { search?: string; resolve: (b: Bucket[]) => void }[] = []
165
- const load = (search?: string) => new Promise<Bucket[]>(resolve => calls.push({ search, resolve }))
166
- return { load, calls }
167
- }
168
- const titled = (title: string): Bucket[] => {
169
- const b = demoBuckets(repo)
170
- b[0]!.prs[0] = { ...b[0]!.prs[0]!, title }
171
- return b
172
- }
173
- const results = (title: string): Bucket[] => [{ name: "Search results", prs: [{ ...demoBuckets(repo)[0]!.prs[1]!, title }] }]
174
-
175
- test("clearing a search shows the last unsearched data at once, from the top; r refetches it", async () => {
176
- const { load, calls } = manualLoads()
177
- const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 20 })
178
- const search = async (q: string) => {
179
- await press(s, () => s.mockInput.pressKey("/"))
180
- await press(s, () => s.mockInput.typeText(q))
181
- await press(s, () => s.mockInput.pressEnter())
182
- }
183
- try {
184
- await press(s, () => calls[0]!.resolve(titled("first unsearched load")))
185
- await press(s, () => s.mockInput.pressArrow("down"))
186
- await press(s, () => s.mockInput.pressArrow("down"))
187
- expect(s.captureCharFrame()).toMatch(/▌ • ◷ refactor/)
188
-
189
- await search("docs")
190
- await press(s, () => calls[1]!.resolve(results("docs result")))
191
- expect(s.captureCharFrame()).toContain("docs result")
192
-
193
- await escape(s)
194
- let frame = s.captureCharFrame()
195
- expect(calls.length).toBe(2)
196
- expect(frame).toContain("first unsearched load")
197
- expect(frame).not.toContain("loading…")
198
- expect(frame).not.toContain("▌") // back at the top, on the first header
199
-
200
- // A search still loading when it's cleared can't land afterwards.
201
- await search("slow")
202
- await escape(s)
203
- await press(s, () => calls[2]!.resolve(results("late search result")))
204
- frame = s.captureCharFrame()
205
- expect(frame).toContain("first unsearched load")
206
- expect(frame).not.toContain("late search result")
207
-
208
- await press(s, () => s.mockInput.pressKey("r"))
209
- expect(calls.map(c => c.search)).toEqual([undefined, "docs", "slow", undefined])
210
- await press(s, () => calls[3]!.resolve(titled("refreshed unsearched load")))
211
- expect(s.captureCharFrame()).toContain("refreshed unsearched load")
212
- } finally { s.renderer.destroy() }
213
- })
214
-
215
- test("an unsearched load still running when a search starts is dropped, so clearing fetches", async () => {
216
- const { load, calls } = manualLoads()
217
- const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 20 })
218
- try {
219
- await press(s)
220
- await press(s, () => s.mockInput.pressKey("/"))
221
- await press(s, () => s.mockInput.typeText("docs"))
222
- await press(s, () => s.mockInput.pressEnter())
223
- await press(s, () => calls[0]!.resolve(titled("dropped unsearched load")))
224
- await press(s, () => calls[1]!.resolve(results("docs result")))
225
- expect(s.captureCharFrame()).not.toContain("dropped unsearched load")
226
-
227
- await escape(s)
228
- expect(calls.map(c => c.search)).toEqual([undefined, "docs", undefined])
229
- await press(s, () => calls[2]!.resolve(titled("fresh unsearched load")))
230
- expect(s.captureCharFrame()).toContain("fresh unsearched load")
231
- } finally { s.renderer.destroy() }
232
- })
233
-
234
- test("collapsed buckets are tracked by name across searches", async () => {
235
- const load = async (search?: string) => (search ? results("docs result") : demoBuckets(repo))
236
- const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 30 })
237
- try {
238
- await press(s)
239
- for (let i = 0; i < 5; i++) await press(s, () => s.mockInput.pressArrow("down")) // "Approved" header
240
- await press(s, () => s.mockInput.pressKey("t"))
241
- expect(s.captureCharFrame()).toContain("▸ Approved")
242
-
243
- await press(s, () => s.mockInput.pressKey("/"))
244
- await press(s, () => s.mockInput.typeText("docs"))
245
- await press(s, () => s.mockInput.pressEnter())
246
- await press(s, () => s.mockInput.pressKey("t")) // collapse "Search results", the first bucket
247
- expect(s.captureCharFrame()).toContain("▸ Search results")
248
-
249
- await escape(s)
250
- const frame = s.captureCharFrame()
251
- expect(frame).toContain("▾ Needs your review") // not collapsed by position
252
- expect(frame).toContain("▸ Approved")
253
- } finally { s.renderer.destroy() }
254
- })
255
-
256
- test("c and w act on the PR under the cursor and show the outcome", async () => {
257
- const calls: string[] = []
258
- let finish!: () => void
259
- const actions: Actions = {
260
- checkout: async pr => {
261
- calls.push(`checkout #${pr.number}`)
262
- await new Promise<void>(r => (finish = r))
263
- return `Checked out PR #${pr.number}`
264
- },
265
- worktree: async pr => { calls.push(`worktree #${pr.number}`); throw new Error("w needs gh 2.98 or newer") },
266
- }
267
- const s = await testRender(<App repo={repo} load={async () => demoBuckets(repo)} actions={actions} />, { width: 110, height: 20 })
268
- try {
269
- await press(s)
270
- await press(s, () => s.mockInput.pressKey("c")) // on a bucket header: nothing to check out
271
- expect(calls).toEqual([])
272
-
273
- await press(s, () => s.mockInput.pressArrow("down"))
274
- await press(s, () => s.mockInput.pressKey("c"))
275
- expect(s.captureCharFrame()).toContain("Checking out PR #21325…") // the app stays up meanwhile
276
- await press(s, () => s.mockInput.pressKey("c")) // ignored while busy
277
- expect(calls).toEqual(["checkout #21325"])
278
- await press(s, () => finish())
279
- expect(s.captureCharFrame()).toContain("✓ Checked out PR #21325")
280
-
281
- await press(s, () => s.mockInput.pressArrow("down"))
282
- await press(s, () => s.mockInput.pressKey("w"))
283
- await press(s)
284
- expect(calls).toEqual(["checkout #21325", "worktree #21324"])
285
- expect(s.captureCharFrame()).toContain("✗ w needs gh 2.98 or newer")
286
- } finally { s.renderer.destroy() }
287
- })
@@ -1,145 +0,0 @@
1
- import { expect, test } from "bun:test"
2
- import { checkoutPr, findWorktree, needsPrompt, openWorktree, worktreeDir } from "./checkout"
3
- import { demoBuckets, type PR } from "./data"
4
- import { parseRepo } from "./repo"
5
- import type { Run, RunResult } from "./run"
6
-
7
- const repo = parseRepo("cli/cli")
8
- const home = "/home/me"
9
- const pr: PR = { ...demoBuckets(repo)[0]!.prs[0]!, number: 42, branch: "babakks/fix-tokens" }
10
-
11
- // A fake `run` that records every command and answers from a table keyed by "cmd arg1 arg2...".
12
- // Nothing is ever executed, so no branch is checked out and no worktree is created.
13
- function fakeRun(answers: Record<string, Partial<RunResult>> = {}) {
14
- const calls: { cmd: string; interactive: boolean }[] = []
15
- const run: Run = async (cmd, args, opts) => {
16
- const line = [cmd, ...args].join(" ")
17
- calls.push({ cmd: line, interactive: !!opts?.interactive })
18
- return { code: 0, stdout: "", stderr: "", ...answers[line] }
19
- }
20
- return { run, calls, cmds: () => calls.map(c => c.cmd) }
21
- }
22
-
23
- const porcelain = `worktree /home/me/src/cli
24
- HEAD 1111111111111111111111111111111111111111
25
- branch refs/heads/trunk
26
-
27
- worktree /home/me/worktrees/cli/babakks-fix-tokens
28
- HEAD 2222222222222222222222222222222222222222
29
- branch refs/heads/babakks/fix-tokens
30
-
31
- worktree /home/me/worktrees/cli/detached
32
- HEAD 3333333333333333333333333333333333333333
33
- detached
34
- `
35
-
36
- const NEW_GH = { stdout: "Flags:\n -b, --branch string\n --worktree path Check out the pull request into a worktree\n" }
37
- const OLD_GH = { stdout: "Flags:\n -b, --branch string\n -f, --force\n" }
38
- const CHECKOUT = "gh pr checkout 42 -R github.com/cli/cli --worktree=/home/me/worktrees/cli/babakks-fix-tokens --branch=babakks/fix-tokens"
39
-
40
- test("worktreeDir flattens the branch like `wa`, under the repo's name", () => {
41
- expect(worktreeDir(repo, "babakks/fix/tokens", home)).toBe("/home/me/worktrees/cli/babakks-fix-tokens")
42
- expect(worktreeDir(repo, "main", home)).toBe("/home/me/worktrees/cli/main")
43
- })
44
-
45
- test("findWorktree matches the exact branch", () => {
46
- expect(findWorktree(porcelain, "trunk")).toBe("/home/me/src/cli")
47
- expect(findWorktree(porcelain, "babakks/fix-tokens")).toBe("/home/me/worktrees/cli/babakks-fix-tokens")
48
- expect(findWorktree(porcelain, "babakks/fix")).toBeUndefined()
49
- expect(findWorktree("", "trunk")).toBeUndefined()
50
- })
51
-
52
- test("c runs gh pr checkout against the repo on screen, with the terminal", async () => {
53
- const f = fakeRun()
54
- expect(await checkoutPr(pr, repo, f.run)).toBe("Checked out PR #42")
55
- expect(f.calls).toEqual([{ cmd: "gh pr checkout 42 -R github.com/cli/cli", interactive: true }])
56
- })
57
-
58
- test("c reports git's error line, not its fetch progress", async () => {
59
- const stderr = [
60
- "From github.com:cli/cli",
61
- " * [new ref] refs/pull/42/head -> babakks/fix-tokens",
62
- "error: Your local changes to the following files would be overwritten by checkout:",
63
- "\tREADME.md",
64
- "Aborting",
65
- "failed to run git: exit status 1",
66
- ].join("\n")
67
- const f = fakeRun({ "gh pr checkout 42 -R github.com/cli/cli": { code: 1, stderr } })
68
- await expect(checkoutPr(pr, repo, f.run)).rejects.toThrow(/^error: Your local changes/)
69
-
70
- const bare = fakeRun({ "gh pr checkout 42 -R github.com/cli/cli": { code: 1 } })
71
- await expect(checkoutPr(pr, repo, bare.run)).rejects.toThrow("gh pr checkout exited with code 1")
72
- })
73
-
74
- test("c reports the prompt it couldn't show, so the error can point at --show-git-prompt", async () => {
75
- const stderr = [
76
- "git@github.com: Permission denied (publickey).",
77
- "fatal: Could not read from remote repository.",
78
- "",
79
- "Please make sure you have the correct access rights",
80
- "failed to run git: exit status 128",
81
- ].join("\n")
82
- const f = fakeRun({ "gh pr checkout 42 -R github.com/cli/cli": { code: 1, stderr } })
83
- await expect(checkoutPr(pr, repo, f.run)).rejects.toThrow("git@github.com: Permission denied (publickey).")
84
-
85
- for (const msg of ["Host key verification failed.", "fatal: could not read Username for 'https://github.com': terminal prompts disabled"]) {
86
- expect(needsPrompt(msg)).toBe(true)
87
- }
88
- expect(needsPrompt("fatal: not a git repository")).toBe(false)
89
- })
90
-
91
- test("c without gh installed says so", async () => {
92
- const run: Run = async () => { throw Object.assign(new Error("spawn gh ENOENT"), { code: "ENOENT" }) }
93
- await expect(checkoutPr(pr, repo, run)).rejects.toThrow("gh is not installed")
94
- })
95
-
96
- test("w creates the worktree with gh and reports where, outside tmux", async () => {
97
- const f = fakeRun({ "git worktree list --porcelain": { stdout: porcelain.split("\n\n")[0] }, "gh pr checkout --help": NEW_GH })
98
- expect(await openWorktree(pr, repo, { run: f.run, tmux: false, home })).toBe("Worktree at ~/worktrees/cli/babakks-fix-tokens")
99
- expect(f.calls).toEqual([
100
- { cmd: "git worktree list --porcelain", interactive: false },
101
- { cmd: "gh pr checkout --help", interactive: false },
102
- { cmd: CHECKOUT, interactive: true },
103
- ])
104
- })
105
-
106
- test("w inside tmux opens a window in the new worktree", async () => {
107
- const f = fakeRun({ "gh pr checkout --help": NEW_GH })
108
- expect(await openWorktree(pr, repo, { run: f.run, tmux: true, home })).toBe("Opened ~/worktrees/cli/babakks-fix-tokens in a new tmux window")
109
- expect(f.cmds().slice(-2)).toEqual([CHECKOUT, "tmux new-window -c /home/me/worktrees/cli/babakks-fix-tokens"])
110
- })
111
-
112
- test("w reuses a worktree that already has the branch, wherever it is", async () => {
113
- const f = fakeRun({ "git worktree list --porcelain": { stdout: porcelain } })
114
- expect(await openWorktree(pr, repo, { run: f.run, tmux: true, home })).toBe("Opened ~/worktrees/cli/babakks-fix-tokens in a new tmux window")
115
- expect(f.cmds()).toEqual(["git worktree list --porcelain", "tmux new-window -c /home/me/worktrees/cli/babakks-fix-tokens"])
116
-
117
- // The branch checked out in the main clone, outside ~/worktrees.
118
- const main = fakeRun({ "git worktree list --porcelain": { stdout: porcelain } })
119
- expect(await openWorktree({ ...pr, branch: "trunk" }, repo, { run: main.run, tmux: false, home })).toBe("Worktree already at ~/src/cli")
120
- expect(main.cmds()).toEqual(["git worktree list --porcelain"])
121
- })
122
-
123
- test("w passes gh the branch name from the PR (a fork's may carry an owner prefix)", async () => {
124
- const f = fakeRun({ "gh pr checkout --help": NEW_GH })
125
- await openWorktree({ ...pr, branch: "octo/trunk" }, repo, { run: f.run, tmux: false, home })
126
- expect(f.cmds().at(-1)).toBe("gh pr checkout 42 -R github.com/cli/cli --worktree=/home/me/worktrees/cli/octo-trunk --branch=octo/trunk")
127
- })
128
-
129
- test("w needs gh 2.98+, and runs nothing when gh is older", async () => {
130
- const f = fakeRun({ "gh pr checkout --help": OLD_GH })
131
- await expect(openWorktree(pr, repo, { run: f.run, tmux: true, home })).rejects.toThrow("w needs gh 2.98 or newer")
132
- expect(f.cmds()).toEqual(["git worktree list --porcelain", "gh pr checkout --help"])
133
- })
134
-
135
- test("w surfaces gh, git and tmux failures", async () => {
136
- const gitFails = fakeRun({ "git worktree list --porcelain": { code: 128, stderr: "fatal: not a git repository (or any of the parent directories): .git" } })
137
- await expect(openWorktree(pr, repo, { run: gitFails.run, tmux: false, home })).rejects.toThrow("fatal: not a git repository")
138
-
139
- const ghFails = fakeRun({ "gh pr checkout --help": NEW_GH, [CHECKOUT]: { code: 1, stderr: "--worktree path must be a directory: /home/me/worktrees/cli/babakks-fix-tokens\n" } })
140
- await expect(openWorktree(pr, repo, { run: ghFails.run, tmux: true, home })).rejects.toThrow("--worktree path must be a directory")
141
- expect(ghFails.cmds()).not.toContain("tmux new-window -c /home/me/worktrees/cli/babakks-fix-tokens")
142
-
143
- const tmuxFails = fakeRun({ "git worktree list --porcelain": { stdout: porcelain }, "tmux new-window -c /home/me/worktrees/cli/babakks-fix-tokens": { code: 1, stderr: "no server running on /tmp/tmux-1000/default" } })
144
- await expect(openWorktree(pr, repo, { run: tmuxFails.run, tmux: true, home })).rejects.toThrow("no server running")
145
- })
@@ -1,121 +0,0 @@
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/repo.test.ts DELETED
@@ -1,91 +0,0 @@
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
- })
package/src/run.test.ts DELETED
@@ -1,28 +0,0 @@
1
- import { expect, test } from "bun:test"
2
- import { run } from "./run"
3
-
4
- // Prints the prompt switches and whether the shell leads its own session (sid == pid).
5
- const probe = ["-c", 'echo "$GIT_TERMINAL_PROMPT/$GH_PROMPT_DISABLED"; [ "$(ps -o sid= -p $$ | tr -d " ")" = "$$" ] && echo own-session || echo shared-session']
6
-
7
- test.skipIf(process.platform === "win32")("noPrompts runs without a controlling terminal and with prompts disabled", async () => {
8
- const { code, stdout } = await run("sh", probe, { noPrompts: true })
9
- expect(code).toBe(0)
10
- expect(stdout).toBe("0/1\nown-session\n")
11
- })
12
-
13
- test.skipIf(process.platform === "win32")("by default the command keeps the app's session and environment", async () => {
14
- const saved = [process.env.GIT_TERMINAL_PROMPT, process.env.GH_PROMPT_DISABLED]
15
- delete process.env.GIT_TERMINAL_PROMPT
16
- delete process.env.GH_PROMPT_DISABLED
17
- try {
18
- expect((await run("sh", probe)).stdout).toBe("/\nshared-session\n")
19
- } finally {
20
- if (saved[0] !== undefined) process.env.GIT_TERMINAL_PROMPT = saved[0]
21
- if (saved[1] !== undefined) process.env.GH_PROMPT_DISABLED = saved[1]
22
- }
23
- })
24
-
25
- test("run reports exit codes and output, and rejects when the command is missing", async () => {
26
- expect(await run("sh", ["-c", "echo out; echo err >&2; exit 3"])).toEqual({ code: 3, stdout: "out\n", stderr: "err\n" })
27
- await expect(run("tungsten-no-such-command", [])).rejects.toMatchObject({ code: "ENOENT" })
28
- })