@kitlangton/ghui 0.1.7 → 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/src/config.ts CHANGED
@@ -1,9 +1,18 @@
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
- }
1
+ import { Config, Effect } from "effect"
5
2
 
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
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"
@@ -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 GitHubPullRequestNode {
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: GitHubPullRequestNode): PullRequestItem["reviewStatus"] => {
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 getCheckInfo = (item: GitHubPullRequestNode): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
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
+ }