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