@oscbacon/tungsten 0.0.2 → 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
@@ -34,7 +34,9 @@ bun link # links `tungsten` into ~/.bun/bin; make sure that's on your PATH
34
34
  ```bash
35
35
  tungsten # repo from the current clone's git remotes, like gh
36
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)
37
38
  tungsten --demo # sample data, no clone or network
39
+ tungsten --demo-multiple # sample data from several repos
38
40
  tungsten --show-git-prompt # let c and w ask for ssh passphrases or credentials
39
41
  ```
40
42
 
@@ -48,7 +50,8 @@ tungsten --show-git-prompt # let c and w ask for ssh passphrases or credentials
48
50
  - Open
49
51
  - Recently merged
50
52
 
51
- 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.
52
55
 
53
56
  ## Keys
54
57
 
@@ -70,11 +73,12 @@ PRs from before it without refetching them (`r` refreshes them).
70
73
 
71
74
  fix token words in the title
72
75
  @login PRs by that author (several @logins: any of them)
73
- #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
74
78
 
75
79
  ## Checking out
76
80
 
77
- 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`,
78
82
  so forks and remotes behave as they do there.
79
83
 
80
84
  - `c` checks the PR out in the current clone.
package/package.json CHANGED
@@ -1,9 +1,15 @@
1
1
  {
2
2
  "name": "@oscbacon/tungsten",
3
3
  "type": "module",
4
- "version": "0.0.2",
4
+ "version": "0.1.0",
5
5
  "description": "A terminal inbox for a GitHub repo's pull requests",
6
- "keywords": ["github", "pull-requests", "tui", "terminal", "cli"],
6
+ "keywords": [
7
+ "github",
8
+ "pull-requests",
9
+ "tui",
10
+ "terminal",
11
+ "cli"
12
+ ],
7
13
  "license": "MIT",
8
14
  "author": "Oscar Baracos",
9
15
  "repository": {
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 {