@kitlangton/ghui 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/.env.example +2 -0
- package/LICENSE +21 -0
- package/README.md +67 -0
- package/bin/ghui +3 -0
- package/package.json +54 -0
- package/src/App.tsx +1502 -0
- package/src/config.ts +9 -0
- package/src/date.ts +20 -0
- package/src/domain.ts +30 -0
- package/src/index.tsx +14 -0
- package/src/services/CommandRunner.ts +43 -0
- package/src/services/GitHubService.ts +193 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const parsePositiveInt = (value: string | undefined, fallback: number) => {
|
|
2
|
+
const parsed = Number.parseInt(value ?? "", 10)
|
|
3
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export const config = {
|
|
7
|
+
author: process.env.GHUI_AUTHOR?.trim() || "@me",
|
|
8
|
+
prFetchLimit: parsePositiveInt(process.env.GHUI_PR_FETCH_LIMIT, 200),
|
|
9
|
+
} as const
|
package/src/date.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
2
|
+
|
|
3
|
+
export const formatShortDate = (date: Date) =>
|
|
4
|
+
date.toLocaleDateString("en-US", { weekday: "short", month: "numeric", day: "numeric" })
|
|
5
|
+
|
|
6
|
+
export const formatTimestamp = (date: Date) =>
|
|
7
|
+
date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }).toLowerCase()
|
|
8
|
+
|
|
9
|
+
export const daysOpen = (date: Date) => Math.max(0, Math.floor((Date.now() - date.getTime()) / DAY_MS))
|
|
10
|
+
|
|
11
|
+
export const formatRelativeDate = (date: Date): string => {
|
|
12
|
+
const days = daysOpen(date)
|
|
13
|
+
if (days === 0) return "today"
|
|
14
|
+
if (days === 1) return "yesterday"
|
|
15
|
+
if (days < 7) return `${days} days ago`
|
|
16
|
+
if (days < 14) return "last week"
|
|
17
|
+
if (days < 30) return `${Math.floor(days / 7)} weeks ago`
|
|
18
|
+
if (days < 60) return "last month"
|
|
19
|
+
return `${Math.floor(days / 30)} months ago`
|
|
20
|
+
}
|
package/src/domain.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type PullRequestState = "open" | "closed"
|
|
2
|
+
|
|
3
|
+
export type CheckConclusion = "success" | "failure" | "neutral" | "skipped" | "cancelled" | "timed_out"
|
|
4
|
+
|
|
5
|
+
export interface CheckItem {
|
|
6
|
+
readonly name: string
|
|
7
|
+
readonly status: "completed" | "in_progress" | "queued" | "pending"
|
|
8
|
+
readonly conclusion: CheckConclusion | null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface PullRequestLabel {
|
|
12
|
+
readonly name: string
|
|
13
|
+
readonly color: string | null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PullRequestItem {
|
|
17
|
+
readonly repository: string
|
|
18
|
+
readonly number: number
|
|
19
|
+
readonly title: string
|
|
20
|
+
readonly body: string
|
|
21
|
+
readonly labels: readonly PullRequestLabel[]
|
|
22
|
+
readonly state: PullRequestState
|
|
23
|
+
readonly reviewStatus: "draft" | "approved" | "changes" | "review" | "none"
|
|
24
|
+
readonly checkStatus: "passing" | "pending" | "failing" | "none"
|
|
25
|
+
readonly checkSummary: string | null
|
|
26
|
+
readonly checks: readonly CheckItem[]
|
|
27
|
+
readonly createdAt: Date
|
|
28
|
+
readonly closedAt: Date | null
|
|
29
|
+
readonly url: string
|
|
30
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { createCliRenderer } from "@opentui/core"
|
|
4
|
+
import { RegistryProvider } from "@effect/atom-react"
|
|
5
|
+
import { createRoot } from "@opentui/react"
|
|
6
|
+
import { App } from "./App.js"
|
|
7
|
+
|
|
8
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: false })
|
|
9
|
+
|
|
10
|
+
createRoot(renderer).render(
|
|
11
|
+
<RegistryProvider>
|
|
12
|
+
<App />
|
|
13
|
+
</RegistryProvider>,
|
|
14
|
+
)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export interface CommandResult {
|
|
2
|
+
readonly stdout: string
|
|
3
|
+
readonly stderr: string
|
|
4
|
+
readonly exitCode: number
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const readStream = async (stream: ReadableStream | null | undefined) => {
|
|
8
|
+
if (!stream) return ""
|
|
9
|
+
return Bun.readableStreamToText(stream)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const runProcess = async (command: string, args: readonly string[]): Promise<CommandResult> => {
|
|
13
|
+
try {
|
|
14
|
+
const proc = Bun.spawn({
|
|
15
|
+
cmd: [command, ...args],
|
|
16
|
+
stdout: "pipe",
|
|
17
|
+
stderr: "pipe",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
const [exitCode, stdout, stderr] = await Promise.all([proc.exited, readStream(proc.stdout), readStream(proc.stderr)])
|
|
21
|
+
return { stdout, stderr, exitCode }
|
|
22
|
+
} catch (error) {
|
|
23
|
+
throw new Error(`Failed to run ${command}: ${String(error)}`)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const run = async (command: string, args: readonly string[]) => {
|
|
28
|
+
const result = await runProcess(command, args)
|
|
29
|
+
if (result.exitCode !== 0) {
|
|
30
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`
|
|
31
|
+
throw new Error(`${command} ${args.join(" ")} failed: ${detail}`)
|
|
32
|
+
}
|
|
33
|
+
return result
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const runJson = async <A>(command: string, args: readonly string[]) => {
|
|
37
|
+
const result = await run(command, args)
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(result.stdout) as A
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new Error(`Could not parse JSON from ${command}: ${String(error)}`)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { config } from "../config.js"
|
|
2
|
+
import type { CheckItem, PullRequestItem, PullRequestLabel } from "../domain.js"
|
|
3
|
+
import { run, runJson } from "./CommandRunner.js"
|
|
4
|
+
|
|
5
|
+
interface GitHubListPullRequest {
|
|
6
|
+
readonly number: number
|
|
7
|
+
readonly title: string
|
|
8
|
+
readonly body: string
|
|
9
|
+
readonly labels: readonly {
|
|
10
|
+
readonly name: string
|
|
11
|
+
readonly color?: string | null
|
|
12
|
+
}[]
|
|
13
|
+
readonly isDraft: boolean
|
|
14
|
+
readonly reviewDecision: string
|
|
15
|
+
readonly statusCheckRollup: readonly {
|
|
16
|
+
readonly name?: string | null
|
|
17
|
+
readonly context?: string | null
|
|
18
|
+
readonly status?: string | null
|
|
19
|
+
readonly conclusion?: string | null
|
|
20
|
+
readonly state?: string | null
|
|
21
|
+
}[]
|
|
22
|
+
readonly state: string
|
|
23
|
+
readonly createdAt: string
|
|
24
|
+
readonly closedAt?: string | null
|
|
25
|
+
readonly url: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface GitHubSearchPullRequest {
|
|
29
|
+
readonly number: number
|
|
30
|
+
readonly repository: {
|
|
31
|
+
readonly nameWithOwner: string
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface GitHubViewer {
|
|
36
|
+
readonly login: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const searchJsonFields = "repository,number"
|
|
40
|
+
const detailJsonFields = "number,title,body,labels,isDraft,reviewDecision,statusCheckRollup,state,createdAt,closedAt,url"
|
|
41
|
+
|
|
42
|
+
const normalizeDate = (value: string | null | undefined) => {
|
|
43
|
+
if (!value || value.startsWith("0001-01-01")) return null
|
|
44
|
+
return new Date(value)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const getReviewStatus = (item: GitHubListPullRequest): PullRequestItem["reviewStatus"] => {
|
|
48
|
+
if (item.isDraft) return "draft"
|
|
49
|
+
if (item.reviewDecision === "APPROVED") return "approved"
|
|
50
|
+
if (item.reviewDecision === "CHANGES_REQUESTED") return "changes"
|
|
51
|
+
if (item.reviewDecision === "REVIEW_REQUIRED") return "review"
|
|
52
|
+
return "none"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const normalizeCheckStatus = (raw?: string | null): CheckItem["status"] => {
|
|
56
|
+
if (raw === "COMPLETED") return "completed"
|
|
57
|
+
if (raw === "IN_PROGRESS") return "in_progress"
|
|
58
|
+
if (raw === "QUEUED") return "queued"
|
|
59
|
+
return "pending"
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const normalizeCheckConclusion = (raw?: string | null): CheckItem["conclusion"] => {
|
|
63
|
+
if (raw === "SUCCESS") return "success"
|
|
64
|
+
if (raw === "FAILURE" || raw === "ERROR") return "failure"
|
|
65
|
+
if (raw === "NEUTRAL") return "neutral"
|
|
66
|
+
if (raw === "SKIPPED") return "skipped"
|
|
67
|
+
if (raw === "CANCELLED") return "cancelled"
|
|
68
|
+
if (raw === "TIMED_OUT") return "timed_out"
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const getCheckInfo = (item: GitHubListPullRequest): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
|
|
73
|
+
if (item.statusCheckRollup.length === 0) {
|
|
74
|
+
return { checkStatus: "none", checkSummary: null, checks: [] }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let completed = 0
|
|
78
|
+
let successful = 0
|
|
79
|
+
let pending = false
|
|
80
|
+
let failing = false
|
|
81
|
+
const checks: CheckItem[] = []
|
|
82
|
+
|
|
83
|
+
for (const check of item.statusCheckRollup) {
|
|
84
|
+
const name = check.name ?? check.context ?? "check"
|
|
85
|
+
|
|
86
|
+
checks.push({
|
|
87
|
+
name,
|
|
88
|
+
status: normalizeCheckStatus(check.status),
|
|
89
|
+
conclusion: normalizeCheckConclusion(check.conclusion),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
if (check.status === "COMPLETED") {
|
|
93
|
+
completed += 1
|
|
94
|
+
} else {
|
|
95
|
+
pending = true
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (check.conclusion === "SUCCESS" || check.conclusion === "NEUTRAL" || check.conclusion === "SKIPPED") {
|
|
99
|
+
successful += 1
|
|
100
|
+
} else if (check.conclusion && check.conclusion !== "SUCCESS") {
|
|
101
|
+
failing = true
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (pending) {
|
|
106
|
+
return { checkStatus: "pending", checkSummary: `checks ${completed}/${item.statusCheckRollup.length}`, checks }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (failing) {
|
|
110
|
+
return { checkStatus: "failing", checkSummary: `checks ${successful}/${item.statusCheckRollup.length}`, checks }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { checkStatus: "passing", checkSummary: `checks ${successful}/${item.statusCheckRollup.length}`, checks }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const parsePullRequest = (repository: string, item: GitHubListPullRequest): PullRequestItem => {
|
|
117
|
+
const checkInfo = getCheckInfo(item)
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
repository,
|
|
121
|
+
number: item.number,
|
|
122
|
+
title: item.title,
|
|
123
|
+
body: item.body,
|
|
124
|
+
labels: item.labels.map((label) => ({
|
|
125
|
+
name: label.name,
|
|
126
|
+
color: label.color ? `#${label.color}` : null,
|
|
127
|
+
})),
|
|
128
|
+
state: item.state.toLowerCase() === "open" ? "open" : "closed",
|
|
129
|
+
reviewStatus: getReviewStatus(item),
|
|
130
|
+
checkStatus: checkInfo.checkStatus,
|
|
131
|
+
checkSummary: checkInfo.checkSummary,
|
|
132
|
+
checks: checkInfo.checks,
|
|
133
|
+
createdAt: new Date(item.createdAt),
|
|
134
|
+
closedAt: normalizeDate(item.closedAt),
|
|
135
|
+
url: item.url,
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const searchOpenArgs = (author: string) => [
|
|
140
|
+
"search",
|
|
141
|
+
"prs",
|
|
142
|
+
"--author",
|
|
143
|
+
author,
|
|
144
|
+
"--state",
|
|
145
|
+
"open",
|
|
146
|
+
"--limit",
|
|
147
|
+
String(config.prFetchLimit),
|
|
148
|
+
"--sort",
|
|
149
|
+
"created",
|
|
150
|
+
"--order",
|
|
151
|
+
"desc",
|
|
152
|
+
"--json",
|
|
153
|
+
searchJsonFields,
|
|
154
|
+
] as const
|
|
155
|
+
|
|
156
|
+
export const listOpenPullRequests = async (): Promise<readonly PullRequestItem[]> => {
|
|
157
|
+
const searchResults = await runJson<readonly GitHubSearchPullRequest[]>("gh", [...searchOpenArgs(config.author)])
|
|
158
|
+
const pullRequests = await Promise.all(
|
|
159
|
+
searchResults.map(async (searchResult) => {
|
|
160
|
+
const repository = searchResult.repository.nameWithOwner
|
|
161
|
+
const pullRequest = await runJson<GitHubListPullRequest>("gh", [
|
|
162
|
+
"pr", "view", String(searchResult.number), "--repo", repository, "--json", detailJsonFields,
|
|
163
|
+
])
|
|
164
|
+
return parsePullRequest(repository, pullRequest)
|
|
165
|
+
}),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
return pullRequests.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export const getAuthenticatedUser = async () => {
|
|
172
|
+
const viewer = await runJson<GitHubViewer>("gh", ["api", "user"])
|
|
173
|
+
return viewer.login
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export const toggleDraftStatus = async (repository: string, number: number, isDraft: boolean) => {
|
|
177
|
+
await run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const listRepoLabels = async (repository: string): Promise<readonly PullRequestLabel[]> => {
|
|
181
|
+
const labels = await runJson<readonly { name: string; color: string }[]>("gh", [
|
|
182
|
+
"label", "list", "--repo", repository, "--json", "name,color", "--limit", "100",
|
|
183
|
+
])
|
|
184
|
+
return labels.map((label) => ({ name: label.name, color: `#${label.color}` }))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export const addPullRequestLabel = async (repository: string, number: number, label: string) => {
|
|
188
|
+
await run("gh", ["pr", "edit", String(number), "--repo", repository, "--add-label", label])
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const removePullRequestLabel = async (repository: string, number: number, label: string) => {
|
|
192
|
+
await run("gh", ["pr", "edit", String(number), "--repo", repository, "--remove-label", label])
|
|
193
|
+
}
|