@oscbacon/tungsten 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/bun.lock +85 -0
- package/package.json +21 -0
- package/src/app.test.tsx +287 -0
- package/src/app.tsx +287 -0
- package/src/checkout.test.ts +145 -0
- package/src/checkout.ts +83 -0
- package/src/data.ts +57 -0
- package/src/github.test.ts +121 -0
- package/src/github.ts +222 -0
- package/src/index.tsx +95 -0
- package/src/repo.test.ts +91 -0
- package/src/repo.ts +182 -0
- package/src/run.test.ts +28 -0
- package/src/run.ts +36 -0
- package/tsconfig.json +18 -0
|
@@ -0,0 +1,121 @@
|
|
|
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/github.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import type { Bucket, PR, State, Status } from "./data"
|
|
2
|
+
import type { Repo } from "./repo"
|
|
3
|
+
import { run } from "./run"
|
|
4
|
+
|
|
5
|
+
export const PAGE_SIZE = 25
|
|
6
|
+
const STALE_DAYS = 14
|
|
7
|
+
|
|
8
|
+
// One GitHub search per bucket; searchQuery() prepends the repo and sort.
|
|
9
|
+
export const BUCKETS = [
|
|
10
|
+
{ name: "Needs your review", query: "is:open user-review-requested:@me" },
|
|
11
|
+
{ name: "Returned to you", query: "is:open author:@me review:changes_requested" },
|
|
12
|
+
{ name: "Approved", query: "is:open author:@me review:approved" },
|
|
13
|
+
// Not `review:required`: that never matches in repos without required reviews.
|
|
14
|
+
{ name: "Waiting for reviewers", query: "is:open author:@me -is:draft -review:approved -review:changes_requested" },
|
|
15
|
+
{ name: "Drafts", query: "is:open author:@me draft:true" },
|
|
16
|
+
{ name: "Open", query: "is:open" }, // everyone's, not just yours
|
|
17
|
+
{ name: "Recently merged", query: "is:merged author:@me" },
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
// What the user typed after "/": words match titles, @login an author, #123 a PR number.
|
|
21
|
+
// Like GitHub's author: qualifiers, several authors mean any of them.
|
|
22
|
+
export interface Search {
|
|
23
|
+
words: string[]
|
|
24
|
+
authors: string[]
|
|
25
|
+
numbers: number[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function parseSearch(input: string): Search {
|
|
29
|
+
const search: Search = { words: [], authors: [], numbers: [] }
|
|
30
|
+
for (const term of input.trim().split(/\s+/).filter(Boolean)) {
|
|
31
|
+
if (/^@[^@\s]+$/.test(term)) search.authors.push(term.slice(1))
|
|
32
|
+
else if (/^#\d+$/.test(term)) search.numbers.push(Number(term.slice(1)))
|
|
33
|
+
else search.words.push(term)
|
|
34
|
+
}
|
|
35
|
+
return search
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The same test done locally, for --demo and for PRs looked up by number.
|
|
39
|
+
export function matches(pr: PR, s: Search): boolean {
|
|
40
|
+
const title = pr.title.toLowerCase(), author = pr.author.toLowerCase()
|
|
41
|
+
return (s.numbers.length === 0 || s.numbers.includes(pr.number))
|
|
42
|
+
&& s.words.every(w => title.includes(w.toLowerCase()))
|
|
43
|
+
&& (s.authors.length === 0 || s.authors.some(a => author === a.toLowerCase()))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Buckets of your own PRs. GitHub ORs author: qualifiers, so these can't take the search's authors;
|
|
47
|
+
// toBuckets() keeps them only when one of those authors is you.
|
|
48
|
+
const isMine = (i: number) => BUCKETS[i]!.query.includes("author:@me")
|
|
49
|
+
|
|
50
|
+
// GitHub search terms for the words and authors; numbers can't be searched, see buildNumbersQuery().
|
|
51
|
+
function searchTerms(s: Search, mine: boolean): string {
|
|
52
|
+
const authors = mine ? [] : s.authors.map(a => `author:${a}`)
|
|
53
|
+
return [...s.words, ...(s.words.length ? ["in:title"] : []), ...authors].join(" ")
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const searchQuery = (repo: Repo, i: number, search?: Search) =>
|
|
57
|
+
[`repo:${repo.owner}/${repo.name} is:pr sort:updated-desc ${BUCKETS[i]!.query}`, search && searchTerms(search, isMine(i))].filter(Boolean).join(" ")
|
|
58
|
+
|
|
59
|
+
const FIELDS = `number title url isDraft state additions deletions updatedAt reviewDecision
|
|
60
|
+
author { login } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
|
|
61
|
+
headRefName isCrossRepository headRepositoryOwner { login } baseRepository { defaultBranchRef { name } }`
|
|
62
|
+
|
|
63
|
+
// Searches are passed as variables, so repo names never need escaping in the query text.
|
|
64
|
+
export function buildQuery(): string {
|
|
65
|
+
const vars = BUCKETS.map((_, i) => `$q${i}: String!`).join(", ")
|
|
66
|
+
const searches = BUCKETS.map((_, i) => `b${i}: search(query: $q${i}, type: ISSUE, first: ${PAGE_SIZE}) { issueCount nodes { ...pr } }`)
|
|
67
|
+
return `fragment pr on PullRequest { ${FIELDS} }\nquery(${vars}) {\n viewer { login }\n ${searches.join("\n ")}\n}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Search doesn't match PR numbers, so #123 looks each one up directly.
|
|
71
|
+
export function buildNumbersQuery(numbers: number[]): string {
|
|
72
|
+
const prs = numbers.map(n => `n${n}: pullRequest(number: ${n}) { ...pr }`)
|
|
73
|
+
return `fragment pr on PullRequest { ${FIELDS} }\nquery($owner: String!, $name: String!) {\n repository(owner: $owner, name: $name) { ${prs.join(" ")} }\n}`
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface PrNode {
|
|
77
|
+
number: number
|
|
78
|
+
title: string
|
|
79
|
+
url: string
|
|
80
|
+
isDraft: boolean
|
|
81
|
+
state: string // OPEN | CLOSED | MERGED
|
|
82
|
+
additions: number
|
|
83
|
+
deletions: number
|
|
84
|
+
updatedAt: string
|
|
85
|
+
reviewDecision: string | null // APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED
|
|
86
|
+
author: { login: string } | null // null for deleted accounts
|
|
87
|
+
commits: { nodes: ({ commit: { statusCheckRollup: { state: string } | null } } | null)[] }
|
|
88
|
+
headRefName: string
|
|
89
|
+
isCrossRepository: boolean
|
|
90
|
+
headRepositoryOwner: { login: string } | null // null once the fork is deleted
|
|
91
|
+
baseRepository: { defaultBranchRef: { name: string } | null } | null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface SearchResult {
|
|
95
|
+
issueCount: number
|
|
96
|
+
nodes: unknown[]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SearchResponse {
|
|
100
|
+
data?: Record<string, SearchResult | { login: string } | null> // b0..b6, plus viewer
|
|
101
|
+
errors?: { message: string }[]
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface NumbersResponse {
|
|
105
|
+
data?: { repository: Record<string, unknown> | null } | null
|
|
106
|
+
errors?: { message: string; type?: string }[]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Non-PR search hits come back as empty objects.
|
|
110
|
+
const isPrNode = (n: unknown): n is PrNode => typeof n === "object" && n !== null && "number" in n
|
|
111
|
+
|
|
112
|
+
const CI: Record<string, Status> = { SUCCESS: "pass", FAILURE: "fail", ERROR: "fail", PENDING: "running" }
|
|
113
|
+
|
|
114
|
+
export function relativeTime(iso: string, now: Date): string {
|
|
115
|
+
const s = Math.max(0, (now.getTime() - Date.parse(iso)) / 1000)
|
|
116
|
+
if (s < 60) return "now"
|
|
117
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`
|
|
118
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`
|
|
119
|
+
if (s < 365 * 86400) return `${Math.floor(s / 86400)}d`
|
|
120
|
+
return `${Math.floor(s / (365 * 86400))}y`
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Like gh pr checkout: a fork's branch named after the default branch would clash with it,
|
|
124
|
+
// so it gets the fork owner as a prefix.
|
|
125
|
+
export function localBranch(n: Pick<PrNode, "headRefName" | "isCrossRepository" | "headRepositoryOwner" | "baseRepository">): string {
|
|
126
|
+
const clashes = n.isCrossRepository && n.headRefName === n.baseRepository?.defaultBranchRef?.name
|
|
127
|
+
return clashes ? `${n.headRepositoryOwner?.login ?? "ghost"}/${n.headRefName}` : n.headRefName
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function toPR(n: PrNode, now: Date): PR {
|
|
131
|
+
const merged = n.state === "MERGED"
|
|
132
|
+
const state: State =
|
|
133
|
+
merged ? "merged" :
|
|
134
|
+
n.isDraft ? "draft" :
|
|
135
|
+
n.reviewDecision === "APPROVED" ? "approved" :
|
|
136
|
+
n.reviewDecision === "CHANGES_REQUESTED" ? "author" :
|
|
137
|
+
"review"
|
|
138
|
+
const review: Status =
|
|
139
|
+
merged ? "merged" :
|
|
140
|
+
n.reviewDecision === "APPROVED" ? "pass" :
|
|
141
|
+
n.reviewDecision === "CHANGES_REQUESTED" ? "fail" :
|
|
142
|
+
"pending"
|
|
143
|
+
const rollup = n.commits.nodes[0]?.commit.statusCheckRollup?.state ?? ""
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
number: n.number,
|
|
147
|
+
title: n.title,
|
|
148
|
+
author: n.author?.login ?? "ghost",
|
|
149
|
+
state,
|
|
150
|
+
ci: CI[rollup] ?? "pending",
|
|
151
|
+
review,
|
|
152
|
+
additions: n.additions,
|
|
153
|
+
deletions: n.deletions,
|
|
154
|
+
updated: relativeTime(n.updatedAt, now),
|
|
155
|
+
stale: !merged && now.getTime() - Date.parse(n.updatedAt) > STALE_DAYS * 86400_000,
|
|
156
|
+
url: n.url,
|
|
157
|
+
branch: localBranch(n),
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function toBuckets(res: SearchResponse, now = new Date(), search?: Search): Bucket[] {
|
|
162
|
+
if (res.errors?.length) throw new Error(res.errors.map(e => e.message).join("; "))
|
|
163
|
+
const viewer = (res.data?.viewer as { login: string } | undefined)?.login.toLowerCase()
|
|
164
|
+
const notMe = !!search?.authors.length && !search.authors.some(a => a.toLowerCase() === viewer)
|
|
165
|
+
return BUCKETS.map((b, i) => {
|
|
166
|
+
if (notMe && isMine(i)) return { name: b.name, total: 0, prs: [] }
|
|
167
|
+
const result = res.data?.[`b${i}`] as SearchResult | null | undefined
|
|
168
|
+
return { name: b.name, total: result?.issueCount ?? 0, prs: (result?.nodes ?? []).filter(isPrNode).map(n => toPR(n, now)) }
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// One "Search results" bucket with the PRs that exist, narrowed by the other terms.
|
|
173
|
+
export function toNumbersBucket(res: NumbersResponse, search: Search, now = new Date()): Bucket {
|
|
174
|
+
// A missing number is a NOT_FOUND error next to the PRs that were found.
|
|
175
|
+
const errors = res.errors?.filter(e => e.type !== "NOT_FOUND") ?? []
|
|
176
|
+
if (errors.length) throw new Error(errors.map(e => e.message).join("; "))
|
|
177
|
+
const prs = search.numbers
|
|
178
|
+
.map(n => res.data?.repository?.[`n${n}`])
|
|
179
|
+
.filter(isPrNode)
|
|
180
|
+
.map(n => toPR(n, now))
|
|
181
|
+
.filter(pr => matches(pr, search))
|
|
182
|
+
return { name: "Search results", prs }
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function gh(args: string[]) {
|
|
186
|
+
try {
|
|
187
|
+
return await run("gh", args)
|
|
188
|
+
} catch (e) {
|
|
189
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") throw new Error("gh is not installed (https://cli.github.com); run with --demo to try the UI")
|
|
190
|
+
throw e
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// gh handles auth and the API host, so no token ever passes through here.
|
|
195
|
+
async function graphql<T extends { errors?: unknown[] }>(repo: Repo, fields: Record<string, string>): Promise<T> {
|
|
196
|
+
const { code, stdout, stderr } = await gh([
|
|
197
|
+
"api", "graphql", "--hostname", repo.host,
|
|
198
|
+
...Object.entries(fields).flatMap(([k, v]) => ["-f", `${k}=${v}`]),
|
|
199
|
+
])
|
|
200
|
+
|
|
201
|
+
let res: T | undefined
|
|
202
|
+
try {
|
|
203
|
+
res = JSON.parse(stdout)
|
|
204
|
+
} catch {
|
|
205
|
+
// Not JSON: gh failed before querying (not logged in, network, ...).
|
|
206
|
+
}
|
|
207
|
+
// GraphQL errors exit non-zero but still print the response, which carries the useful message.
|
|
208
|
+
if (res && (code === 0 || res.errors?.length)) return res
|
|
209
|
+
throw new Error(stderr.trim().split("\n")[0] || `gh exited with code ${code}`)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function fetchBuckets(repo: Repo, search?: Search): Promise<Bucket[]> {
|
|
213
|
+
if (search?.numbers.length) {
|
|
214
|
+
const res = await graphql<NumbersResponse>(repo, { query: buildNumbersQuery(search.numbers), owner: repo.owner, name: repo.name })
|
|
215
|
+
return [toNumbersBucket(res, search)]
|
|
216
|
+
}
|
|
217
|
+
const res = await graphql<SearchResponse>(repo, {
|
|
218
|
+
query: buildQuery(),
|
|
219
|
+
...Object.fromEntries(BUCKETS.map((_, i) => [`q${i}`, searchQuery(repo, i, search)])),
|
|
220
|
+
})
|
|
221
|
+
return toBuckets(res, new Date(), search)
|
|
222
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createCliRenderer } from "@opentui/core"
|
|
3
|
+
import { createRoot } from "@opentui/react"
|
|
4
|
+
import { parseArgs } from "node:util"
|
|
5
|
+
import { App, type Actions } from "./app"
|
|
6
|
+
import { checkoutPr, needsPrompt, openWorktree } from "./checkout"
|
|
7
|
+
import { demoBuckets } from "./data"
|
|
8
|
+
import { fetchBuckets, matches, parseSearch } from "./github"
|
|
9
|
+
import { detectRepo, ghHosts, parseRepo, repoLabel, type Repo } from "./repo"
|
|
10
|
+
import { run, type Run } from "./run"
|
|
11
|
+
|
|
12
|
+
const USAGE = `Usage: tungsten [flags]
|
|
13
|
+
|
|
14
|
+
Flags:
|
|
15
|
+
-R, --repo [HOST/]OWNER/REPO Select another repository using the [HOST/]OWNER/REPO format
|
|
16
|
+
--demo Show sample data instead of fetching from GitHub
|
|
17
|
+
--show-git-prompt Let c and w show git's prompts (ssh passphrase, credentials);
|
|
18
|
+
the app is hidden while they run
|
|
19
|
+
-h, --help Show help`
|
|
20
|
+
|
|
21
|
+
let repo: Repo
|
|
22
|
+
let demo: boolean
|
|
23
|
+
let showGitPrompt: boolean
|
|
24
|
+
let notLocal: string | undefined // why c and w can't work from here
|
|
25
|
+
try {
|
|
26
|
+
const { values } = parseArgs({
|
|
27
|
+
options: {
|
|
28
|
+
repo: { type: "string", short: "R" },
|
|
29
|
+
demo: { type: "boolean" },
|
|
30
|
+
"show-git-prompt": { type: "boolean" },
|
|
31
|
+
help: { type: "boolean", short: "h" },
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
if (values.help) {
|
|
35
|
+
console.log(USAGE)
|
|
36
|
+
process.exit(0)
|
|
37
|
+
}
|
|
38
|
+
demo = values.demo ?? false
|
|
39
|
+
showGitPrompt = values["show-git-prompt"] ?? false
|
|
40
|
+
// 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 a sample repo.
|
|
42
|
+
const hosts = ghHosts()
|
|
43
|
+
repo =
|
|
44
|
+
values.repo ? parseRepo(values.repo, hosts.defaultHost) :
|
|
45
|
+
demo ? parseRepo("withgraphite/monologue") :
|
|
46
|
+
detectRepo(process.cwd(), hosts)
|
|
47
|
+
// Checkouts happen in the current folder, so it has to be a clone of the repo on screen.
|
|
48
|
+
if (demo) notLocal = "not available with --demo"
|
|
49
|
+
else if (values.repo) {
|
|
50
|
+
const key = (r: Repo) => `${r.host}/${r.owner}/${r.name}`.toLowerCase()
|
|
51
|
+
let here: Repo | undefined
|
|
52
|
+
try { here = detectRepo(process.cwd(), hosts) } catch {}
|
|
53
|
+
if (!here || key(here) !== key(repo)) {
|
|
54
|
+
notLocal = `run tungsten from a clone of ${repoLabel(repo)} to check out its PRs`
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch (e) {
|
|
58
|
+
console.error(`${(e as Error).message}\n\n${USAGE}`)
|
|
59
|
+
process.exit(1)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const load = async (search?: string) => {
|
|
63
|
+
const s = search ? parseSearch(search) : undefined
|
|
64
|
+
if (!demo) return fetchBuckets(repo, s)
|
|
65
|
+
const buckets = demoBuckets(repo)
|
|
66
|
+
return s ? buckets.map(b => ({ name: b.name, prs: b.prs.filter(pr => matches(pr, s)) })) : buckets
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: true })
|
|
70
|
+
|
|
71
|
+
// Commands that may prompt fail instead, so the app stays on screen;
|
|
72
|
+
// --show-git-prompt hands them the real terminal, hiding the app while they run.
|
|
73
|
+
const runGit: Run = async (cmd, args, opts) => {
|
|
74
|
+
if (!opts?.interactive) return run(cmd, args)
|
|
75
|
+
if (!showGitPrompt) return run(cmd, args, { noPrompts: true })
|
|
76
|
+
renderer.suspend()
|
|
77
|
+
try {
|
|
78
|
+
return await run(cmd, args, { terminal: true })
|
|
79
|
+
} finally {
|
|
80
|
+
renderer.resume()
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const withHint = (p: Promise<string>) => p.catch((e: Error) => {
|
|
85
|
+
throw showGitPrompt || !needsPrompt(e.message) ? e : new Error(`${e.message} (run with --show-git-prompt to answer git's prompts)`)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
const actions: Actions = notLocal
|
|
89
|
+
? { checkout: () => Promise.reject(new Error(notLocal)), worktree: () => Promise.reject(new Error(notLocal)) }
|
|
90
|
+
: {
|
|
91
|
+
checkout: pr => withHint(checkoutPr(pr, repo, runGit)),
|
|
92
|
+
worktree: pr => withHint(openWorktree(pr, repo, { run: runGit, tmux: !!process.env.TMUX })),
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
createRoot(renderer).render(<App repo={repo} load={load} actions={actions} />)
|
package/src/repo.test.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
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
|
+
})
|