@kitlangton/ghui 0.1.6 → 0.1.8
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 +4 -0
- package/package.json +1 -1
- package/src/App.tsx +221 -809
- package/src/config.ts +17 -8
- package/src/domain.ts +17 -0
- package/src/index.tsx +1 -0
- package/src/observability.ts +46 -0
- package/src/services/CommandRunner.ts +6 -1
- package/src/services/GitHubService.ts +161 -14
- package/src/ui/FooterHints.tsx +145 -0
- package/src/ui/PullRequestList.tsx +128 -0
- package/src/ui/colors.ts +28 -0
- package/src/ui/diff.ts +220 -0
- package/src/ui/modals.tsx +294 -0
- package/src/ui/primitives.tsx +111 -0
- package/src/ui/pullRequests.ts +72 -0
package/src/config.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
const parsed = Number.parseInt(value ?? "", 10)
|
|
3
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback
|
|
4
|
-
}
|
|
1
|
+
import { Config, Effect } from "effect"
|
|
5
2
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
const positiveIntOr = (fallback: number) => (value: number) => Number.isFinite(value) && value > 0 ? value : fallback
|
|
4
|
+
|
|
5
|
+
const appConfig = Config.all({
|
|
6
|
+
author: Config.string("GHUI_AUTHOR").pipe(
|
|
7
|
+
Config.withDefault("@me"),
|
|
8
|
+
Config.map((value) => value.trim() || "@me"),
|
|
9
|
+
),
|
|
10
|
+
prFetchLimit: Config.int("GHUI_PR_FETCH_LIMIT").pipe(
|
|
11
|
+
Config.withDefault(200),
|
|
12
|
+
Config.map(positiveIntOr(200)),
|
|
13
|
+
),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export const config = Effect.runSync(Effect.gen(function*() {
|
|
17
|
+
return yield* appConfig
|
|
18
|
+
}))
|
package/src/domain.ts
CHANGED
|
@@ -27,7 +27,24 @@ export interface PullRequestItem {
|
|
|
27
27
|
readonly checkStatus: "passing" | "pending" | "failing" | "none"
|
|
28
28
|
readonly checkSummary: string | null
|
|
29
29
|
readonly checks: readonly CheckItem[]
|
|
30
|
+
readonly autoMergeEnabled: boolean
|
|
31
|
+
readonly detailLoaded: boolean
|
|
30
32
|
readonly createdAt: Date
|
|
31
33
|
readonly closedAt: Date | null
|
|
32
34
|
readonly url: string
|
|
33
35
|
}
|
|
36
|
+
|
|
37
|
+
export interface PullRequestMergeInfo {
|
|
38
|
+
readonly repository: string
|
|
39
|
+
readonly number: number
|
|
40
|
+
readonly title: string
|
|
41
|
+
readonly state: PullRequestState
|
|
42
|
+
readonly isDraft: boolean
|
|
43
|
+
readonly mergeable: "mergeable" | "conflicting" | "unknown"
|
|
44
|
+
readonly reviewStatus: PullRequestItem["reviewStatus"]
|
|
45
|
+
readonly checkStatus: PullRequestItem["checkStatus"]
|
|
46
|
+
readonly checkSummary: string | null
|
|
47
|
+
readonly autoMergeEnabled: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type PullRequestMergeAction = "squash" | "auto" | "admin" | "disable-auto"
|
package/src/index.tsx
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Config, Effect, Layer } from "effect"
|
|
2
|
+
import { FetchHttpClient } from "effect/unstable/http"
|
|
3
|
+
import { OtlpLogger, OtlpSerialization, OtlpTracer } from "effect/unstable/observability"
|
|
4
|
+
|
|
5
|
+
const observabilityConfig = Config.all({
|
|
6
|
+
endpoint: Config.string("GHUI_OTLP_ENDPOINT").pipe(
|
|
7
|
+
Config.withDefault(""),
|
|
8
|
+
Config.map((value) => value.trim()),
|
|
9
|
+
),
|
|
10
|
+
motelPort: Config.string("GHUI_MOTEL_PORT").pipe(
|
|
11
|
+
Config.withDefault(""),
|
|
12
|
+
Config.map((value) => value.trim()),
|
|
13
|
+
),
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const resource = {
|
|
17
|
+
serviceName: "ghui",
|
|
18
|
+
serviceVersion: "local",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const Observability = {
|
|
22
|
+
layer: Layer.unwrap(Effect.gen(function*() {
|
|
23
|
+
const { endpoint, motelPort } = yield* observabilityConfig
|
|
24
|
+
const baseUrl = endpoint || (motelPort ? `http://127.0.0.1:${motelPort}` : null)
|
|
25
|
+
|
|
26
|
+
return baseUrl === null
|
|
27
|
+
? Layer.empty
|
|
28
|
+
: Layer.merge(
|
|
29
|
+
OtlpTracer.layer({
|
|
30
|
+
url: `${baseUrl}/v1/traces`,
|
|
31
|
+
exportInterval: "500 millis",
|
|
32
|
+
shutdownTimeout: "1 second",
|
|
33
|
+
resource,
|
|
34
|
+
}),
|
|
35
|
+
OtlpLogger.layer({
|
|
36
|
+
url: `${baseUrl}/v1/logs`,
|
|
37
|
+
exportInterval: "500 millis",
|
|
38
|
+
shutdownTimeout: "1 second",
|
|
39
|
+
resource,
|
|
40
|
+
}),
|
|
41
|
+
).pipe(
|
|
42
|
+
Layer.provide(OtlpSerialization.layerJson),
|
|
43
|
+
Layer.provide(FetchHttpClient.layer),
|
|
44
|
+
)
|
|
45
|
+
})),
|
|
46
|
+
} as const
|
|
@@ -49,7 +49,12 @@ export class CommandRunner extends Context.Service<CommandRunner, {
|
|
|
49
49
|
)
|
|
50
50
|
|
|
51
51
|
const run = Effect.fn("CommandRunner.run")(function*(command: string, args: readonly string[]) {
|
|
52
|
-
const result = yield* runProcess(command, args)
|
|
52
|
+
const result = yield* runProcess(command, args).pipe(Effect.withSpan("ghui.command.runProcess", {
|
|
53
|
+
attributes: {
|
|
54
|
+
"process.command": command,
|
|
55
|
+
"process.argv.count": args.length,
|
|
56
|
+
},
|
|
57
|
+
}))
|
|
53
58
|
if (result.exitCode !== 0) {
|
|
54
59
|
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.exitCode}`
|
|
55
60
|
return yield* new CommandError({ command, args: [...args], detail, cause: detail })
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import { Context, Effect, Layer } from "effect"
|
|
2
2
|
import { config } from "../config.js"
|
|
3
|
-
import type { CheckItem, PullRequestItem } from "../domain.js"
|
|
3
|
+
import type { CheckItem, PullRequestItem, PullRequestMergeAction, PullRequestMergeInfo } from "../domain.js"
|
|
4
4
|
import { CommandRunner, type CommandError, type JsonParseError } from "./CommandRunner.js"
|
|
5
5
|
|
|
6
|
-
interface
|
|
6
|
+
interface GitHubPullRequestSummaryNode {
|
|
7
7
|
readonly number: number
|
|
8
8
|
readonly title: string
|
|
9
|
+
readonly isDraft: boolean
|
|
10
|
+
readonly reviewDecision: string | null
|
|
11
|
+
readonly autoMergeRequest: unknown | null
|
|
12
|
+
readonly state: string
|
|
13
|
+
readonly createdAt: string
|
|
14
|
+
readonly closedAt?: string | null
|
|
15
|
+
readonly url: string
|
|
16
|
+
readonly repository: {
|
|
17
|
+
readonly nameWithOwner: string
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface GitHubPullRequestNode extends GitHubPullRequestSummaryNode {
|
|
9
22
|
readonly body: string
|
|
10
23
|
readonly labels: {
|
|
11
24
|
readonly nodes: readonly {
|
|
@@ -16,20 +29,11 @@ interface GitHubPullRequestNode {
|
|
|
16
29
|
readonly additions: number
|
|
17
30
|
readonly deletions: number
|
|
18
31
|
readonly changedFiles: number
|
|
19
|
-
readonly isDraft: boolean
|
|
20
|
-
readonly reviewDecision: string | null
|
|
21
32
|
readonly statusCheckRollup?: {
|
|
22
33
|
readonly contexts: {
|
|
23
34
|
readonly nodes: readonly GraphQLCheckContext[]
|
|
24
35
|
}
|
|
25
36
|
} | null
|
|
26
|
-
readonly state: string
|
|
27
|
-
readonly createdAt: string
|
|
28
|
-
readonly closedAt?: string | null
|
|
29
|
-
readonly url: string
|
|
30
|
-
readonly repository: {
|
|
31
|
-
readonly nameWithOwner: string
|
|
32
|
-
}
|
|
33
37
|
}
|
|
34
38
|
|
|
35
39
|
type GraphQLCheckContext =
|
|
@@ -57,10 +61,33 @@ interface GraphQLSearchResponse {
|
|
|
57
61
|
}
|
|
58
62
|
}
|
|
59
63
|
|
|
64
|
+
interface GraphQLSearchSummaryResponse {
|
|
65
|
+
readonly data: {
|
|
66
|
+
readonly search: {
|
|
67
|
+
readonly nodes: readonly (GitHubPullRequestSummaryNode | null)[]
|
|
68
|
+
readonly pageInfo: {
|
|
69
|
+
readonly hasNextPage: boolean
|
|
70
|
+
readonly endCursor: string | null
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
60
76
|
interface GitHubViewer {
|
|
61
77
|
readonly login: string
|
|
62
78
|
}
|
|
63
79
|
|
|
80
|
+
interface GitHubMergeInfoResponse {
|
|
81
|
+
readonly number: number
|
|
82
|
+
readonly title: string
|
|
83
|
+
readonly state: string
|
|
84
|
+
readonly isDraft: boolean
|
|
85
|
+
readonly mergeable: string
|
|
86
|
+
readonly reviewDecision: string | null
|
|
87
|
+
readonly autoMergeRequest: unknown | null
|
|
88
|
+
readonly statusCheckRollup: readonly GraphQLCheckContext[]
|
|
89
|
+
}
|
|
90
|
+
|
|
64
91
|
const pullRequestSearchQuery = `
|
|
65
92
|
query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
|
|
66
93
|
search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
|
|
@@ -71,6 +98,7 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
|
|
|
71
98
|
body
|
|
72
99
|
isDraft
|
|
73
100
|
reviewDecision
|
|
101
|
+
autoMergeRequest { enabledAt }
|
|
74
102
|
additions
|
|
75
103
|
deletions
|
|
76
104
|
changedFiles
|
|
@@ -96,12 +124,34 @@ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
|
|
|
96
124
|
}
|
|
97
125
|
`
|
|
98
126
|
|
|
127
|
+
const pullRequestSummarySearchQuery = `
|
|
128
|
+
query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
|
|
129
|
+
search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
|
|
130
|
+
nodes {
|
|
131
|
+
... on PullRequest {
|
|
132
|
+
number
|
|
133
|
+
title
|
|
134
|
+
isDraft
|
|
135
|
+
reviewDecision
|
|
136
|
+
autoMergeRequest { enabledAt }
|
|
137
|
+
state
|
|
138
|
+
createdAt
|
|
139
|
+
closedAt
|
|
140
|
+
url
|
|
141
|
+
repository { nameWithOwner }
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
pageInfo { hasNextPage endCursor }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
`
|
|
148
|
+
|
|
99
149
|
const normalizeDate = (value: string | null | undefined) => {
|
|
100
150
|
if (!value || value.startsWith("0001-01-01")) return null
|
|
101
151
|
return new Date(value)
|
|
102
152
|
}
|
|
103
153
|
|
|
104
|
-
const getReviewStatus = (item:
|
|
154
|
+
const getReviewStatus = (item: { readonly isDraft: boolean; readonly reviewDecision: string | null }): PullRequestItem["reviewStatus"] => {
|
|
105
155
|
if (item.isDraft) return "draft"
|
|
106
156
|
if (item.reviewDecision === "APPROVED") return "approved"
|
|
107
157
|
if (item.reviewDecision === "CHANGES_REQUESTED") return "changes"
|
|
@@ -139,8 +189,7 @@ const getContextConclusion = (context: GraphQLCheckContext): CheckItem["conclusi
|
|
|
139
189
|
return null
|
|
140
190
|
}
|
|
141
191
|
|
|
142
|
-
const
|
|
143
|
-
const contexts = item.statusCheckRollup?.contexts.nodes ?? []
|
|
192
|
+
const getCheckInfoFromContexts = (contexts: readonly GraphQLCheckContext[]): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
|
|
144
193
|
if (contexts.length === 0) {
|
|
145
194
|
return { checkStatus: "none", checkSummary: null, checks: [] }
|
|
146
195
|
}
|
|
@@ -182,6 +231,9 @@ const getCheckInfo = (item: GitHubPullRequestNode): Pick<PullRequestItem, "check
|
|
|
182
231
|
return { checkStatus: "passing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
|
|
183
232
|
}
|
|
184
233
|
|
|
234
|
+
const getCheckInfo = (item: GitHubPullRequestNode): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> =>
|
|
235
|
+
getCheckInfoFromContexts(item.statusCheckRollup?.contexts.nodes ?? [])
|
|
236
|
+
|
|
185
237
|
const parsePullRequest = (item: GitHubPullRequestNode): PullRequestItem => {
|
|
186
238
|
const checkInfo = getCheckInfo(item)
|
|
187
239
|
|
|
@@ -202,20 +254,52 @@ const parsePullRequest = (item: GitHubPullRequestNode): PullRequestItem => {
|
|
|
202
254
|
checkStatus: checkInfo.checkStatus,
|
|
203
255
|
checkSummary: checkInfo.checkSummary,
|
|
204
256
|
checks: checkInfo.checks,
|
|
257
|
+
autoMergeEnabled: item.autoMergeRequest !== null,
|
|
258
|
+
detailLoaded: true,
|
|
205
259
|
createdAt: new Date(item.createdAt),
|
|
206
260
|
closedAt: normalizeDate(item.closedAt),
|
|
207
261
|
url: item.url,
|
|
208
262
|
}
|
|
209
263
|
}
|
|
210
264
|
|
|
265
|
+
const parsePullRequestSummary = (item: GitHubPullRequestSummaryNode): PullRequestItem => ({
|
|
266
|
+
repository: item.repository.nameWithOwner,
|
|
267
|
+
number: item.number,
|
|
268
|
+
title: item.title,
|
|
269
|
+
body: "",
|
|
270
|
+
labels: [],
|
|
271
|
+
additions: 0,
|
|
272
|
+
deletions: 0,
|
|
273
|
+
changedFiles: 0,
|
|
274
|
+
state: item.state.toLowerCase() === "open" ? "open" : "closed",
|
|
275
|
+
reviewStatus: getReviewStatus(item),
|
|
276
|
+
checkStatus: "none",
|
|
277
|
+
checkSummary: null,
|
|
278
|
+
checks: [],
|
|
279
|
+
autoMergeEnabled: item.autoMergeRequest !== null,
|
|
280
|
+
detailLoaded: false,
|
|
281
|
+
createdAt: new Date(item.createdAt),
|
|
282
|
+
closedAt: normalizeDate(item.closedAt),
|
|
283
|
+
url: item.url,
|
|
284
|
+
})
|
|
285
|
+
|
|
211
286
|
const searchQuery = (author: string) => `author:${author} is:pr is:open sort:created-desc`
|
|
212
287
|
|
|
213
288
|
type GitHubError = CommandError | JsonParseError
|
|
214
289
|
|
|
290
|
+
const normalizeMergeable = (value: string): PullRequestMergeInfo["mergeable"] => {
|
|
291
|
+
if (value === "MERGEABLE") return "mergeable"
|
|
292
|
+
if (value === "CONFLICTING") return "conflicting"
|
|
293
|
+
return "unknown"
|
|
294
|
+
}
|
|
295
|
+
|
|
215
296
|
export class GitHubService extends Context.Service<GitHubService, {
|
|
216
297
|
readonly listOpenPullRequests: () => Effect.Effect<readonly PullRequestItem[], GitHubError>
|
|
298
|
+
readonly listOpenPullRequestDetails: () => Effect.Effect<readonly PullRequestItem[], GitHubError>
|
|
217
299
|
readonly getAuthenticatedUser: () => Effect.Effect<string, GitHubError>
|
|
218
300
|
readonly getPullRequestDiff: (repository: string, number: number) => Effect.Effect<string, CommandError>
|
|
301
|
+
readonly getPullRequestMergeInfo: (repository: string, number: number) => Effect.Effect<PullRequestMergeInfo, GitHubError>
|
|
302
|
+
readonly mergePullRequest: (repository: string, number: number, action: PullRequestMergeAction) => Effect.Effect<void, CommandError>
|
|
219
303
|
readonly toggleDraftStatus: (repository: string, number: number, isDraft: boolean) => Effect.Effect<void, CommandError>
|
|
220
304
|
readonly listRepoLabels: (repository: string) => Effect.Effect<readonly { readonly name: string; readonly color: string | null }[], GitHubError>
|
|
221
305
|
readonly addPullRequestLabel: (repository: string, number: number, label: string) => Effect.Effect<void, CommandError>
|
|
@@ -230,6 +314,32 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
230
314
|
const pullRequests: PullRequestItem[] = []
|
|
231
315
|
let cursor: string | null = null
|
|
232
316
|
|
|
317
|
+
while (pullRequests.length < config.prFetchLimit) {
|
|
318
|
+
const pageSize = Math.min(100, config.prFetchLimit - pullRequests.length)
|
|
319
|
+
const response: GraphQLSearchSummaryResponse = yield* command.runJson<GraphQLSearchSummaryResponse>("gh", [
|
|
320
|
+
"api", "graphql",
|
|
321
|
+
"-f", `query=${pullRequestSummarySearchQuery}`,
|
|
322
|
+
"-F", `searchQuery=${searchQuery(config.author)}`,
|
|
323
|
+
"-F", `first=${pageSize}`,
|
|
324
|
+
...(cursor ? ["-F", `after=${cursor}`] : []),
|
|
325
|
+
])
|
|
326
|
+
|
|
327
|
+
for (const node of response.data.search.nodes) {
|
|
328
|
+
if (node) pullRequests.push(parsePullRequestSummary(node))
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (!response.data.search.pageInfo.hasNextPage) break
|
|
332
|
+
cursor = response.data.search.pageInfo.endCursor
|
|
333
|
+
if (!cursor) break
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return pullRequests.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
const listOpenPullRequestDetails = Effect.fn("GitHubService.listOpenPullRequestDetails")(function*() {
|
|
340
|
+
const pullRequests: PullRequestItem[] = []
|
|
341
|
+
let cursor: string | null = null
|
|
342
|
+
|
|
233
343
|
while (pullRequests.length < config.prFetchLimit) {
|
|
234
344
|
const pageSize = Math.min(100, config.prFetchLimit - pullRequests.length)
|
|
235
345
|
const response: GraphQLSearchResponse = yield* command.runJson<GraphQLSearchResponse>("gh", [
|
|
@@ -262,6 +372,40 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
262
372
|
return result.stdout
|
|
263
373
|
})
|
|
264
374
|
|
|
375
|
+
const getPullRequestMergeInfo = Effect.fn("GitHubService.getPullRequestMergeInfo")(function*(repository: string, number: number) {
|
|
376
|
+
const info = yield* command.runJson<GitHubMergeInfoResponse>("gh", [
|
|
377
|
+
"pr", "view", String(number), "--repo", repository,
|
|
378
|
+
"--json", "number,title,state,isDraft,mergeable,reviewDecision,autoMergeRequest,statusCheckRollup",
|
|
379
|
+
])
|
|
380
|
+
const checkInfo = getCheckInfoFromContexts(info.statusCheckRollup)
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
repository,
|
|
384
|
+
number: info.number,
|
|
385
|
+
title: info.title,
|
|
386
|
+
state: info.state.toLowerCase() === "open" ? "open" : "closed",
|
|
387
|
+
isDraft: info.isDraft,
|
|
388
|
+
mergeable: normalizeMergeable(info.mergeable),
|
|
389
|
+
reviewStatus: getReviewStatus(info),
|
|
390
|
+
checkStatus: checkInfo.checkStatus,
|
|
391
|
+
checkSummary: checkInfo.checkSummary,
|
|
392
|
+
autoMergeEnabled: info.autoMergeRequest !== null,
|
|
393
|
+
} satisfies PullRequestMergeInfo
|
|
394
|
+
})
|
|
395
|
+
|
|
396
|
+
const mergePullRequest = Effect.fn("GitHubService.mergePullRequest")(function*(repository: string, number: number, action: PullRequestMergeAction) {
|
|
397
|
+
const base = ["pr", "merge", String(number), "--repo", repository] as const
|
|
398
|
+
if (action === "disable-auto") {
|
|
399
|
+
yield* command.run("gh", [...base, "--disable-auto"])
|
|
400
|
+
} else if (action === "auto") {
|
|
401
|
+
yield* command.run("gh", [...base, "--squash", "--auto", "--delete-branch"])
|
|
402
|
+
} else if (action === "admin") {
|
|
403
|
+
yield* command.run("gh", [...base, "--squash", "--admin", "--delete-branch"])
|
|
404
|
+
} else {
|
|
405
|
+
yield* command.run("gh", [...base, "--squash", "--delete-branch"])
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
|
|
265
409
|
const toggleDraftStatus = Effect.fn("GitHubService.toggleDraftStatus")(function*(repository: string, number: number, isDraft: boolean) {
|
|
266
410
|
yield* command.run("gh", ["pr", "ready", String(number), "--repo", repository, ...(isDraft ? [] : ["--undo"])])
|
|
267
411
|
})
|
|
@@ -283,8 +427,11 @@ export class GitHubService extends Context.Service<GitHubService, {
|
|
|
283
427
|
|
|
284
428
|
return GitHubService.of({
|
|
285
429
|
listOpenPullRequests,
|
|
430
|
+
listOpenPullRequestDetails,
|
|
286
431
|
getAuthenticatedUser,
|
|
287
432
|
getPullRequestDiff,
|
|
433
|
+
getPullRequestMergeInfo,
|
|
434
|
+
mergePullRequest,
|
|
288
435
|
toggleDraftStatus,
|
|
289
436
|
listRepoLabels,
|
|
290
437
|
addPullRequestLabel,
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { colors } from "./colors.js"
|
|
2
|
+
import { TextLine } from "./primitives.js"
|
|
3
|
+
|
|
4
|
+
export interface RetryProgress {
|
|
5
|
+
readonly attempt: number
|
|
6
|
+
readonly max: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const FooterHints = ({
|
|
10
|
+
filterEditing,
|
|
11
|
+
showFilterClear,
|
|
12
|
+
detailFullView,
|
|
13
|
+
diffFullView,
|
|
14
|
+
hasSelection,
|
|
15
|
+
hasError,
|
|
16
|
+
isLoading,
|
|
17
|
+
loadingIndicator,
|
|
18
|
+
retryProgress,
|
|
19
|
+
}: {
|
|
20
|
+
filterEditing: boolean
|
|
21
|
+
showFilterClear: boolean
|
|
22
|
+
detailFullView: boolean
|
|
23
|
+
diffFullView: boolean
|
|
24
|
+
hasSelection: boolean
|
|
25
|
+
hasError: boolean
|
|
26
|
+
isLoading: boolean
|
|
27
|
+
loadingIndicator: string
|
|
28
|
+
retryProgress: RetryProgress | null
|
|
29
|
+
}) => {
|
|
30
|
+
if (filterEditing) {
|
|
31
|
+
return (
|
|
32
|
+
<TextLine>
|
|
33
|
+
<span fg={colors.count}>search</span>
|
|
34
|
+
<span fg={colors.muted}> typing </span>
|
|
35
|
+
<span fg={colors.count}>↑↓</span>
|
|
36
|
+
<span fg={colors.muted}> move </span>
|
|
37
|
+
<span fg={colors.count}>enter</span>
|
|
38
|
+
<span fg={colors.muted}> apply </span>
|
|
39
|
+
<span fg={colors.count}>esc</span>
|
|
40
|
+
<span fg={colors.muted}> cancel </span>
|
|
41
|
+
<span fg={colors.count}>ctrl-u</span>
|
|
42
|
+
<span fg={colors.muted}> clear </span>
|
|
43
|
+
<span fg={colors.count}>ctrl-w</span>
|
|
44
|
+
<span fg={colors.muted}> word</span>
|
|
45
|
+
</TextLine>
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (diffFullView) {
|
|
50
|
+
return (
|
|
51
|
+
<TextLine>
|
|
52
|
+
<span fg={colors.count}>esc</span>
|
|
53
|
+
<span fg={colors.muted}> back </span>
|
|
54
|
+
<span fg={colors.count}>v</span>
|
|
55
|
+
<span fg={colors.muted}> view </span>
|
|
56
|
+
<span fg={colors.count}>w</span>
|
|
57
|
+
<span fg={colors.muted}> wrap </span>
|
|
58
|
+
<span fg={colors.count}>[]</span>
|
|
59
|
+
<span fg={colors.muted}> files </span>
|
|
60
|
+
<span fg={colors.count}>r</span>
|
|
61
|
+
<span fg={colors.muted}> reload </span>
|
|
62
|
+
<span fg={colors.count}>o</span>
|
|
63
|
+
<span fg={colors.muted}> open </span>
|
|
64
|
+
<span fg={colors.count}>q</span>
|
|
65
|
+
<span fg={colors.muted}> quit</span>
|
|
66
|
+
</TextLine>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (detailFullView) {
|
|
71
|
+
return (
|
|
72
|
+
<TextLine>
|
|
73
|
+
<span fg={colors.count}>esc</span>
|
|
74
|
+
<span fg={colors.muted}> back </span>
|
|
75
|
+
<span fg={colors.count}>o</span>
|
|
76
|
+
<span fg={colors.muted}> open </span>
|
|
77
|
+
<span fg={colors.count}>y</span>
|
|
78
|
+
<span fg={colors.muted}> copy </span>
|
|
79
|
+
<span fg={colors.count}>q</span>
|
|
80
|
+
<span fg={colors.muted}> quit</span>
|
|
81
|
+
</TextLine>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<TextLine>
|
|
87
|
+
<span fg={colors.count}>/</span>
|
|
88
|
+
<span fg={colors.muted}> filter </span>
|
|
89
|
+
{showFilterClear ? (
|
|
90
|
+
<>
|
|
91
|
+
<span fg={colors.count}>esc</span>
|
|
92
|
+
<span fg={colors.muted}> clear </span>
|
|
93
|
+
</>
|
|
94
|
+
) : null}
|
|
95
|
+
{retryProgress ? (
|
|
96
|
+
<>
|
|
97
|
+
<span fg={colors.status.pending}>retry</span>
|
|
98
|
+
<span fg={colors.muted}> {retryProgress.attempt}/{retryProgress.max} </span>
|
|
99
|
+
</>
|
|
100
|
+
) : isLoading ? (
|
|
101
|
+
<>
|
|
102
|
+
<span fg={colors.status.pending}>{loadingIndicator}</span>
|
|
103
|
+
<span fg={colors.muted}> loading </span>
|
|
104
|
+
</>
|
|
105
|
+
) : null}
|
|
106
|
+
<span fg={colors.count}>r</span>
|
|
107
|
+
<span fg={colors.muted}>{hasError ? " retry " : " ref "}</span>
|
|
108
|
+
{hasSelection ? (
|
|
109
|
+
<>
|
|
110
|
+
<span fg={colors.count}>↑↓</span>
|
|
111
|
+
<span fg={colors.muted}> move </span>
|
|
112
|
+
</>
|
|
113
|
+
) : null}
|
|
114
|
+
{hasSelection && detailFullView ? (
|
|
115
|
+
<>
|
|
116
|
+
<span fg={colors.count}>esc</span>
|
|
117
|
+
<span fg={colors.muted}> back </span>
|
|
118
|
+
</>
|
|
119
|
+
) : hasSelection ? (
|
|
120
|
+
<>
|
|
121
|
+
<span fg={colors.count}>enter</span>
|
|
122
|
+
<span fg={colors.muted}> expand </span>
|
|
123
|
+
</>
|
|
124
|
+
) : null}
|
|
125
|
+
{hasSelection ? (
|
|
126
|
+
<>
|
|
127
|
+
<span fg={colors.count}>d</span>
|
|
128
|
+
<span fg={colors.muted}> draft </span>
|
|
129
|
+
<span fg={colors.count}>p</span>
|
|
130
|
+
<span fg={colors.muted}> diff </span>
|
|
131
|
+
<span fg={colors.count}>l</span>
|
|
132
|
+
<span fg={colors.muted}> labels </span>
|
|
133
|
+
<span fg={colors.count}>M</span>
|
|
134
|
+
<span fg={colors.muted}> merge </span>
|
|
135
|
+
<span fg={colors.count}>o</span>
|
|
136
|
+
<span fg={colors.muted}> open </span>
|
|
137
|
+
<span fg={colors.count}>y</span>
|
|
138
|
+
<span fg={colors.muted}> copy </span>
|
|
139
|
+
</>
|
|
140
|
+
) : null}
|
|
141
|
+
<span fg={colors.count}>q</span>
|
|
142
|
+
<span fg={colors.muted}> quit</span>
|
|
143
|
+
</TextLine>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import type { PullRequestItem } from "../domain.js"
|
|
3
|
+
import { daysOpen } from "../date.js"
|
|
4
|
+
import { colors } from "./colors.js"
|
|
5
|
+
import { fitCell, PlainLine, SectionTitle, TextLine } from "./primitives.js"
|
|
6
|
+
import { checkLabel, repoColor, reviewIcon, statusColor } from "./pullRequests.js"
|
|
7
|
+
|
|
8
|
+
export type LoadStatus = "loading" | "ready" | "error"
|
|
9
|
+
export type PullRequestGroups = Array<[string, PullRequestItem[]]>
|
|
10
|
+
|
|
11
|
+
const GROUP_ICON = "◆"
|
|
12
|
+
|
|
13
|
+
const getRowLayout = (contentWidth: number, numberWidth = 6) => {
|
|
14
|
+
const reviewWidth = 1
|
|
15
|
+
const checkWidth = 6
|
|
16
|
+
const ageWidth = 4
|
|
17
|
+
const fixedWidth = reviewWidth + 1 + numberWidth + 1 + checkWidth + ageWidth
|
|
18
|
+
const titleWidth = Math.max(8, contentWidth - fixedWidth)
|
|
19
|
+
return { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const groupNumberWidth = (pullRequests: readonly PullRequestItem[]) => {
|
|
23
|
+
if (pullRequests.length === 0) return 4
|
|
24
|
+
const maxLen = Math.max(...pullRequests.map((pr) => String(pr.number).length))
|
|
25
|
+
return maxLen + 1
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const GroupTitle = ({ label, color }: { label: string; color: string }) => (
|
|
29
|
+
<TextLine>
|
|
30
|
+
<span fg={color}>{GROUP_ICON} </span>
|
|
31
|
+
<span fg={color} attributes={TextAttributes.BOLD}>{label}</span>
|
|
32
|
+
</TextLine>
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const PullRequestRow = ({
|
|
36
|
+
pullRequest,
|
|
37
|
+
selected,
|
|
38
|
+
contentWidth,
|
|
39
|
+
numWidth,
|
|
40
|
+
onSelect,
|
|
41
|
+
}: {
|
|
42
|
+
pullRequest: PullRequestItem
|
|
43
|
+
selected: boolean
|
|
44
|
+
contentWidth: number
|
|
45
|
+
numWidth: number
|
|
46
|
+
onSelect: () => void
|
|
47
|
+
}) => {
|
|
48
|
+
const checkText = checkLabel(pullRequest)?.replace(/^checks\s+/, "") ?? ""
|
|
49
|
+
const ageText = `${daysOpen(pullRequest.createdAt)}d`
|
|
50
|
+
const { reviewWidth, checkWidth, ageWidth, numberWidth, titleWidth } = getRowLayout(contentWidth, numWidth)
|
|
51
|
+
const rowWidth = reviewWidth + 1 + numberWidth + 1 + titleWidth + checkWidth + ageWidth
|
|
52
|
+
const fillerWidth = Math.max(0, contentWidth - rowWidth)
|
|
53
|
+
const indicatorColor = pullRequest.autoMergeEnabled ? colors.accent : statusColor(pullRequest.reviewStatus)
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<box height={1} onMouseDown={onSelect}>
|
|
57
|
+
<TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
|
|
58
|
+
<span fg={indicatorColor}>{fitCell(reviewIcon(pullRequest), reviewWidth)}</span>
|
|
59
|
+
<span> </span>
|
|
60
|
+
<span fg={selected ? colors.accent : colors.count}>{fitCell(`#${pullRequest.number}`, numberWidth, "right")}</span>
|
|
61
|
+
<span> </span>
|
|
62
|
+
<span>{fitCell(pullRequest.title, titleWidth)}</span>
|
|
63
|
+
<span fg={statusColor(pullRequest.checkStatus)}>{fitCell(checkText, checkWidth, "right")}</span>
|
|
64
|
+
<span fg={colors.muted}>{fitCell(ageText, ageWidth, "right")}</span>
|
|
65
|
+
{fillerWidth > 0 ? <span>{" ".repeat(fillerWidth)}</span> : null}
|
|
66
|
+
</TextLine>
|
|
67
|
+
</box>
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export const PullRequestList = ({
|
|
72
|
+
groups,
|
|
73
|
+
selectedUrl,
|
|
74
|
+
status,
|
|
75
|
+
error,
|
|
76
|
+
contentWidth,
|
|
77
|
+
filterText,
|
|
78
|
+
showFilterBar,
|
|
79
|
+
isFilterEditing,
|
|
80
|
+
onSelectPullRequest,
|
|
81
|
+
}: {
|
|
82
|
+
groups: PullRequestGroups
|
|
83
|
+
selectedUrl: string | null
|
|
84
|
+
status: LoadStatus
|
|
85
|
+
error: string | null
|
|
86
|
+
contentWidth: number
|
|
87
|
+
filterText: string
|
|
88
|
+
showFilterBar: boolean
|
|
89
|
+
isFilterEditing: boolean
|
|
90
|
+
onSelectPullRequest: (url: string) => void
|
|
91
|
+
}) => {
|
|
92
|
+
const itemCount = groups.reduce((count, [, pullRequests]) => count + pullRequests.length, 0)
|
|
93
|
+
const emptyText = filterText.length > 0 ? "- No matching pull requests." : "- No open pull requests."
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<box flexDirection="column">
|
|
97
|
+
<SectionTitle title="PULL REQUESTS" />
|
|
98
|
+
{showFilterBar ? (
|
|
99
|
+
<TextLine>
|
|
100
|
+
<span fg={colors.count}>/</span>
|
|
101
|
+
<span fg={colors.muted}> </span>
|
|
102
|
+
<span fg={isFilterEditing ? colors.text : colors.count}>{filterText.length > 0 ? filterText : "type to filter..."}</span>
|
|
103
|
+
</TextLine>
|
|
104
|
+
) : null}
|
|
105
|
+
{status === "loading" && itemCount === 0 ? <PlainLine text="- Loading pull requests..." fg={colors.muted} /> : null}
|
|
106
|
+
{status === "error" ? <PlainLine text={`- ${error ?? "Could not load pull requests."}`} fg={colors.error} /> : null}
|
|
107
|
+
{status === "ready" && itemCount === 0 ? <PlainLine text={emptyText} fg={colors.muted} /> : null}
|
|
108
|
+
{groups.map(([repo, pullRequests]) => {
|
|
109
|
+
const numWidth = groupNumberWidth(pullRequests)
|
|
110
|
+
return (
|
|
111
|
+
<box key={repo} flexDirection="column">
|
|
112
|
+
<GroupTitle label={repo} color={repoColor(repo)} />
|
|
113
|
+
{pullRequests.map((pullRequest) => (
|
|
114
|
+
<PullRequestRow
|
|
115
|
+
key={pullRequest.url}
|
|
116
|
+
pullRequest={pullRequest}
|
|
117
|
+
selected={pullRequest.url === selectedUrl}
|
|
118
|
+
contentWidth={contentWidth}
|
|
119
|
+
numWidth={numWidth}
|
|
120
|
+
onSelect={() => onSelectPullRequest(pullRequest.url)}
|
|
121
|
+
/>
|
|
122
|
+
))}
|
|
123
|
+
</box>
|
|
124
|
+
)
|
|
125
|
+
})}
|
|
126
|
+
</box>
|
|
127
|
+
)
|
|
128
|
+
}
|