@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 +7 -3
- package/package.json +8 -2
- package/src/app.tsx +25 -10
- package/src/data.ts +9 -3
- package/src/github.ts +55 -20
- package/src/index.tsx +37 -29
- package/src/repo.ts +1 -1
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
|
|
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
|
|
4
|
+
"version": "0.1.0",
|
|
5
5
|
"description": "A terminal inbox for a GitHub repo's pull requests",
|
|
6
|
-
"keywords": [
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
const
|
|
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({
|
|
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
|
|
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 -
|
|
210
|
-
<text fg={C.muted}>{loading && <span fg={C.faint}>loading… </span>}{
|
|
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
|
|
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}`,
|
|
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
|
-
|
|
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://${
|
|
31
|
-
const pr = (p: Omit<PR, "url" | "branch">): PR =>
|
|
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
|
|
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:
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
57
|
-
|
|
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
|
-
//
|
|
71
|
-
export function
|
|
72
|
-
|
|
73
|
-
|
|
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?:
|
|
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 =
|
|
178
|
-
.
|
|
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[] }>(
|
|
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",
|
|
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
|
-
|
|
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
|
|
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>(
|
|
252
|
+
const res = await graphql<SearchResponse>(host, {
|
|
218
253
|
query: buildQuery(),
|
|
219
|
-
...Object.fromEntries(BUCKETS.map((_, i) => [`q${i}`, searchQuery(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
47
|
+
// --demo shouldn't need a clone, so it falls back to sample repos.
|
|
42
48
|
const hosts = ghHosts()
|
|
43
|
-
repo
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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(
|
|
65
|
-
const buckets = demoBuckets(
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
:
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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 {
|