@oscbacon/tungsten 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/bun.lock +85 -0
- package/package.json +21 -0
- package/src/app.test.tsx +287 -0
- package/src/app.tsx +287 -0
- package/src/checkout.test.ts +145 -0
- package/src/checkout.ts +83 -0
- package/src/data.ts +57 -0
- package/src/github.test.ts +121 -0
- package/src/github.ts +222 -0
- package/src/index.tsx +95 -0
- package/src/repo.test.ts +91 -0
- package/src/repo.ts +182 -0
- package/src/run.test.ts +28 -0
- package/src/run.ts +36 -0
- package/tsconfig.json +18 -0
package/src/app.tsx
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
+
import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"
|
|
3
|
+
import { spawn } from "node:child_process"
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
5
|
+
import type { Bucket, PR, State, Status } from "./data"
|
|
6
|
+
import { repoLabel, type Repo } from "./repo"
|
|
7
|
+
|
|
8
|
+
const C = {
|
|
9
|
+
bg: "#1c1d21", border: "#34353b", borderActive: "#7b8cff", selected: "#2b2d3a",
|
|
10
|
+
text: "#ececef", muted: "#9a9ba2", faint: "#6c6d74",
|
|
11
|
+
green: "#5fb865", red: "#e5534b", blue: "#7b8cff", purple: "#8e6bf2", orange: "#e3a23b",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const STATUS: Record<Status, [string, string]> = {
|
|
15
|
+
pass: ["✓", C.green], fail: ["✗", C.red], pending: ["◷", C.faint], running: ["●", C.orange], merged: ["◆", C.purple],
|
|
16
|
+
}
|
|
17
|
+
const STATE: Record<State, [string, string]> = {
|
|
18
|
+
review: ["◷", C.muted], approved: ["✓", C.green], draft: ["✎", C.muted], merged: ["◆", C.purple], author: ["↩", C.orange],
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Navigation walks one flat list: bucket headers plus the rows of open buckets.
|
|
22
|
+
// Headers stay in the list so a collapsed bucket can still be reached and reopened.
|
|
23
|
+
type Item =
|
|
24
|
+
| { kind: "bucket"; id: string; bucket: number }
|
|
25
|
+
| { kind: "pr"; id: string; bucket: number; pr: PR }
|
|
26
|
+
|
|
27
|
+
const bucketId = (i: number) => `bucket-${i}`
|
|
28
|
+
const bucketBoxId = (i: number) => `bucket-box-${i}`
|
|
29
|
+
// Scoped to the bucket: one PR can sit in two buckets (say review-requested and open),
|
|
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}`
|
|
32
|
+
|
|
33
|
+
function openInBrowser(url: string) {
|
|
34
|
+
const [cmd, args] =
|
|
35
|
+
process.platform === "darwin" ? ["open", [url]] :
|
|
36
|
+
process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] :
|
|
37
|
+
process.env.WSL_DISTRO_NAME ? ["wslview", [url]] :
|
|
38
|
+
["xdg-open", [url]]
|
|
39
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const fit = (s: string, n: number) => (s.length > n ? s.slice(0, Math.max(0, n - 1)) + "…" : s.padEnd(n))
|
|
43
|
+
|
|
44
|
+
// 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
|
+
const CHROME = 2 + 2 + 1 // bucket border, row padding, scrollbar
|
|
48
|
+
|
|
49
|
+
// Each resolves to a success message or rejects with the error to show.
|
|
50
|
+
export interface Actions {
|
|
51
|
+
checkout(pr: PR): Promise<string>
|
|
52
|
+
worktree(pr: PR): Promise<string>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const printable = (s: string) => s.length > 0 && !/[\x00-\x1f\x7f]/.test(s)
|
|
56
|
+
|
|
57
|
+
export function App({ repo, load, actions }: { repo: Repo; load: (search?: string) => Promise<Bucket[]>; actions: Actions }) {
|
|
58
|
+
const renderer = useRenderer()
|
|
59
|
+
const { width } = useTerminalDimensions()
|
|
60
|
+
const scroll = useRef<ScrollBoxRenderable>(null)
|
|
61
|
+
const [buckets, setBuckets] = useState<Bucket[]>([])
|
|
62
|
+
const [loading, setLoading] = useState(true)
|
|
63
|
+
const [error, setError] = useState<string>()
|
|
64
|
+
// By name, so a bucket stays collapsed across refreshes and searches that change the bucket list.
|
|
65
|
+
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set())
|
|
66
|
+
const [cursorId, setCursorId] = useState<string>()
|
|
67
|
+
const lastIndex = useRef(0)
|
|
68
|
+
const [search, setSearch] = useState("") // applied: loads and refreshes use it
|
|
69
|
+
// The search being typed, while the "/" prompt is open. Keys can arrive faster than renders
|
|
70
|
+
// (typing fast, pasting), so each one reads and writes the ref, not a stale render's value.
|
|
71
|
+
const [input, setInputState] = useState<string>()
|
|
72
|
+
const inputRef = useRef<string>(undefined)
|
|
73
|
+
const setInput = (v: string | undefined) => { inputRef.current = v; setInputState(v) }
|
|
74
|
+
const [notice, setNotice] = useState<{ text: string; color: string }>()
|
|
75
|
+
const [busy, setBusy] = useState(false)
|
|
76
|
+
|
|
77
|
+
// The last unsearched buckets, so clearing a search puts them back without a fetch; r refreshes them.
|
|
78
|
+
const home = useRef<Bucket[]>(undefined)
|
|
79
|
+
|
|
80
|
+
// A failed refresh keeps the last good data on screen. Only the latest load lands,
|
|
81
|
+
// so a slow one can't overwrite the results of a newer search.
|
|
82
|
+
const latest = useRef(0)
|
|
83
|
+
const refresh = useCallback((search: string) => {
|
|
84
|
+
const id = ++latest.current
|
|
85
|
+
setLoading(true)
|
|
86
|
+
load(search || undefined)
|
|
87
|
+
.then(b => {
|
|
88
|
+
if (id !== latest.current) return
|
|
89
|
+
setBuckets(b)
|
|
90
|
+
setError(undefined)
|
|
91
|
+
if (!search) home.current = b
|
|
92
|
+
}, (e: unknown) => { if (id === latest.current) setError(e instanceof Error ? e.message : String(e)) })
|
|
93
|
+
.finally(() => { if (id === latest.current) setLoading(false) })
|
|
94
|
+
}, [load])
|
|
95
|
+
useEffect(() => refresh(""), [refresh])
|
|
96
|
+
|
|
97
|
+
const applySearch = (next: string) => {
|
|
98
|
+
if (next === search) return
|
|
99
|
+
setSearch(next)
|
|
100
|
+
setCursorId(undefined) // new results: start from the top
|
|
101
|
+
lastIndex.current = 0
|
|
102
|
+
if (next || !home.current) return refresh(next)
|
|
103
|
+
latest.current++ // drop a search still loading
|
|
104
|
+
setBuckets(home.current)
|
|
105
|
+
setError(undefined)
|
|
106
|
+
setLoading(false)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const items = useMemo<Item[]>(() => buckets.flatMap((b, i) => [
|
|
110
|
+
{ kind: "bucket" as const, id: bucketId(i), bucket: i },
|
|
111
|
+
...(collapsed.has(b.name) ? [] : b.prs.map(pr => ({ kind: "pr" as const, id: prId(i, pr), bucket: i, pr }))),
|
|
112
|
+
]), [buckets, collapsed])
|
|
113
|
+
|
|
114
|
+
// The cursor follows an id so it stays on the same PR across refreshes;
|
|
115
|
+
// if that PR is gone, it keeps its position instead.
|
|
116
|
+
const found = items.findIndex(it => it.id === cursorId)
|
|
117
|
+
const index = found >= 0 ? found : Math.min(lastIndex.current, items.length - 1)
|
|
118
|
+
lastIndex.current = Math.max(0, index)
|
|
119
|
+
const current = items[index]
|
|
120
|
+
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const box = scroll.current
|
|
123
|
+
if (!box || !current) return
|
|
124
|
+
// At a bucket's edge, first reveal the whole bucket so its bottom border isn't clipped.
|
|
125
|
+
const bucket = buckets[current.bucket]!
|
|
126
|
+
const rows = collapsed.has(bucket.name) ? [] : bucket.prs
|
|
127
|
+
const atBucketEnd = rows.length === 0 || (current.kind === "pr" && current.pr === rows.at(-1))
|
|
128
|
+
if (atBucketEnd) box.scrollChildIntoView(bucketBoxId(current.bucket))
|
|
129
|
+
box.scrollChildIntoView(current.id)
|
|
130
|
+
}, [current?.id, buckets, collapsed])
|
|
131
|
+
|
|
132
|
+
const move = (delta: number) => {
|
|
133
|
+
const next = items[Math.min(items.length - 1, Math.max(0, index + delta))]
|
|
134
|
+
if (next) setCursorId(next.id)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const toggle = (bucket: number) => {
|
|
138
|
+
const name = buckets[bucket]!.name
|
|
139
|
+
setCollapsed(prev => {
|
|
140
|
+
const next = new Set(prev)
|
|
141
|
+
next.has(name) ? next.delete(name) : next.add(name)
|
|
142
|
+
return next
|
|
143
|
+
})
|
|
144
|
+
// Park the cursor on the header: the rows below it may vanish.
|
|
145
|
+
setCursorId(bucketId(bucket))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const runAction = (fn: (pr: PR) => Promise<string>, pending: string) => {
|
|
149
|
+
if (busy || current?.kind !== "pr") return
|
|
150
|
+
setBusy(true)
|
|
151
|
+
setNotice({ text: `${pending} PR #${current.pr.number}…`, color: C.muted })
|
|
152
|
+
fn(current.pr)
|
|
153
|
+
.then(text => setNotice({ text: `✓ ${text}`, color: C.green }),
|
|
154
|
+
(e: unknown) => setNotice({ text: `✗ ${e instanceof Error ? e.message : String(e)}`, color: C.red }))
|
|
155
|
+
.finally(() => setBusy(false))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
useKeyboard(key => {
|
|
159
|
+
// While typing a search, keys are text; only enter and esc leave the prompt.
|
|
160
|
+
const input = inputRef.current
|
|
161
|
+
if (input !== undefined) {
|
|
162
|
+
if (key.name === "escape") { setInput(undefined); applySearch("") }
|
|
163
|
+
else if (key.name === "return") { setInput(undefined); applySearch(input.trim()) }
|
|
164
|
+
else if (key.name === "backspace") setInput(input.slice(0, -1))
|
|
165
|
+
else if (!key.ctrl && !key.meta && printable(key.sequence)) setInput(input + key.sequence)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
if (key.ctrl || key.meta) return
|
|
169
|
+
if (key.sequence === "/") return setInput(search)
|
|
170
|
+
switch (key.name) {
|
|
171
|
+
case "up":
|
|
172
|
+
case "k":
|
|
173
|
+
move(-1)
|
|
174
|
+
break
|
|
175
|
+
case "down":
|
|
176
|
+
case "j":
|
|
177
|
+
move(1)
|
|
178
|
+
break
|
|
179
|
+
case "t":
|
|
180
|
+
if (current) toggle(current.bucket)
|
|
181
|
+
break
|
|
182
|
+
case "return":
|
|
183
|
+
if (current?.kind === "pr") openInBrowser(current.pr.url)
|
|
184
|
+
else if (current) toggle(current.bucket)
|
|
185
|
+
break
|
|
186
|
+
case "c":
|
|
187
|
+
runAction(actions.checkout, "Checking out")
|
|
188
|
+
break
|
|
189
|
+
case "w":
|
|
190
|
+
runAction(actions.worktree, "Opening a worktree for")
|
|
191
|
+
break
|
|
192
|
+
case "escape":
|
|
193
|
+
applySearch("")
|
|
194
|
+
break
|
|
195
|
+
case "r":
|
|
196
|
+
if (!loading) refresh(search)
|
|
197
|
+
break
|
|
198
|
+
case "q":
|
|
199
|
+
renderer.destroy()
|
|
200
|
+
process.exit(0)
|
|
201
|
+
}
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
const titleW = Math.max(12, width - FIXED - CHROME)
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<box style={{ flexDirection: "column", width: "100%", height: "100%", backgroundColor: C.bg }}>
|
|
208
|
+
<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>
|
|
211
|
+
</box>
|
|
212
|
+
|
|
213
|
+
{(error || notice) && (
|
|
214
|
+
<box style={{ height: 1, paddingLeft: 1, paddingRight: 1 }}>
|
|
215
|
+
<text fg={error ? C.red : notice!.color}>{fit(error ? `✗ ${error}` : notice!.text, Math.max(0, width - 2))}</text>
|
|
216
|
+
</box>
|
|
217
|
+
)}
|
|
218
|
+
|
|
219
|
+
<scrollbox ref={scroll} style={{ flexGrow: 1 }}>
|
|
220
|
+
{buckets.length === 0 && (
|
|
221
|
+
<box style={{ height: 1, paddingLeft: 1 }}>
|
|
222
|
+
<text fg={C.faint}>{loading ? "Loading pull requests…" : "No pull requests loaded. Press r to retry."}</text>
|
|
223
|
+
</box>
|
|
224
|
+
)}
|
|
225
|
+
{buckets.map((b, i) => {
|
|
226
|
+
const count = b.total ?? b.prs.length
|
|
227
|
+
const open = !collapsed.has(b.name)
|
|
228
|
+
const onHeader = current?.id === bucketId(i)
|
|
229
|
+
return (
|
|
230
|
+
<box
|
|
231
|
+
key={b.name}
|
|
232
|
+
id={bucketBoxId(i)}
|
|
233
|
+
border
|
|
234
|
+
borderStyle="rounded"
|
|
235
|
+
borderColor={current?.bucket === i ? C.borderActive : C.border}
|
|
236
|
+
style={{ flexDirection: "column" }}
|
|
237
|
+
>
|
|
238
|
+
<box id={bucketId(i)} style={{ height: 1, paddingLeft: 1, backgroundColor: onHeader ? C.selected : C.bg }}>
|
|
239
|
+
<text fg={C.text}>
|
|
240
|
+
<span fg={C.muted}>{count === 0 ? " " : open ? "▾" : "▸"}</span> {b.name}{" "}
|
|
241
|
+
<span fg={C.faint}>{count === 0 ? "- No pull requests" : String(count)}</span>
|
|
242
|
+
</text>
|
|
243
|
+
</box>
|
|
244
|
+
{open && b.prs.map(pr => (
|
|
245
|
+
<PrRow key={pr.number} id={prId(i, pr)} pr={pr} titleW={titleW} selected={current?.id === prId(i, pr)} />
|
|
246
|
+
))}
|
|
247
|
+
</box>
|
|
248
|
+
)
|
|
249
|
+
})}
|
|
250
|
+
</scrollbox>
|
|
251
|
+
|
|
252
|
+
<box style={{ height: 1, paddingLeft: 1 }}>
|
|
253
|
+
{input !== undefined ? (
|
|
254
|
+
<text fg={C.faint}>
|
|
255
|
+
<span fg={C.text}>/ {input}█</span> <span fg={C.text}>enter</span> search <span fg={C.text}>esc</span> clear words match titles, @login authors, #123 numbers
|
|
256
|
+
</text>
|
|
257
|
+
) : (
|
|
258
|
+
<text fg={C.faint}>
|
|
259
|
+
{search && <><span fg={C.text}>esc</span> clear search </>}<span fg={C.text}>↑↓</span> move <span fg={C.text}>t</span> toggle bucket <span fg={C.text}>enter</span> open PR {!search && <><span fg={C.text}>/</span> search </>}<span fg={C.text}>c</span> checkout <span fg={C.text}>w</span> worktree <span fg={C.text}>r</span> refresh <span fg={C.text}>q</span> quit
|
|
260
|
+
</text>
|
|
261
|
+
)}
|
|
262
|
+
</box>
|
|
263
|
+
</box>
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function PrRow({ id, pr, titleW, selected }: { id: string; pr: PR; titleW: number; selected: boolean }) {
|
|
268
|
+
const [stateGlyph, stateColor] = STATE[pr.state]
|
|
269
|
+
const [ciGlyph, ciColor] = STATUS[pr.ci]
|
|
270
|
+
const [revGlyph, revColor] = STATUS[pr.review]
|
|
271
|
+
const add = `+${pr.additions}`, del = `-${pr.deletions}`
|
|
272
|
+
const changesPad = " ".repeat(Math.max(0, CHANGES_W - add.length - del.length - 1))
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<box id={id} style={{ height: 1, paddingLeft: 1, paddingRight: 1, backgroundColor: selected ? C.selected : C.bg }}>
|
|
276
|
+
<text fg={C.text}>
|
|
277
|
+
<span fg={C.blue}>{selected ? "▌" : " "}</span> <span fg={C.blue}>•</span> <span fg={stateColor}>{stateGlyph}</span>
|
|
278
|
+
{" "}{selected ? <b>{fit(pr.title, titleW)}</b> : fit(pr.title, titleW)}
|
|
279
|
+
{" "}<span fg={C.muted}>{fit(`${pr.author} #${pr.number}`, AUTHOR_W)}</span>
|
|
280
|
+
{" "}<span fg={ciColor}>{ciGlyph}</span>
|
|
281
|
+
{" "}<span fg={revColor}>{revGlyph}</span>
|
|
282
|
+
{" "}{changesPad}<span fg={C.green}>{add}</span><span fg={C.muted}>/</span><span fg={C.red}>{del}</span>
|
|
283
|
+
{" "}<span fg={pr.stale ? C.red : C.text}>{pr.updated.padStart(UPDATED_W)}</span>
|
|
284
|
+
</text>
|
|
285
|
+
</box>
|
|
286
|
+
)
|
|
287
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
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
|
+
})
|
package/src/checkout.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { homedir } from "node:os"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import type { PR } from "./data"
|
|
4
|
+
import type { Repo } from "./repo"
|
|
5
|
+
import type { Run, RunResult } from "./run"
|
|
6
|
+
|
|
7
|
+
// Both keys delegate to `gh pr checkout`, which already handles forks, remotes and upstream config
|
|
8
|
+
// (cli/cli pkg/cmd/pr/checkout). This module only decides what to ask it for.
|
|
9
|
+
|
|
10
|
+
const repoArg = (r: Repo) => `${r.host}/${r.owner}/${r.name}`
|
|
11
|
+
|
|
12
|
+
// ~/worktrees/<repo>/<branch>, with slashes in the branch flattened like the `wa` shell function.
|
|
13
|
+
export const worktreeDir = (repo: Repo, branch: string, home = homedir()) =>
|
|
14
|
+
join(home, "worktrees", repo.name, branch.replaceAll("/", "-"))
|
|
15
|
+
|
|
16
|
+
// The path of the worktree that has `branch` checked out, from `git worktree list --porcelain`.
|
|
17
|
+
export function findWorktree(porcelain: string, branch: string): string | undefined {
|
|
18
|
+
for (const block of porcelain.split("\n\n")) {
|
|
19
|
+
const lines = block.split("\n")
|
|
20
|
+
if (lines.includes(`branch refs/heads/${branch}`)) return lines.find(l => l.startsWith("worktree "))?.slice("worktree ".length)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// What ssh and git print when they needed to prompt but couldn't (see RunOptions.noPrompts).
|
|
25
|
+
const PROMPT_NEEDED = /Permission denied \(publickey|Host key verification failed|terminal prompts disabled|can't open \/dev\/tty/
|
|
26
|
+
export const needsPrompt = (message: string) => PROMPT_NEEDED.test(message)
|
|
27
|
+
|
|
28
|
+
// git's fetch progress goes to stderr too, so prefer the line that explains the failure over the first one.
|
|
29
|
+
function failure(r: RunResult, what: string): Error {
|
|
30
|
+
const lines = r.stderr.split("\n").map(l => l.trim()).filter(Boolean)
|
|
31
|
+
const line = lines.find(needsPrompt) ?? lines.find(l => /^(error|fatal):/.test(l)) ?? lines.at(-1)
|
|
32
|
+
return new Error(line ?? `${what} exited with code ${r.code}`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function gh(run: Run, args: string[], interactive = false) {
|
|
36
|
+
try {
|
|
37
|
+
return await run("gh", args, { interactive })
|
|
38
|
+
} catch (e) {
|
|
39
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") throw new Error("gh is not installed (https://cli.github.com)")
|
|
40
|
+
throw e
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function checkoutPr(pr: PR, repo: Repo, run: Run): Promise<string> {
|
|
45
|
+
const r = await gh(run, ["pr", "checkout", String(pr.number), "-R", repoArg(repo)], true)
|
|
46
|
+
if (r.code !== 0) throw failure(r, "gh pr checkout")
|
|
47
|
+
return `Checked out PR #${pr.number}`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface WorktreeEnv {
|
|
51
|
+
run: Run
|
|
52
|
+
tmux: boolean // inside a tmux session ($TMUX is set)
|
|
53
|
+
home?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Reuses the worktree that already has the PR's branch, or creates one; then opens a tmux window there.
|
|
57
|
+
export async function openWorktree(pr: PR, repo: Repo, { run, tmux, home = homedir() }: WorktreeEnv): Promise<string> {
|
|
58
|
+
const list = await run("git", ["worktree", "list", "--porcelain"])
|
|
59
|
+
if (list.code !== 0) throw failure(list, "git worktree list")
|
|
60
|
+
|
|
61
|
+
// gh doesn't check this, and `git worktree add` refuses a branch that's checked out elsewhere.
|
|
62
|
+
let dir = findWorktree(list.stdout, pr.branch)
|
|
63
|
+
const existed = dir !== undefined
|
|
64
|
+
if (dir === undefined) {
|
|
65
|
+
const help = await gh(run, ["pr", "checkout", "--help"])
|
|
66
|
+
if (!`${help.stdout}${help.stderr}`.includes("--worktree")) {
|
|
67
|
+
throw new Error("w needs gh 2.98 or newer (for gh pr checkout --worktree); please upgrade gh")
|
|
68
|
+
}
|
|
69
|
+
dir = worktreeDir(repo, pr.branch, home)
|
|
70
|
+
// --branch pins the name we looked up above; `=` keeps a leading "-" from reading as a flag.
|
|
71
|
+
const args = ["pr", "checkout", String(pr.number), "-R", repoArg(repo), `--worktree=${dir}`, `--branch=${pr.branch}`]
|
|
72
|
+
const r = await gh(run, args, true)
|
|
73
|
+
if (r.code !== 0) throw failure(r, "gh pr checkout")
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const shown = dir === home || dir.startsWith(home + "/") ? "~" + dir.slice(home.length) : dir
|
|
77
|
+
if (tmux) {
|
|
78
|
+
const t = await run("tmux", ["new-window", "-c", dir])
|
|
79
|
+
if (t.code !== 0) throw failure(t, "tmux new-window")
|
|
80
|
+
return `Opened ${shown} in a new tmux window`
|
|
81
|
+
}
|
|
82
|
+
return existed ? `Worktree already at ${shown}` : `Worktree at ${shown}`
|
|
83
|
+
}
|
package/src/data.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Repo } from "./repo"
|
|
2
|
+
|
|
3
|
+
export type Status = "pass" | "fail" | "pending" | "running" | "merged"
|
|
4
|
+
export type State = "review" | "approved" | "draft" | "merged" | "author"
|
|
5
|
+
|
|
6
|
+
export interface PR {
|
|
7
|
+
number: number
|
|
8
|
+
title: string
|
|
9
|
+
author: string
|
|
10
|
+
state: State
|
|
11
|
+
ci: Status
|
|
12
|
+
review: Status
|
|
13
|
+
additions: number
|
|
14
|
+
deletions: number
|
|
15
|
+
updated: string
|
|
16
|
+
stale?: boolean
|
|
17
|
+
url: string
|
|
18
|
+
branch: string // local branch name for checkouts; see localBranch() in github.ts
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface Bucket {
|
|
22
|
+
name: string
|
|
23
|
+
total?: number // server-side count when only a page of PRs is loaded
|
|
24
|
+
prs: PR[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Sample data for --demo and tests; the UI only depends on these shapes.
|
|
28
|
+
export function demoBuckets(repo: Repo): Bucket[] {
|
|
29
|
+
// 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}` })
|
|
32
|
+
|
|
33
|
+
return [
|
|
34
|
+
{ name: "Needs your review", prs: [
|
|
35
|
+
pr({ number: 21325, title: "fix: only find upstack PRs for MQ after self", author: "aryamannaik", state: "review", ci: "fail", review: "pass", additions: 84, deletions: 3, updated: "7m" }),
|
|
36
|
+
pr({ number: 21324, title: "refactor: add logic to fetch MQ entries after self into helper", author: "aryamannaik", state: "review", ci: "pending", review: "pass", additions: 104, deletions: 20, updated: "7m" }),
|
|
37
|
+
pr({ number: 21100, title: "feat: set the location of the upstack blame modal when clicking on the upstack", author: "ZiyaoWei", state: "review", ci: "fail", review: "fail", additions: 186, deletions: 2, updated: "7m" }),
|
|
38
|
+
] },
|
|
39
|
+
{ name: "Returned to you", prs: [] },
|
|
40
|
+
{ name: "Approved", prs: [
|
|
41
|
+
pr({ number: 20967, title: "[Snyk] Security upgrade @withgraphite/retype from 0.0.0-use.local to 0.3.15", author: "gregoryfoster", state: "approved", ci: "fail", review: "pass", additions: 1, deletions: 1, updated: "18h" }),
|
|
42
|
+
pr({ number: 20673, title: "fix(auth): fix edge case in repo membership", author: "gregoryfoster", state: "approved", ci: "fail", review: "pass", additions: 31, deletions: 4, updated: "18h" }),
|
|
43
|
+
] },
|
|
44
|
+
{ name: "Waiting for reviewers", prs: [] },
|
|
45
|
+
{ name: "Drafts", prs: [
|
|
46
|
+
pr({ number: 19965, title: "experiment(pr_perf): explore using cached PRs on pr page", author: "gregoryfoster", state: "draft", ci: "running", review: "fail", additions: 54, deletions: 23, updated: "21d", stale: true }),
|
|
47
|
+
] },
|
|
48
|
+
{ name: "Open", total: 42, prs: [
|
|
49
|
+
pr({ number: 21330, title: "chore(deps): bump @opentelemetry/api from 1.9.0 to 1.9.1", author: "dependabot", state: "review", ci: "pass", review: "pending", additions: 3, deletions: 3, updated: "2m" }),
|
|
50
|
+
pr({ number: 21318, title: "feat(mq): show estimated merge time in queue sidebar", author: "ZiyaoWei", state: "approved", ci: "running", review: "pass", additions: 212, deletions: 41, updated: "1h" }),
|
|
51
|
+
pr({ number: 21102, title: "docs: document stack submit flags", author: "aryamannaik", state: "author", ci: "pass", review: "fail", additions: 58, deletions: 12, updated: "16d", stale: true }),
|
|
52
|
+
] },
|
|
53
|
+
{ name: "Recently merged", total: 318, prs: [
|
|
54
|
+
pr({ number: 21305, title: "fix: throw when we hit a merge conflict when rebasing stack for draft PRs", author: "aryamannaik", state: "merged", ci: "pass", review: "merged", additions: 10, deletions: 0, updated: "3m" }),
|
|
55
|
+
] },
|
|
56
|
+
]
|
|
57
|
+
}
|