@oscbacon/tungsten 0.0.1 → 0.0.2
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 +19 -8
- package/package.json +21 -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,28 @@ 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 --demo # sample data, no clone or network
|
|
38
|
+
tungsten --show-git-prompt # let c and w ask for ssh passphrases or credentials
|
|
39
|
+
```
|
|
29
40
|
|
|
30
41
|
## Buckets
|
|
31
42
|
|
package/package.json
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oscbacon/tungsten",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.2",
|
|
5
|
+
"description": "A terminal inbox for a GitHub repo's pull requests",
|
|
6
|
+
"keywords": ["github", "pull-requests", "tui", "terminal", "cli"],
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"author": "Oscar Baracos",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/OscBacon/tungsten.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/OscBacon/tungsten#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/OscBacon/tungsten/issues"
|
|
16
|
+
},
|
|
5
17
|
"bin": {
|
|
6
18
|
"tungsten": "src/index.tsx"
|
|
7
19
|
},
|
|
20
|
+
"files": [
|
|
21
|
+
"src",
|
|
22
|
+
"!src/**/*.test.*",
|
|
23
|
+
"tsconfig.json"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"bun": ">=1.0.0"
|
|
27
|
+
},
|
|
8
28
|
"devDependencies": {
|
|
9
29
|
"@types/bun": "^1.4.2",
|
|
10
30
|
"@types/react": "^19.3.0"
|
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
|
-
})
|