@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/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
+ })
@@ -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
+ }