@oscbacon/tungsten 0.0.1 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -11
- package/package.json +27 -1
- 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/bun.lock +0 -85
- package/src/app.test.tsx +0 -287
- package/src/checkout.test.ts +0 -145
- package/src/github.test.ts +0 -121
- package/src/repo.test.ts +0 -91
- package/src/run.test.ts +0 -28
package/README.md
CHANGED
|
@@ -15,17 +15,30 @@ Lists a repo's PRs by bucket, and allow you to open the PR in your browser
|
|
|
15
15
|
|
|
16
16
|
## Install
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
```bash
|
|
19
|
+
npm i -g @oscbacon/tungsten
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or from the source:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
git clone git@github.com:OscBacon/tungsten.git
|
|
26
|
+
cd tungsten
|
|
27
|
+
bun install
|
|
28
|
+
bun link # links `tungsten` into ~/.bun/bin; make sure that's on your PATH
|
|
29
|
+
```
|
|
30
|
+
|
|
22
31
|
|
|
23
32
|
## Usage
|
|
24
33
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
```bash
|
|
35
|
+
tungsten # repo from the current clone's git remotes, like gh
|
|
36
|
+
tungsten -R cli/cli # or pick one: [HOST/]OWNER/REPO
|
|
37
|
+
tungsten -R cli/cli,cli/go-gh # or several, listed together (repeating -R works too)
|
|
38
|
+
tungsten --demo # sample data, no clone or network
|
|
39
|
+
tungsten --demo-multiple # sample data from several repos
|
|
40
|
+
tungsten --show-git-prompt # let c and w ask for ssh passphrases or credentials
|
|
41
|
+
```
|
|
29
42
|
|
|
30
43
|
## Buckets
|
|
31
44
|
|
|
@@ -37,7 +50,8 @@ Lists a repo's PRs by bucket, and allow you to open the PR in your browser
|
|
|
37
50
|
- Open
|
|
38
51
|
- Recently merged
|
|
39
52
|
|
|
40
|
-
Each shows up to 25 PRs and the total count.
|
|
53
|
+
Each shows up to 25 PRs and the total count. With several repos, each bucket holds all of
|
|
54
|
+
their PRs, newest first, and each row names its repo next to the author.
|
|
41
55
|
|
|
42
56
|
## Keys
|
|
43
57
|
|
|
@@ -59,11 +73,12 @@ PRs from before it without refetching them (`r` refreshes them).
|
|
|
59
73
|
|
|
60
74
|
fix token words in the title
|
|
61
75
|
@login PRs by that author (several @logins: any of them)
|
|
62
|
-
#123 that PR, looked up directly
|
|
76
|
+
#123 that PR, looked up directly (in every repo shown)
|
|
77
|
+
go-gh#123 that PR in one repo; cli/go-gh#123 works too
|
|
63
78
|
|
|
64
79
|
## Checking out
|
|
65
80
|
|
|
66
|
-
Both keys work from a clone of the repo
|
|
81
|
+
Both keys work from a clone of the PR's repo, and hand the PR to `gh pr checkout`,
|
|
67
82
|
so forks and remotes behave as they do there.
|
|
68
83
|
|
|
69
84
|
- `c` checks the PR out in the current clone.
|
package/package.json
CHANGED
|
@@ -1,10 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oscbacon/tungsten",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "A terminal inbox for a GitHub repo's pull requests",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"github",
|
|
8
|
+
"pull-requests",
|
|
9
|
+
"tui",
|
|
10
|
+
"terminal",
|
|
11
|
+
"cli"
|
|
12
|
+
],
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"author": "Oscar Baracos",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/OscBacon/tungsten.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/OscBacon/tungsten#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/OscBacon/tungsten/issues"
|
|
22
|
+
},
|
|
5
23
|
"bin": {
|
|
6
24
|
"tungsten": "src/index.tsx"
|
|
7
25
|
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src",
|
|
28
|
+
"!src/**/*.test.*",
|
|
29
|
+
"tsconfig.json"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"bun": ">=1.0.0"
|
|
33
|
+
},
|
|
8
34
|
"devDependencies": {
|
|
9
35
|
"@types/bun": "^1.4.2",
|
|
10
36
|
"@types/react": "^19.3.0"
|
package/src/app.tsx
CHANGED
|
@@ -28,7 +28,9 @@ const bucketId = (i: number) => `bucket-${i}`
|
|
|
28
28
|
const bucketBoxId = (i: number) => `bucket-box-${i}`
|
|
29
29
|
// Scoped to the bucket: one PR can sit in two buckets (say review-requested and open),
|
|
30
30
|
// and each copy needs its own id to be selectable and scrollable on its own.
|
|
31
|
-
|
|
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 {
|
package/bun.lock
DELETED
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"lockfileVersion": 1,
|
|
3
|
-
"configVersion": 1,
|
|
4
|
-
"workspaces": {
|
|
5
|
-
"": {
|
|
6
|
-
"name": "tungsten",
|
|
7
|
-
"dependencies": {
|
|
8
|
-
"@opentui/core": "^0.5.11",
|
|
9
|
-
"@opentui/react": "^0.5.11",
|
|
10
|
-
"react": "^19.3.0",
|
|
11
|
-
},
|
|
12
|
-
"devDependencies": {
|
|
13
|
-
"@types/bun": "^1.4.2",
|
|
14
|
-
"@types/react": "^19.3.0",
|
|
15
|
-
},
|
|
16
|
-
},
|
|
17
|
-
},
|
|
18
|
-
"packages": {
|
|
19
|
-
"@opentui/core": ["@opentui/core@0.5.11", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.11", "@opentui/core-darwin-x64": "0.5.11", "@opentui/core-linux-arm64": "0.5.11", "@opentui/core-linux-arm64-musl": "0.5.11", "@opentui/core-linux-x64": "0.5.11", "@opentui/core-linux-x64-musl": "0.5.11", "@opentui/core-win32-arm64": "0.5.11", "@opentui/core-win32-x64": "0.5.11" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-pImMfjCNx7JUp9Df1LRZBDLisWqgzOLXZKXO+hh3jA9ujBFPQnZOqbG+/N5uVAAX8IZGaqhYSQRwSZIFBmcfbQ=="],
|
|
20
|
-
|
|
21
|
-
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DRXY5ioq+n1ZNAMAcaFaBunr0cmi2gqucjbTW7lgFp8t9uN3fNZnLTDOqkCTQtT2XhrU4GXqySaPSQ4qFG/EAQ=="],
|
|
22
|
-
|
|
23
|
-
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-yP/8GliJDiJNm8YYJKvgWuy6xyCEd8d4GwBVOIzCFOI7ZIbGP8GOTvmwIjRW4Paw20pTttWMWyRoQiCvOXvH2g=="],
|
|
24
|
-
|
|
25
|
-
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-zBIsRFHlLUYFNhapRSNt9dz4mC8gZ4Wxcfy3A+2AwqsgCipcr2FkIuAXYqN08q+IvqFX7DfqgIFGWDNedHTPUg=="],
|
|
26
|
-
|
|
27
|
-
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-x+xeR2LYibvIi/qQetRjJR008sFRve60QuDcO8ItxUwzFeKTDzl5CEiZpBXfm5I4FhRNuyuj0TSPIFadMvrjFQ=="],
|
|
28
|
-
|
|
29
|
-
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-pSOXqOADrv+zINOgR3FDFA9zVRaim3zl8/yhtO+X9rEJ6f34z3gDund0Gf88hNJSMpZK5xWtipEVm28RY5VF8w=="],
|
|
30
|
-
|
|
31
|
-
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.11", "", { "os": "linux", "cpu": "x64" }, "sha512-MyqOnSs8pTYG2xmFr1xt6xZIuHu2Xu4pkle9my9JdE+WClmusHf0YN9Eas6jQLAq5XUi34r22wMeK0juk93zyw=="],
|
|
32
|
-
|
|
33
|
-
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-MGGRXIDJ//HyaqC5ndSr7/CUl+ICdYEAMcjbA00UWsthh4ZO/rYhPkyqOaTn5ck6+G4ca3/+M76J+jImMYTjNg=="],
|
|
34
|
-
|
|
35
|
-
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.11", "", { "os": "win32", "cpu": "x64" }, "sha512-sMEGX9rhiPd1gBa190jzj5uIdzKCMImxHmr22hJLxIRWqejxWvqGnRBkW68wT8NfMViDiMtZWavgWL7GgmYCAQ=="],
|
|
36
|
-
|
|
37
|
-
"@opentui/react": ["@opentui/react@0.5.11", "", { "dependencies": { "@opentui/core": "0.5.11", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-ACH6Kpl3/O1zdNL0sZtzrnxfw0+auJu4eefIbPTAPuNEessyuhd2xvV4agrdWRJCeogywS7er5k7uS/8YRWnZg=="],
|
|
38
|
-
|
|
39
|
-
"@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
|
|
40
|
-
|
|
41
|
-
"@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="],
|
|
42
|
-
|
|
43
|
-
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
|
|
44
|
-
|
|
45
|
-
"ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
|
|
46
|
-
|
|
47
|
-
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
|
|
48
|
-
|
|
49
|
-
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
|
|
50
|
-
|
|
51
|
-
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
|
52
|
-
|
|
53
|
-
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
|
54
|
-
|
|
55
|
-
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
|
56
|
-
|
|
57
|
-
"get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
|
|
58
|
-
|
|
59
|
-
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
|
60
|
-
|
|
61
|
-
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
|
|
62
|
-
|
|
63
|
-
"react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
|
|
64
|
-
|
|
65
|
-
"react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
|
|
66
|
-
|
|
67
|
-
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
|
68
|
-
|
|
69
|
-
"shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="],
|
|
70
|
-
|
|
71
|
-
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
|
72
|
-
|
|
73
|
-
"strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
|
|
74
|
-
|
|
75
|
-
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
76
|
-
|
|
77
|
-
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
|
78
|
-
|
|
79
|
-
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
|
|
80
|
-
|
|
81
|
-
"ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
|
82
|
-
|
|
83
|
-
"react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
|
|
84
|
-
}
|
|
85
|
-
}
|
package/src/app.test.tsx
DELETED
|
@@ -1,287 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test"
|
|
2
|
-
import { testRender } from "@opentui/react/test-utils"
|
|
3
|
-
import { act } from "react"
|
|
4
|
-
import { App, type Actions } from "./app"
|
|
5
|
-
import { demoBuckets, type Bucket } from "./data"
|
|
6
|
-
import { parseRepo } from "./repo"
|
|
7
|
-
|
|
8
|
-
const press = async (s: Awaited<ReturnType<typeof testRender>>, fn: () => unknown = () => {}) => {
|
|
9
|
-
await act(async () => { await fn(); await new Promise(r => setTimeout(r, 10)) })
|
|
10
|
-
await s.renderOnce()
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// A lone ESC is only reported once the input parser gives up waiting for a longer sequence.
|
|
14
|
-
const escape = async (s: Awaited<ReturnType<typeof testRender>>) => {
|
|
15
|
-
await press(s, () => s.mockInput.pressEscape())
|
|
16
|
-
await press(s)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const repo = parseRepo("withgraphite/monologue")
|
|
20
|
-
const noActions: Actions = { checkout: async () => "", worktree: async () => "" }
|
|
21
|
-
|
|
22
|
-
test("arrows move, t collapses the current bucket, cursor lands on its header", async () => {
|
|
23
|
-
const s = await testRender(<App repo={repo} load={async () => demoBuckets(repo)} actions={noActions} />, { width: 110, height: 20 })
|
|
24
|
-
try {
|
|
25
|
-
await press(s) // let the load resolve
|
|
26
|
-
await press(s, () => s.mockInput.pressArrow("down"))
|
|
27
|
-
await press(s, () => s.mockInput.pressArrow("down"))
|
|
28
|
-
expect(s.captureCharFrame()).toMatch(/▌ • ◷ refactor/)
|
|
29
|
-
await press(s, () => s.mockInput.pressKey("t"))
|
|
30
|
-
let frame = s.captureCharFrame()
|
|
31
|
-
expect(frame).toContain("▸ Needs your review")
|
|
32
|
-
expect(frame).not.toContain("refactor")
|
|
33
|
-
await press(s, () => s.mockInput.pressArrow("down")) // header -> "Returned to you"
|
|
34
|
-
await press(s, () => s.mockInput.pressArrow("down")) // -> "Approved" header
|
|
35
|
-
await press(s, () => s.mockInput.pressArrow("down")) // -> first approved PR
|
|
36
|
-
expect(s.captureCharFrame()).toMatch(/▌ • ✓ \[Snyk\]/)
|
|
37
|
-
// scroll: walk to the bottom of a short viewport
|
|
38
|
-
for (let i = 0; i < 12; i++) await press(s, () => s.mockInput.pressArrow("down"))
|
|
39
|
-
frame = s.captureCharFrame()
|
|
40
|
-
console.log(frame)
|
|
41
|
-
expect(frame).toMatch(/▌ • ◆ fix: throw/)
|
|
42
|
-
expect(frame.trimEnd().split("\n").at(-2)).toMatch(/^╰/) // last bucket fully visible
|
|
43
|
-
} finally { s.renderer.destroy() }
|
|
44
|
-
})
|
|
45
|
-
|
|
46
|
-
test("r refreshes: loading state, cursor follows its PR, a failed refresh keeps the last data", async () => {
|
|
47
|
-
const withoutFirstPr = (): Bucket[] => {
|
|
48
|
-
const b = demoBuckets(repo)
|
|
49
|
-
b[0]!.prs.shift()
|
|
50
|
-
return b
|
|
51
|
-
}
|
|
52
|
-
let release!: (b: Bucket[]) => void
|
|
53
|
-
const loads: (() => Promise<Bucket[]>)[] = [
|
|
54
|
-
() => new Promise(r => (release = r)),
|
|
55
|
-
async () => withoutFirstPr(),
|
|
56
|
-
async () => { throw new Error("HTTP 401: Bad credentials") },
|
|
57
|
-
]
|
|
58
|
-
let calls = 0
|
|
59
|
-
const load = () => loads[calls++]!()
|
|
60
|
-
|
|
61
|
-
const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 20 })
|
|
62
|
-
try {
|
|
63
|
-
await press(s)
|
|
64
|
-
expect(s.captureCharFrame()).toContain("Loading pull requests…")
|
|
65
|
-
await press(s, () => release(demoBuckets(repo)))
|
|
66
|
-
await press(s, () => s.mockInput.pressArrow("down"))
|
|
67
|
-
await press(s, () => s.mockInput.pressArrow("down"))
|
|
68
|
-
expect(s.captureCharFrame()).toMatch(/▌ • ◷ refactor/)
|
|
69
|
-
|
|
70
|
-
await press(s, () => s.mockInput.pressKey("r"))
|
|
71
|
-
let frame = s.captureCharFrame()
|
|
72
|
-
expect(frame).not.toContain("only find upstack")
|
|
73
|
-
expect(frame).toMatch(/▌ • ◷ refactor/) // same PR, now the first row
|
|
74
|
-
|
|
75
|
-
await press(s, () => s.mockInput.pressKey("r"))
|
|
76
|
-
frame = s.captureCharFrame()
|
|
77
|
-
expect(frame).toContain("✗ HTTP 401: Bad credentials")
|
|
78
|
-
expect(frame).toMatch(/▌ • ◷ refactor/)
|
|
79
|
-
expect(calls).toBe(3)
|
|
80
|
-
} finally { s.renderer.destroy() }
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
test("a PR listed in two buckets selects and moves from the copy the cursor is on", async () => {
|
|
84
|
-
const dup = demoBuckets(repo)[0]!.prs[0]!
|
|
85
|
-
const buckets: Bucket[] = [
|
|
86
|
-
{ name: "Needs your review", prs: [dup] },
|
|
87
|
-
{ name: "Open", prs: [dup, { ...dup, number: 999, title: "second open pull request" }] },
|
|
88
|
-
]
|
|
89
|
-
const s = await testRender(<App repo={repo} load={async () => buckets} actions={noActions} />, { width: 110, height: 20 })
|
|
90
|
-
try {
|
|
91
|
-
await press(s)
|
|
92
|
-
// header -> the copy in "Needs your review" -> "Open" header -> the copy in "Open"
|
|
93
|
-
for (let i = 0; i < 3; i++) await press(s, () => s.mockInput.pressArrow("down"))
|
|
94
|
-
const lines = s.captureCharFrame().split("\n")
|
|
95
|
-
const openHeader = lines.findIndex(l => l.includes("▾ Open"))
|
|
96
|
-
expect(lines[openHeader + 1]).toContain("▌") // the copy under Open, not the one above
|
|
97
|
-
expect(lines.slice(0, openHeader).join("\n")).not.toContain("▌")
|
|
98
|
-
|
|
99
|
-
await press(s, () => s.mockInput.pressArrow("down"))
|
|
100
|
-
expect(s.captureCharFrame()).toMatch(/▌ • ◷ second open pull request/)
|
|
101
|
-
} finally { s.renderer.destroy() }
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
test("footer lists / c w between enter and r", async () => {
|
|
105
|
-
const s = await testRender(<App repo={repo} load={async () => demoBuckets(repo)} actions={noActions} />, { width: 110, height: 20 })
|
|
106
|
-
try {
|
|
107
|
-
await press(s)
|
|
108
|
-
expect(s.captureCharFrame().trimEnd().split("\n").at(-1)).toMatch(/enter open PR {3}\/ search {3}c checkout {3}w worktree {3}r refresh {3}q quit/)
|
|
109
|
-
} finally { s.renderer.destroy() }
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
test("/ types a search that only loads on enter; esc clears it", async () => {
|
|
113
|
-
const searches: (string | undefined)[] = []
|
|
114
|
-
const load = async (search?: string) => {
|
|
115
|
-
searches.push(search)
|
|
116
|
-
return search ? [{ name: "Open", prs: demoBuckets(repo)[0]!.prs.slice(1, 2) }] : demoBuckets(repo)
|
|
117
|
-
}
|
|
118
|
-
const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 20 })
|
|
119
|
-
try {
|
|
120
|
-
await press(s)
|
|
121
|
-
await press(s, () => s.mockInput.pressKey("/"))
|
|
122
|
-
await press(s, () => s.mockInput.typeText("refactor rtq"))
|
|
123
|
-
await press(s, () => s.mockInput.pressBackspace())
|
|
124
|
-
let frame = s.captureCharFrame()
|
|
125
|
-
expect(frame).toContain("/ refactor rt█")
|
|
126
|
-
expect(frame).toContain("only find upstack") // nothing filtered while typing
|
|
127
|
-
expect(searches).toEqual([undefined]) // and r, t, q were text, not keys
|
|
128
|
-
|
|
129
|
-
await press(s, () => s.mockInput.pressEnter())
|
|
130
|
-
frame = s.captureCharFrame()
|
|
131
|
-
expect(searches).toEqual([undefined, "refactor rt"])
|
|
132
|
-
expect(frame).toMatch(/Inbox {2}\/ refactor rt/)
|
|
133
|
-
expect(frame).not.toContain("only find upstack")
|
|
134
|
-
expect(frame).toContain("refactor: add logic")
|
|
135
|
-
// The footer is back, with a way out in place of "/ search".
|
|
136
|
-
expect(frame.trimEnd().split("\n").at(-1)).toMatch(/^ esc clear search {3}↑↓ move .* enter open PR {3}c checkout .* q quit/)
|
|
137
|
-
|
|
138
|
-
await press(s, () => s.mockInput.pressKey("r")) // refresh keeps the search
|
|
139
|
-
expect(searches.at(-1)).toBe("refactor rt")
|
|
140
|
-
|
|
141
|
-
await press(s, () => s.mockInput.pressEscape())
|
|
142
|
-
await press(s)
|
|
143
|
-
expect(searches).toEqual([undefined, "refactor rt", "refactor rt"]) // cleared without a fetch
|
|
144
|
-
frame = s.captureCharFrame()
|
|
145
|
-
expect(frame).toContain("only find upstack")
|
|
146
|
-
expect(frame.trimEnd().split("\n").at(-1)).toMatch(/^ ↑↓ move .* enter open PR {3}\/ search {3}c checkout/)
|
|
147
|
-
|
|
148
|
-
// esc in the prompt also clears an applied search
|
|
149
|
-
await press(s, () => s.mockInput.pressKey("/"))
|
|
150
|
-
await press(s, () => s.mockInput.typeText("@someone"))
|
|
151
|
-
await press(s, () => s.mockInput.pressEnter())
|
|
152
|
-
expect(searches.at(-1)).toBe("@someone")
|
|
153
|
-
await press(s, () => s.mockInput.pressKey("/"))
|
|
154
|
-
expect(s.captureCharFrame()).toContain("/ @someone█") // reopens with the current search
|
|
155
|
-
await press(s, () => s.mockInput.pressEscape())
|
|
156
|
-
await press(s)
|
|
157
|
-
expect(searches.at(-1)).toBe("@someone")
|
|
158
|
-
expect(s.captureCharFrame()).toContain("only find upstack")
|
|
159
|
-
} finally { s.renderer.destroy() }
|
|
160
|
-
})
|
|
161
|
-
|
|
162
|
-
// A load per call that the test resolves by hand, recording what each was for.
|
|
163
|
-
function manualLoads() {
|
|
164
|
-
const calls: { search?: string; resolve: (b: Bucket[]) => void }[] = []
|
|
165
|
-
const load = (search?: string) => new Promise<Bucket[]>(resolve => calls.push({ search, resolve }))
|
|
166
|
-
return { load, calls }
|
|
167
|
-
}
|
|
168
|
-
const titled = (title: string): Bucket[] => {
|
|
169
|
-
const b = demoBuckets(repo)
|
|
170
|
-
b[0]!.prs[0] = { ...b[0]!.prs[0]!, title }
|
|
171
|
-
return b
|
|
172
|
-
}
|
|
173
|
-
const results = (title: string): Bucket[] => [{ name: "Search results", prs: [{ ...demoBuckets(repo)[0]!.prs[1]!, title }] }]
|
|
174
|
-
|
|
175
|
-
test("clearing a search shows the last unsearched data at once, from the top; r refetches it", async () => {
|
|
176
|
-
const { load, calls } = manualLoads()
|
|
177
|
-
const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 20 })
|
|
178
|
-
const search = async (q: string) => {
|
|
179
|
-
await press(s, () => s.mockInput.pressKey("/"))
|
|
180
|
-
await press(s, () => s.mockInput.typeText(q))
|
|
181
|
-
await press(s, () => s.mockInput.pressEnter())
|
|
182
|
-
}
|
|
183
|
-
try {
|
|
184
|
-
await press(s, () => calls[0]!.resolve(titled("first unsearched load")))
|
|
185
|
-
await press(s, () => s.mockInput.pressArrow("down"))
|
|
186
|
-
await press(s, () => s.mockInput.pressArrow("down"))
|
|
187
|
-
expect(s.captureCharFrame()).toMatch(/▌ • ◷ refactor/)
|
|
188
|
-
|
|
189
|
-
await search("docs")
|
|
190
|
-
await press(s, () => calls[1]!.resolve(results("docs result")))
|
|
191
|
-
expect(s.captureCharFrame()).toContain("docs result")
|
|
192
|
-
|
|
193
|
-
await escape(s)
|
|
194
|
-
let frame = s.captureCharFrame()
|
|
195
|
-
expect(calls.length).toBe(2)
|
|
196
|
-
expect(frame).toContain("first unsearched load")
|
|
197
|
-
expect(frame).not.toContain("loading…")
|
|
198
|
-
expect(frame).not.toContain("▌") // back at the top, on the first header
|
|
199
|
-
|
|
200
|
-
// A search still loading when it's cleared can't land afterwards.
|
|
201
|
-
await search("slow")
|
|
202
|
-
await escape(s)
|
|
203
|
-
await press(s, () => calls[2]!.resolve(results("late search result")))
|
|
204
|
-
frame = s.captureCharFrame()
|
|
205
|
-
expect(frame).toContain("first unsearched load")
|
|
206
|
-
expect(frame).not.toContain("late search result")
|
|
207
|
-
|
|
208
|
-
await press(s, () => s.mockInput.pressKey("r"))
|
|
209
|
-
expect(calls.map(c => c.search)).toEqual([undefined, "docs", "slow", undefined])
|
|
210
|
-
await press(s, () => calls[3]!.resolve(titled("refreshed unsearched load")))
|
|
211
|
-
expect(s.captureCharFrame()).toContain("refreshed unsearched load")
|
|
212
|
-
} finally { s.renderer.destroy() }
|
|
213
|
-
})
|
|
214
|
-
|
|
215
|
-
test("an unsearched load still running when a search starts is dropped, so clearing fetches", async () => {
|
|
216
|
-
const { load, calls } = manualLoads()
|
|
217
|
-
const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 20 })
|
|
218
|
-
try {
|
|
219
|
-
await press(s)
|
|
220
|
-
await press(s, () => s.mockInput.pressKey("/"))
|
|
221
|
-
await press(s, () => s.mockInput.typeText("docs"))
|
|
222
|
-
await press(s, () => s.mockInput.pressEnter())
|
|
223
|
-
await press(s, () => calls[0]!.resolve(titled("dropped unsearched load")))
|
|
224
|
-
await press(s, () => calls[1]!.resolve(results("docs result")))
|
|
225
|
-
expect(s.captureCharFrame()).not.toContain("dropped unsearched load")
|
|
226
|
-
|
|
227
|
-
await escape(s)
|
|
228
|
-
expect(calls.map(c => c.search)).toEqual([undefined, "docs", undefined])
|
|
229
|
-
await press(s, () => calls[2]!.resolve(titled("fresh unsearched load")))
|
|
230
|
-
expect(s.captureCharFrame()).toContain("fresh unsearched load")
|
|
231
|
-
} finally { s.renderer.destroy() }
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
test("collapsed buckets are tracked by name across searches", async () => {
|
|
235
|
-
const load = async (search?: string) => (search ? results("docs result") : demoBuckets(repo))
|
|
236
|
-
const s = await testRender(<App repo={repo} load={load} actions={noActions} />, { width: 110, height: 30 })
|
|
237
|
-
try {
|
|
238
|
-
await press(s)
|
|
239
|
-
for (let i = 0; i < 5; i++) await press(s, () => s.mockInput.pressArrow("down")) // "Approved" header
|
|
240
|
-
await press(s, () => s.mockInput.pressKey("t"))
|
|
241
|
-
expect(s.captureCharFrame()).toContain("▸ Approved")
|
|
242
|
-
|
|
243
|
-
await press(s, () => s.mockInput.pressKey("/"))
|
|
244
|
-
await press(s, () => s.mockInput.typeText("docs"))
|
|
245
|
-
await press(s, () => s.mockInput.pressEnter())
|
|
246
|
-
await press(s, () => s.mockInput.pressKey("t")) // collapse "Search results", the first bucket
|
|
247
|
-
expect(s.captureCharFrame()).toContain("▸ Search results")
|
|
248
|
-
|
|
249
|
-
await escape(s)
|
|
250
|
-
const frame = s.captureCharFrame()
|
|
251
|
-
expect(frame).toContain("▾ Needs your review") // not collapsed by position
|
|
252
|
-
expect(frame).toContain("▸ Approved")
|
|
253
|
-
} finally { s.renderer.destroy() }
|
|
254
|
-
})
|
|
255
|
-
|
|
256
|
-
test("c and w act on the PR under the cursor and show the outcome", async () => {
|
|
257
|
-
const calls: string[] = []
|
|
258
|
-
let finish!: () => void
|
|
259
|
-
const actions: Actions = {
|
|
260
|
-
checkout: async pr => {
|
|
261
|
-
calls.push(`checkout #${pr.number}`)
|
|
262
|
-
await new Promise<void>(r => (finish = r))
|
|
263
|
-
return `Checked out PR #${pr.number}`
|
|
264
|
-
},
|
|
265
|
-
worktree: async pr => { calls.push(`worktree #${pr.number}`); throw new Error("w needs gh 2.98 or newer") },
|
|
266
|
-
}
|
|
267
|
-
const s = await testRender(<App repo={repo} load={async () => demoBuckets(repo)} actions={actions} />, { width: 110, height: 20 })
|
|
268
|
-
try {
|
|
269
|
-
await press(s)
|
|
270
|
-
await press(s, () => s.mockInput.pressKey("c")) // on a bucket header: nothing to check out
|
|
271
|
-
expect(calls).toEqual([])
|
|
272
|
-
|
|
273
|
-
await press(s, () => s.mockInput.pressArrow("down"))
|
|
274
|
-
await press(s, () => s.mockInput.pressKey("c"))
|
|
275
|
-
expect(s.captureCharFrame()).toContain("Checking out PR #21325…") // the app stays up meanwhile
|
|
276
|
-
await press(s, () => s.mockInput.pressKey("c")) // ignored while busy
|
|
277
|
-
expect(calls).toEqual(["checkout #21325"])
|
|
278
|
-
await press(s, () => finish())
|
|
279
|
-
expect(s.captureCharFrame()).toContain("✓ Checked out PR #21325")
|
|
280
|
-
|
|
281
|
-
await press(s, () => s.mockInput.pressArrow("down"))
|
|
282
|
-
await press(s, () => s.mockInput.pressKey("w"))
|
|
283
|
-
await press(s)
|
|
284
|
-
expect(calls).toEqual(["checkout #21325", "worktree #21324"])
|
|
285
|
-
expect(s.captureCharFrame()).toContain("✗ w needs gh 2.98 or newer")
|
|
286
|
-
} finally { s.renderer.destroy() }
|
|
287
|
-
})
|
package/src/checkout.test.ts
DELETED
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test"
|
|
2
|
-
import { checkoutPr, findWorktree, needsPrompt, openWorktree, worktreeDir } from "./checkout"
|
|
3
|
-
import { demoBuckets, type PR } from "./data"
|
|
4
|
-
import { parseRepo } from "./repo"
|
|
5
|
-
import type { Run, RunResult } from "./run"
|
|
6
|
-
|
|
7
|
-
const repo = parseRepo("cli/cli")
|
|
8
|
-
const home = "/home/me"
|
|
9
|
-
const pr: PR = { ...demoBuckets(repo)[0]!.prs[0]!, number: 42, branch: "babakks/fix-tokens" }
|
|
10
|
-
|
|
11
|
-
// A fake `run` that records every command and answers from a table keyed by "cmd arg1 arg2...".
|
|
12
|
-
// Nothing is ever executed, so no branch is checked out and no worktree is created.
|
|
13
|
-
function fakeRun(answers: Record<string, Partial<RunResult>> = {}) {
|
|
14
|
-
const calls: { cmd: string; interactive: boolean }[] = []
|
|
15
|
-
const run: Run = async (cmd, args, opts) => {
|
|
16
|
-
const line = [cmd, ...args].join(" ")
|
|
17
|
-
calls.push({ cmd: line, interactive: !!opts?.interactive })
|
|
18
|
-
return { code: 0, stdout: "", stderr: "", ...answers[line] }
|
|
19
|
-
}
|
|
20
|
-
return { run, calls, cmds: () => calls.map(c => c.cmd) }
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const porcelain = `worktree /home/me/src/cli
|
|
24
|
-
HEAD 1111111111111111111111111111111111111111
|
|
25
|
-
branch refs/heads/trunk
|
|
26
|
-
|
|
27
|
-
worktree /home/me/worktrees/cli/babakks-fix-tokens
|
|
28
|
-
HEAD 2222222222222222222222222222222222222222
|
|
29
|
-
branch refs/heads/babakks/fix-tokens
|
|
30
|
-
|
|
31
|
-
worktree /home/me/worktrees/cli/detached
|
|
32
|
-
HEAD 3333333333333333333333333333333333333333
|
|
33
|
-
detached
|
|
34
|
-
`
|
|
35
|
-
|
|
36
|
-
const NEW_GH = { stdout: "Flags:\n -b, --branch string\n --worktree path Check out the pull request into a worktree\n" }
|
|
37
|
-
const OLD_GH = { stdout: "Flags:\n -b, --branch string\n -f, --force\n" }
|
|
38
|
-
const CHECKOUT = "gh pr checkout 42 -R github.com/cli/cli --worktree=/home/me/worktrees/cli/babakks-fix-tokens --branch=babakks/fix-tokens"
|
|
39
|
-
|
|
40
|
-
test("worktreeDir flattens the branch like `wa`, under the repo's name", () => {
|
|
41
|
-
expect(worktreeDir(repo, "babakks/fix/tokens", home)).toBe("/home/me/worktrees/cli/babakks-fix-tokens")
|
|
42
|
-
expect(worktreeDir(repo, "main", home)).toBe("/home/me/worktrees/cli/main")
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
test("findWorktree matches the exact branch", () => {
|
|
46
|
-
expect(findWorktree(porcelain, "trunk")).toBe("/home/me/src/cli")
|
|
47
|
-
expect(findWorktree(porcelain, "babakks/fix-tokens")).toBe("/home/me/worktrees/cli/babakks-fix-tokens")
|
|
48
|
-
expect(findWorktree(porcelain, "babakks/fix")).toBeUndefined()
|
|
49
|
-
expect(findWorktree("", "trunk")).toBeUndefined()
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
test("c runs gh pr checkout against the repo on screen, with the terminal", async () => {
|
|
53
|
-
const f = fakeRun()
|
|
54
|
-
expect(await checkoutPr(pr, repo, f.run)).toBe("Checked out PR #42")
|
|
55
|
-
expect(f.calls).toEqual([{ cmd: "gh pr checkout 42 -R github.com/cli/cli", interactive: true }])
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
test("c reports git's error line, not its fetch progress", async () => {
|
|
59
|
-
const stderr = [
|
|
60
|
-
"From github.com:cli/cli",
|
|
61
|
-
" * [new ref] refs/pull/42/head -> babakks/fix-tokens",
|
|
62
|
-
"error: Your local changes to the following files would be overwritten by checkout:",
|
|
63
|
-
"\tREADME.md",
|
|
64
|
-
"Aborting",
|
|
65
|
-
"failed to run git: exit status 1",
|
|
66
|
-
].join("\n")
|
|
67
|
-
const f = fakeRun({ "gh pr checkout 42 -R github.com/cli/cli": { code: 1, stderr } })
|
|
68
|
-
await expect(checkoutPr(pr, repo, f.run)).rejects.toThrow(/^error: Your local changes/)
|
|
69
|
-
|
|
70
|
-
const bare = fakeRun({ "gh pr checkout 42 -R github.com/cli/cli": { code: 1 } })
|
|
71
|
-
await expect(checkoutPr(pr, repo, bare.run)).rejects.toThrow("gh pr checkout exited with code 1")
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
test("c reports the prompt it couldn't show, so the error can point at --show-git-prompt", async () => {
|
|
75
|
-
const stderr = [
|
|
76
|
-
"git@github.com: Permission denied (publickey).",
|
|
77
|
-
"fatal: Could not read from remote repository.",
|
|
78
|
-
"",
|
|
79
|
-
"Please make sure you have the correct access rights",
|
|
80
|
-
"failed to run git: exit status 128",
|
|
81
|
-
].join("\n")
|
|
82
|
-
const f = fakeRun({ "gh pr checkout 42 -R github.com/cli/cli": { code: 1, stderr } })
|
|
83
|
-
await expect(checkoutPr(pr, repo, f.run)).rejects.toThrow("git@github.com: Permission denied (publickey).")
|
|
84
|
-
|
|
85
|
-
for (const msg of ["Host key verification failed.", "fatal: could not read Username for 'https://github.com': terminal prompts disabled"]) {
|
|
86
|
-
expect(needsPrompt(msg)).toBe(true)
|
|
87
|
-
}
|
|
88
|
-
expect(needsPrompt("fatal: not a git repository")).toBe(false)
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
test("c without gh installed says so", async () => {
|
|
92
|
-
const run: Run = async () => { throw Object.assign(new Error("spawn gh ENOENT"), { code: "ENOENT" }) }
|
|
93
|
-
await expect(checkoutPr(pr, repo, run)).rejects.toThrow("gh is not installed")
|
|
94
|
-
})
|
|
95
|
-
|
|
96
|
-
test("w creates the worktree with gh and reports where, outside tmux", async () => {
|
|
97
|
-
const f = fakeRun({ "git worktree list --porcelain": { stdout: porcelain.split("\n\n")[0] }, "gh pr checkout --help": NEW_GH })
|
|
98
|
-
expect(await openWorktree(pr, repo, { run: f.run, tmux: false, home })).toBe("Worktree at ~/worktrees/cli/babakks-fix-tokens")
|
|
99
|
-
expect(f.calls).toEqual([
|
|
100
|
-
{ cmd: "git worktree list --porcelain", interactive: false },
|
|
101
|
-
{ cmd: "gh pr checkout --help", interactive: false },
|
|
102
|
-
{ cmd: CHECKOUT, interactive: true },
|
|
103
|
-
])
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
test("w inside tmux opens a window in the new worktree", async () => {
|
|
107
|
-
const f = fakeRun({ "gh pr checkout --help": NEW_GH })
|
|
108
|
-
expect(await openWorktree(pr, repo, { run: f.run, tmux: true, home })).toBe("Opened ~/worktrees/cli/babakks-fix-tokens in a new tmux window")
|
|
109
|
-
expect(f.cmds().slice(-2)).toEqual([CHECKOUT, "tmux new-window -c /home/me/worktrees/cli/babakks-fix-tokens"])
|
|
110
|
-
})
|
|
111
|
-
|
|
112
|
-
test("w reuses a worktree that already has the branch, wherever it is", async () => {
|
|
113
|
-
const f = fakeRun({ "git worktree list --porcelain": { stdout: porcelain } })
|
|
114
|
-
expect(await openWorktree(pr, repo, { run: f.run, tmux: true, home })).toBe("Opened ~/worktrees/cli/babakks-fix-tokens in a new tmux window")
|
|
115
|
-
expect(f.cmds()).toEqual(["git worktree list --porcelain", "tmux new-window -c /home/me/worktrees/cli/babakks-fix-tokens"])
|
|
116
|
-
|
|
117
|
-
// The branch checked out in the main clone, outside ~/worktrees.
|
|
118
|
-
const main = fakeRun({ "git worktree list --porcelain": { stdout: porcelain } })
|
|
119
|
-
expect(await openWorktree({ ...pr, branch: "trunk" }, repo, { run: main.run, tmux: false, home })).toBe("Worktree already at ~/src/cli")
|
|
120
|
-
expect(main.cmds()).toEqual(["git worktree list --porcelain"])
|
|
121
|
-
})
|
|
122
|
-
|
|
123
|
-
test("w passes gh the branch name from the PR (a fork's may carry an owner prefix)", async () => {
|
|
124
|
-
const f = fakeRun({ "gh pr checkout --help": NEW_GH })
|
|
125
|
-
await openWorktree({ ...pr, branch: "octo/trunk" }, repo, { run: f.run, tmux: false, home })
|
|
126
|
-
expect(f.cmds().at(-1)).toBe("gh pr checkout 42 -R github.com/cli/cli --worktree=/home/me/worktrees/cli/octo-trunk --branch=octo/trunk")
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
test("w needs gh 2.98+, and runs nothing when gh is older", async () => {
|
|
130
|
-
const f = fakeRun({ "gh pr checkout --help": OLD_GH })
|
|
131
|
-
await expect(openWorktree(pr, repo, { run: f.run, tmux: true, home })).rejects.toThrow("w needs gh 2.98 or newer")
|
|
132
|
-
expect(f.cmds()).toEqual(["git worktree list --porcelain", "gh pr checkout --help"])
|
|
133
|
-
})
|
|
134
|
-
|
|
135
|
-
test("w surfaces gh, git and tmux failures", async () => {
|
|
136
|
-
const gitFails = fakeRun({ "git worktree list --porcelain": { code: 128, stderr: "fatal: not a git repository (or any of the parent directories): .git" } })
|
|
137
|
-
await expect(openWorktree(pr, repo, { run: gitFails.run, tmux: false, home })).rejects.toThrow("fatal: not a git repository")
|
|
138
|
-
|
|
139
|
-
const ghFails = fakeRun({ "gh pr checkout --help": NEW_GH, [CHECKOUT]: { code: 1, stderr: "--worktree path must be a directory: /home/me/worktrees/cli/babakks-fix-tokens\n" } })
|
|
140
|
-
await expect(openWorktree(pr, repo, { run: ghFails.run, tmux: true, home })).rejects.toThrow("--worktree path must be a directory")
|
|
141
|
-
expect(ghFails.cmds()).not.toContain("tmux new-window -c /home/me/worktrees/cli/babakks-fix-tokens")
|
|
142
|
-
|
|
143
|
-
const tmuxFails = fakeRun({ "git worktree list --porcelain": { stdout: porcelain }, "tmux new-window -c /home/me/worktrees/cli/babakks-fix-tokens": { code: 1, stderr: "no server running on /tmp/tmux-1000/default" } })
|
|
144
|
-
await expect(openWorktree(pr, repo, { run: tmuxFails.run, tmux: true, home })).rejects.toThrow("no server running")
|
|
145
|
-
})
|
package/src/github.test.ts
DELETED
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test"
|
|
2
|
-
import { BUCKETS, buildNumbersQuery, buildQuery, localBranch, parseSearch, relativeTime, searchQuery, toBuckets, toNumbersBucket } from "./github"
|
|
3
|
-
import { parseRepo } from "./repo"
|
|
4
|
-
|
|
5
|
-
const now = new Date("2026-09-15T15:00:00Z")
|
|
6
|
-
|
|
7
|
-
// Shaped like a real node from `gh api graphql` against cli/cli.
|
|
8
|
-
const node = (over: Record<string, unknown> = {}) => ({
|
|
9
|
-
number: 14456,
|
|
10
|
-
title: "Refreshable tokens (7/7): guided end-to-end verification scripts",
|
|
11
|
-
url: "https://github.com/cli/cli/pull/14456",
|
|
12
|
-
isDraft: false,
|
|
13
|
-
state: "OPEN",
|
|
14
|
-
additions: 1118,
|
|
15
|
-
deletions: 0,
|
|
16
|
-
updatedAt: "2026-09-15T01:49:02Z",
|
|
17
|
-
reviewDecision: "REVIEW_REQUIRED",
|
|
18
|
-
author: { login: "babakks" },
|
|
19
|
-
commits: { nodes: [{ commit: { statusCheckRollup: { state: "SUCCESS" } } }] },
|
|
20
|
-
headRefName: "babakks/refreshable-tokens-7",
|
|
21
|
-
isCrossRepository: false,
|
|
22
|
-
headRepositoryOwner: { login: "cli" },
|
|
23
|
-
baseRepository: { defaultBranchRef: { name: "trunk" } },
|
|
24
|
-
...over,
|
|
25
|
-
})
|
|
26
|
-
const rollup = (state: string | null) => ({ nodes: [{ commit: { statusCheckRollup: state && { state } } }] })
|
|
27
|
-
const search = (...nodes: object[]) => ({ issueCount: 318, nodes })
|
|
28
|
-
|
|
29
|
-
test("buildQuery searches every bucket in one request", () => {
|
|
30
|
-
const q = buildQuery()
|
|
31
|
-
expect(q).toContain("fragment pr on PullRequest")
|
|
32
|
-
BUCKETS.forEach((_, i) => expect(q).toContain(`b${i}: search(query: $q${i}, type: ISSUE, first: 25)`))
|
|
33
|
-
expect(searchQuery(parseRepo("cli/cli"), 0)).toBe("repo:cli/cli is:pr sort:updated-desc is:open user-review-requested:@me")
|
|
34
|
-
})
|
|
35
|
-
|
|
36
|
-
test("toBuckets maps search results onto PR rows", () => {
|
|
37
|
-
const buckets = toBuckets({
|
|
38
|
-
data: {
|
|
39
|
-
b0: search(node(), {}), // {} is a non-PR search hit
|
|
40
|
-
b1: search(node({ reviewDecision: "CHANGES_REQUESTED", author: null, commits: rollup("FAILURE") })),
|
|
41
|
-
b2: search(node({ reviewDecision: "APPROVED", commits: { nodes: [] } })),
|
|
42
|
-
b3: search(node({ reviewDecision: null, updatedAt: "2026-08-26T15:00:00Z", commits: rollup("PENDING") })),
|
|
43
|
-
b4: search(node({ isDraft: true, commits: rollup("EXPECTED") })),
|
|
44
|
-
b5: search(node({ author: { login: "someone-else" } })),
|
|
45
|
-
b6: search(node({ state: "MERGED", reviewDecision: "APPROVED", updatedAt: "2025-01-01T00:00:00Z" })),
|
|
46
|
-
},
|
|
47
|
-
}, now)
|
|
48
|
-
|
|
49
|
-
expect(buckets.map(b => b.name)).toEqual(BUCKETS.map(b => b.name))
|
|
50
|
-
expect(buckets[0]).toEqual({
|
|
51
|
-
name: "Needs your review",
|
|
52
|
-
total: 318,
|
|
53
|
-
prs: [{
|
|
54
|
-
number: 14456, title: "Refreshable tokens (7/7): guided end-to-end verification scripts", author: "babakks",
|
|
55
|
-
state: "review", ci: "pass", review: "pending", additions: 1118, deletions: 0, updated: "13h", stale: false,
|
|
56
|
-
url: "https://github.com/cli/cli/pull/14456", branch: "babakks/refreshable-tokens-7",
|
|
57
|
-
}],
|
|
58
|
-
})
|
|
59
|
-
const row = (i: number) => buckets[i]!.prs[0]!
|
|
60
|
-
expect(row(1)).toMatchObject({ state: "author", ci: "fail", review: "fail", author: "ghost" })
|
|
61
|
-
expect(row(2)).toMatchObject({ state: "approved", ci: "pending", review: "pass" })
|
|
62
|
-
expect(row(3)).toMatchObject({ state: "review", ci: "running", review: "pending", updated: "20d", stale: true })
|
|
63
|
-
expect(row(4)).toMatchObject({ state: "draft", ci: "pending" })
|
|
64
|
-
expect(buckets[5]!.name).toBe("Open")
|
|
65
|
-
expect(row(5)).toMatchObject({ state: "review", author: "someone-else" })
|
|
66
|
-
expect(row(6)).toMatchObject({ state: "merged", ci: "pass", review: "merged", updated: "1y", stale: false })
|
|
67
|
-
})
|
|
68
|
-
|
|
69
|
-
test("toBuckets: missing searches are empty, GraphQL errors throw", () => {
|
|
70
|
-
expect(toBuckets({ data: {} }, now).every(b => b.total === 0 && b.prs.length === 0)).toBe(true)
|
|
71
|
-
expect(() => toBuckets({ errors: [{ message: "Field 'bogus' doesn't exist on type 'Query'" }] }, now)).toThrow("Field 'bogus'")
|
|
72
|
-
})
|
|
73
|
-
|
|
74
|
-
test("relativeTime", () => {
|
|
75
|
-
const ago = (s: number) => relativeTime(new Date(now.getTime() - s * 1000).toISOString(), now)
|
|
76
|
-
expect([ago(5), ago(59 * 60), ago(3600), ago(23 * 3600), ago(86400), ago(400 * 86400), ago(-30)])
|
|
77
|
-
.toEqual(["now", "59m", "1h", "23h", "1d", "1y", "now"])
|
|
78
|
-
})
|
|
79
|
-
|
|
80
|
-
test("localBranch follows gh: a fork's branch named like the default branch gets the owner as a prefix", () => {
|
|
81
|
-
const pr = (over: object) => ({ headRefName: "fix", isCrossRepository: false, headRepositoryOwner: { login: "cli" }, baseRepository: { defaultBranchRef: { name: "trunk" } }, ...over })
|
|
82
|
-
expect(localBranch(pr({}))).toBe("fix")
|
|
83
|
-
expect(localBranch(pr({ isCrossRepository: true, headRepositoryOwner: { login: "octo" } }))).toBe("fix")
|
|
84
|
-
expect(localBranch(pr({ isCrossRepository: true, headRefName: "trunk", headRepositoryOwner: { login: "octo" } }))).toBe("octo/trunk")
|
|
85
|
-
expect(localBranch(pr({ isCrossRepository: true, headRefName: "trunk", headRepositoryOwner: null }))).toBe("ghost/trunk")
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
test("parseSearch splits words, @authors and #numbers; searchQuery adds them to every bucket", () => {
|
|
89
|
-
const s = parseSearch(" fix @babakks #123 token# ")
|
|
90
|
-
expect(s).toEqual({ words: ["fix", "token#"], authors: ["babakks"], numbers: [123] })
|
|
91
|
-
expect(parseSearch("")).toEqual({ words: [], authors: [], numbers: [] })
|
|
92
|
-
|
|
93
|
-
const repo = parseRepo("cli/cli")
|
|
94
|
-
expect(searchQuery(repo, 5, s)).toBe("repo:cli/cli is:pr sort:updated-desc is:open fix token# in:title author:babakks")
|
|
95
|
-
expect(searchQuery(repo, 5, parseSearch("@babakks"))).toBe("repo:cli/cli is:pr sort:updated-desc is:open author:babakks")
|
|
96
|
-
// GitHub would OR a second author: into author:@me, so your own buckets leave it out.
|
|
97
|
-
expect(searchQuery(repo, 6, s)).toBe("repo:cli/cli is:pr sort:updated-desc is:merged author:@me fix token# in:title")
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
test("an @author search empties your own buckets unless the author is you", () => {
|
|
101
|
-
const res = { data: { viewer: { login: "OscBacon" }, ...Object.fromEntries(BUCKETS.map((_, i) => [`b${i}`, search(node())])) } }
|
|
102
|
-
const totals = (q: string) => toBuckets(res, now, parseSearch(q)).map(b => b.total)
|
|
103
|
-
expect(totals("fix")).toEqual([318, 318, 318, 318, 318, 318, 318])
|
|
104
|
-
expect(totals("@babakks")).toEqual([318, 0, 0, 0, 0, 318, 0])
|
|
105
|
-
expect(totals("@babakks @oscbacon")).toEqual([318, 318, 318, 318, 318, 318, 318])
|
|
106
|
-
expect(buildQuery()).toContain("viewer { login }")
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
test("#numbers are looked up directly and narrowed by the other terms", () => {
|
|
110
|
-
expect(buildNumbersQuery([12, 34])).toContain("repository(owner: $owner, name: $name) { n12: pullRequest(number: 12) { ...pr } n34: pullRequest(number: 34) { ...pr } }")
|
|
111
|
-
|
|
112
|
-
const res = {
|
|
113
|
-
data: { repository: { n1: node({ number: 1, title: "Fix tokens" }), n2: node({ number: 2, title: "Docs" }), n3: null } },
|
|
114
|
-
errors: [{ type: "NOT_FOUND", message: "Could not resolve to a PullRequest with the number of 3." }],
|
|
115
|
-
}
|
|
116
|
-
expect(toNumbersBucket(res, parseSearch("#1 #2 #3"), now).prs.map(p => p.number)).toEqual([1, 2])
|
|
117
|
-
expect(toNumbersBucket(res, parseSearch("#1 #2 fix"), now).prs.map(p => p.number)).toEqual([1])
|
|
118
|
-
expect(toNumbersBucket(res, parseSearch("#1 @someone"), now)).toEqual({ name: "Search results", prs: [] })
|
|
119
|
-
expect(toNumbersBucket(res, parseSearch("#1 @someone @BABAKKS"), now).prs.map(p => p.number)).toEqual([1])
|
|
120
|
-
expect(() => toNumbersBucket({ errors: [{ type: "FORBIDDEN", message: "nope" }] }, parseSearch("#1"), now)).toThrow("nope")
|
|
121
|
-
})
|
package/src/repo.test.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test"
|
|
2
|
-
import { execFileSync } from "node:child_process"
|
|
3
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
4
|
-
import { tmpdir } from "node:os"
|
|
5
|
-
import { join } from "node:path"
|
|
6
|
-
import { detectRepo, ghHosts, parseRemoteUrl, parseRepo, resolveRepo, type GhHosts, type Remote } from "./repo"
|
|
7
|
-
|
|
8
|
-
const repo = (owner: string, name: string, host = "github.com") => ({ host, owner, name })
|
|
9
|
-
const hosts = (over: Partial<GhHosts> = {}): GhHosts => ({ known: ["github.com"], defaultHost: "github.com", source: "default", ...over })
|
|
10
|
-
const ghe: Partial<GhHosts> = { known: ["github.com", "ghe.example.com"], defaultHost: "ghe.example.com" }
|
|
11
|
-
|
|
12
|
-
const withTmp = (fn: (dir: string) => void) => {
|
|
13
|
-
const dir = mkdtempSync(join(tmpdir(), "inbox-tui-"))
|
|
14
|
-
try { fn(dir) } finally { rmSync(dir, { recursive: true, force: true }) }
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
test("parseRepo accepts [HOST/]OWNER/REPO", () => {
|
|
18
|
-
expect(parseRepo("cli/cli")).toEqual(repo("cli", "cli"))
|
|
19
|
-
expect(parseRepo("cli/cli", "ghe.example.com")).toEqual(repo("cli", "cli", "ghe.example.com"))
|
|
20
|
-
expect(parseRepo("GHE.example.com/acme/app")).toEqual(repo("acme", "app", "ghe.example.com"))
|
|
21
|
-
for (const bad of ["cli", "a/b/c/d", "/cli", "cli/", ""]) expect(() => parseRepo(bad)).toThrow()
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
test("parseRemoteUrl follows gh's URL handling", () => {
|
|
25
|
-
expect(parseRemoteUrl("https://github.com/cli/cli.git")).toEqual(repo("cli", "cli"))
|
|
26
|
-
expect(parseRemoteUrl("https://github.com/cli/cli/")).toEqual(repo("cli", "cli"))
|
|
27
|
-
expect(parseRemoteUrl("git+https://www.GitHub.com/cli/cli")).toEqual(repo("cli", "cli"))
|
|
28
|
-
expect(parseRemoteUrl("git@github.com:OscBacon/personal-website.git")).toEqual(repo("OscBacon", "personal-website"))
|
|
29
|
-
expect(parseRemoteUrl("git+ssh://git@github.com/cli/cli.git")).toEqual(repo("cli", "cli"))
|
|
30
|
-
expect(parseRemoteUrl("ssh://git@ssh.github.com:443/cli/cli.git")).toEqual(repo("cli", "cli"))
|
|
31
|
-
expect(parseRemoteUrl("https://user@GHE.example.com/acme/app.git")).toEqual(repo("acme", "app", "ghe.example.com"))
|
|
32
|
-
|
|
33
|
-
const alias = (h: string) => (h === "work" ? "github.com" : h)
|
|
34
|
-
expect(parseRemoteUrl("git@work:acme/app.git", alias)).toEqual(repo("acme", "app"))
|
|
35
|
-
expect(parseRemoteUrl("https://work/acme/app", alias)).toEqual(repo("acme", "app", "work")) // aliases are ssh-only
|
|
36
|
-
|
|
37
|
-
for (const bad of ["/srv/git/app.git", "../local", "file:///srv/a/b", "C:\\repos\\app", "https://github.com/cli", "https://github.com/cli/cli/pulls"]) {
|
|
38
|
-
expect(parseRemoteUrl(bad)).toBeNull()
|
|
39
|
-
}
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
test("resolveRepo: gh-resolved first, then upstream > github > origin > others", () => {
|
|
43
|
-
const fork = "git@github.com:me/app.git", parent = "https://github.com/acme/app.git"
|
|
44
|
-
const r = (name: string, fetchUrl: string, extra: Partial<Remote> = {}): Remote => ({ name, fetchUrl, ...extra })
|
|
45
|
-
|
|
46
|
-
expect(resolveRepo([r("mine", fork), r("origin", fork), r("Upstream", parent)], hosts())).toEqual(repo("acme", "app"))
|
|
47
|
-
expect(resolveRepo([r("zzz", parent), r("origin", fork)], hosts())).toEqual(repo("me", "app"))
|
|
48
|
-
expect(resolveRepo([r("origin", fork, { resolved: "base" }), r("upstream", parent)], hosts())).toEqual(repo("me", "app"))
|
|
49
|
-
expect(resolveRepo([r("origin", fork, { resolved: "other/thing" })], hosts({ ...ghe, source: "hosts" }))).toEqual(repo("other", "thing", "ghe.example.com"))
|
|
50
|
-
expect(resolveRepo([r("origin", "/srv/git/app.git", { pushUrl: parent })], hosts())).toEqual(repo("acme", "app"))
|
|
51
|
-
expect(() => resolveRepo([], hosts())).toThrow(/no git remotes/)
|
|
52
|
-
})
|
|
53
|
-
|
|
54
|
-
test("resolveRepo only considers known hosts, preferring the default host", () => {
|
|
55
|
-
const gitlab = { name: "origin", fetchUrl: "https://gitlab.com/me/app" }
|
|
56
|
-
const dotcom = { name: "mirror", fetchUrl: "https://github.com/acme/app" }
|
|
57
|
-
const enterprise = { name: "zzz", fetchUrl: "https://ghe.example.com/acme/app" }
|
|
58
|
-
|
|
59
|
-
expect(resolveRepo([gitlab, dotcom], hosts())).toEqual(repo("acme", "app"))
|
|
60
|
-
expect(() => resolveRepo([gitlab], hosts())).toThrow(/known GitHub host/)
|
|
61
|
-
// A single hosts.yml entry wins when a remote matches it, and falls back otherwise.
|
|
62
|
-
expect(resolveRepo([dotcom, enterprise], hosts({ ...ghe, source: "hosts" }))).toEqual(repo("acme", "app", "ghe.example.com"))
|
|
63
|
-
expect(resolveRepo([dotcom], hosts({ ...ghe, source: "hosts" }))).toEqual(repo("acme", "app"))
|
|
64
|
-
// GH_HOST never falls back.
|
|
65
|
-
expect(() => resolveRepo([dotcom], hosts({ ...ghe, source: "GH_HOST" }))).toThrow(/GH_HOST/)
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
test("ghHosts reads host keys from gh's config dir and GH_HOST", () => {
|
|
69
|
-
withTmp(dir => {
|
|
70
|
-
writeFileSync(join(dir, "hosts.yml"), "github.com:\n user: me\n oauth_token: secret\nghe.example.com:\n user: me\n")
|
|
71
|
-
expect(ghHosts({ GH_CONFIG_DIR: dir })).toEqual({ known: ["github.com", "ghe.example.com"], defaultHost: "github.com", source: "default" })
|
|
72
|
-
expect(ghHosts({ GH_CONFIG_DIR: dir, GH_HOST: "GHE.example.com" })).toMatchObject({ defaultHost: "ghe.example.com", source: "GH_HOST" })
|
|
73
|
-
|
|
74
|
-
writeFileSync(join(dir, "hosts.yml"), "ghe.example.com:\n user: me\n")
|
|
75
|
-
expect(ghHosts({ GH_CONFIG_DIR: dir })).toEqual({ known: ["ghe.example.com", "github.com"], defaultHost: "ghe.example.com", source: "hosts" })
|
|
76
|
-
})
|
|
77
|
-
})
|
|
78
|
-
|
|
79
|
-
test("detectRepo reads a real clone's remotes, insteadOf rewrites and gh-resolved", () => {
|
|
80
|
-
withTmp(dir => {
|
|
81
|
-
const git = (...args: string[]) => execFileSync("git", ["-C", dir, ...args])
|
|
82
|
-
git("init", "-q")
|
|
83
|
-
git("config", "url.https://github.com/.insteadOf", "gh:")
|
|
84
|
-
git("remote", "add", "origin", "gh:me/app")
|
|
85
|
-
git("remote", "add", "upstream", "gh:acme/app")
|
|
86
|
-
expect(detectRepo(dir, hosts())).toEqual(repo("acme", "app"))
|
|
87
|
-
git("config", "remote.origin.gh-resolved", "base")
|
|
88
|
-
expect(detectRepo(dir, hosts())).toEqual(repo("me", "app"))
|
|
89
|
-
})
|
|
90
|
-
withTmp(dir => expect(() => detectRepo(dir, hosts())).toThrow(/no git remotes/))
|
|
91
|
-
})
|
package/src/run.test.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { expect, test } from "bun:test"
|
|
2
|
-
import { run } from "./run"
|
|
3
|
-
|
|
4
|
-
// Prints the prompt switches and whether the shell leads its own session (sid == pid).
|
|
5
|
-
const probe = ["-c", 'echo "$GIT_TERMINAL_PROMPT/$GH_PROMPT_DISABLED"; [ "$(ps -o sid= -p $$ | tr -d " ")" = "$$" ] && echo own-session || echo shared-session']
|
|
6
|
-
|
|
7
|
-
test.skipIf(process.platform === "win32")("noPrompts runs without a controlling terminal and with prompts disabled", async () => {
|
|
8
|
-
const { code, stdout } = await run("sh", probe, { noPrompts: true })
|
|
9
|
-
expect(code).toBe(0)
|
|
10
|
-
expect(stdout).toBe("0/1\nown-session\n")
|
|
11
|
-
})
|
|
12
|
-
|
|
13
|
-
test.skipIf(process.platform === "win32")("by default the command keeps the app's session and environment", async () => {
|
|
14
|
-
const saved = [process.env.GIT_TERMINAL_PROMPT, process.env.GH_PROMPT_DISABLED]
|
|
15
|
-
delete process.env.GIT_TERMINAL_PROMPT
|
|
16
|
-
delete process.env.GH_PROMPT_DISABLED
|
|
17
|
-
try {
|
|
18
|
-
expect((await run("sh", probe)).stdout).toBe("/\nshared-session\n")
|
|
19
|
-
} finally {
|
|
20
|
-
if (saved[0] !== undefined) process.env.GIT_TERMINAL_PROMPT = saved[0]
|
|
21
|
-
if (saved[1] !== undefined) process.env.GH_PROMPT_DISABLED = saved[1]
|
|
22
|
-
}
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
test("run reports exit codes and output, and rejects when the command is missing", async () => {
|
|
26
|
-
expect(await run("sh", ["-c", "echo out; echo err >&2; exit 3"])).toEqual({ code: 3, stdout: "out\n", stderr: "err\n" })
|
|
27
|
-
await expect(run("tungsten-no-such-command", [])).rejects.toMatchObject({ code: "ENOENT" })
|
|
28
|
-
})
|