@kitlangton/ghui 0.1.4 → 0.1.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kitlangton/ghui",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Terminal UI for GitHub pull requests",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/App.tsx CHANGED
@@ -435,23 +435,104 @@ const pullRequestDiffKey = (pullRequest: PullRequestItem) => `${pullRequest.repo
435
435
 
436
436
  const diffStatText = (pullRequest: PullRequestItem) => {
437
437
  const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
438
- return `+${pullRequest.additions} -${pullRequest.deletions} ${files}`
438
+ return [
439
+ pullRequest.additions > 0 ? `+${pullRequest.additions}` : null,
440
+ pullRequest.deletions > 0 ? `-${pullRequest.deletions}` : null,
441
+ files,
442
+ ].filter((part): part is string => part !== null).join(" ")
439
443
  }
440
444
 
441
- const patchRenderableLineCount = (patch: string, view: "unified" | "split") => {
445
+ const DiffStats = ({ pullRequest }: { pullRequest: PullRequestItem }) => {
446
+ const files = pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`
447
+ type Part = { key: string; text: string; color: string }
448
+ const rawParts: Array<Part | null> = [
449
+ pullRequest.additions > 0 ? { key: "additions", text: `+${pullRequest.additions}`, color: colors.status.passing } : null,
450
+ pullRequest.deletions > 0 ? { key: "deletions", text: `-${pullRequest.deletions}`, color: colors.status.failing } : null,
451
+ { key: "files", text: files, color: colors.muted },
452
+ ]
453
+ const parts = rawParts.filter((part): part is Part => part !== null)
454
+
455
+ return (
456
+ <>
457
+ {parts.map((part, index) => (
458
+ <Fragment key={part.key}>
459
+ {index > 0 ? <span fg={colors.muted}> </span> : null}
460
+ <span fg={part.color}>{part.text}</span>
461
+ </Fragment>
462
+ ))}
463
+ </>
464
+ )
465
+ }
466
+
467
+ const estimatedWrappedLineCount = (text: string, width: number, wrapMode: "none" | "word") => {
468
+ if (wrapMode === "none") return 1
469
+ return Math.max(1, Math.ceil(Bun.stringWidth(text) / Math.max(1, width)))
470
+ }
471
+
472
+ const patchLineNumberGutterWidth = (lines: readonly string[]) => {
473
+ let maxLineNumber = 1
474
+ let hasSigns = false
475
+ let oldLine = 0
476
+ let newLine = 0
477
+
478
+ for (const line of lines) {
479
+ const hunk = line.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/)
480
+ if (hunk) {
481
+ oldLine = Number(hunk[1])
482
+ newLine = Number(hunk[2])
483
+ maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
484
+ continue
485
+ }
486
+
487
+ const firstChar = line[0]
488
+ if (firstChar === "-") {
489
+ hasSigns = true
490
+ maxLineNumber = Math.max(maxLineNumber, oldLine)
491
+ oldLine++
492
+ } else if (firstChar === "+") {
493
+ hasSigns = true
494
+ maxLineNumber = Math.max(maxLineNumber, newLine)
495
+ newLine++
496
+ } else if (firstChar === " ") {
497
+ maxLineNumber = Math.max(maxLineNumber, oldLine, newLine)
498
+ oldLine++
499
+ newLine++
500
+ }
501
+ }
502
+
503
+ const digits = Math.floor(Math.log10(maxLineNumber)) + 1
504
+ return Math.max(3, digits + 2) + (hasSigns ? 2 : 0)
505
+ }
506
+
507
+ const patchRenderableLineCount = (patch: string, view: "unified" | "split", wrapMode: "none" | "word", width: number) => {
508
+ const lines = patch.split("\n")
509
+ const lineNumberGutterWidth = patchLineNumberGutterWidth(lines)
510
+ const splitPaneWidth = Math.max(1, Math.floor(width / 2) - lineNumberGutterWidth)
511
+ const unifiedPaneWidth = Math.max(1, width - lineNumberGutterWidth)
512
+ const contentWidth = view === "split" ? splitPaneWidth : unifiedPaneWidth
442
513
  let count = 0
443
514
  let inHunk = false
444
- let deletions = 0
445
- let additions = 0
515
+ let deletions: number[] = []
516
+ let additions: number[] = []
446
517
 
447
518
  const flushChangeBlock = () => {
448
- if (deletions === 0 && additions === 0) return
449
- count += view === "split" ? Math.max(deletions, additions) : deletions + additions
450
- deletions = 0
451
- additions = 0
519
+ if (deletions.length === 0 && additions.length === 0) return
520
+ if (view === "split") {
521
+ const rows = Math.max(deletions.length, additions.length)
522
+ for (let index = 0; index < rows; index++) {
523
+ const deletionCount = index < deletions.length ? deletions[index]! : 1
524
+ const additionCount = index < additions.length ? additions[index]! : 1
525
+ count += Math.max(deletionCount, additionCount)
526
+ }
527
+ } else {
528
+ for (const deletion of deletions) count += deletion
529
+ for (const addition of additions) count += addition
530
+ }
531
+ deletions = []
532
+ additions = []
452
533
  }
453
534
 
454
- for (const line of patch.split("\n")) {
535
+ for (const line of lines) {
455
536
  if (line.startsWith("@@")) {
456
537
  flushChangeBlock()
457
538
  inHunk = true
@@ -464,18 +545,18 @@ const patchRenderableLineCount = (patch: string, view: "unified" | "split") => {
464
545
  if (firstChar === "\\") continue
465
546
 
466
547
  if (firstChar === "-") {
467
- deletions++
548
+ deletions.push(estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode))
468
549
  continue
469
550
  }
470
551
 
471
552
  if (firstChar === "+") {
472
- additions++
553
+ additions.push(estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode))
473
554
  continue
474
555
  }
475
556
 
476
557
  if (firstChar === " ") {
477
558
  flushChangeBlock()
478
- count++
559
+ count += estimatedWrappedLineCount(line.slice(1), contentWidth, wrapMode)
479
560
  }
480
561
  }
481
562
 
@@ -709,10 +790,6 @@ const FooterHints = ({
709
790
  <TextLine>
710
791
  <span fg={colors.count}>esc</span>
711
792
  <span fg={colors.muted}> back </span>
712
- <span fg={colors.count}>j/k</span>
713
- <span fg={colors.muted}> scroll </span>
714
- <span fg={colors.count}>gg/G</span>
715
- <span fg={colors.muted}> top/bot </span>
716
793
  <span fg={colors.count}>v</span>
717
794
  <span fg={colors.muted}> view </span>
718
795
  <span fg={colors.count}>w</span>
@@ -734,12 +811,6 @@ const FooterHints = ({
734
811
  <TextLine>
735
812
  <span fg={colors.count}>esc</span>
736
813
  <span fg={colors.muted}> back </span>
737
- <span fg={colors.count}>j/k</span>
738
- <span fg={colors.muted}> scroll </span>
739
- <span fg={colors.count}>gg/G</span>
740
- <span fg={colors.muted}> top/bot </span>
741
- <span fg={colors.count}>ctrl-d/u</span>
742
- <span fg={colors.muted}> page </span>
743
814
  <span fg={colors.count}>o</span>
744
815
  <span fg={colors.muted}> open </span>
745
816
  <span fg={colors.count}>y</span>
@@ -1046,7 +1117,7 @@ const DetailHeader = ({
1046
1117
  const review = reviewLabel(pullRequest)
1047
1118
  const checks = pullRequest.checkSummary?.replace(/^checks\s+/, "")
1048
1119
  const statusParts = [review, checks].filter((part): part is string => Boolean(part))
1049
- const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
1120
+ const rightSide = statusParts.length > 0 ? `${statusParts.join(" ")} ${opened}` : opened
1050
1121
  const leftWidth = 1 + number.length + 1 + repo.length
1051
1122
  const gap = Math.max(2, contentWidth - leftWidth - rightSide.length)
1052
1123
 
@@ -1058,7 +1129,7 @@ const DetailHeader = ({
1058
1129
  {review ? <span fg={statusColor(pullRequest.reviewStatus)}>{review}</span> : null}
1059
1130
  {review && checks ? <span fg={colors.muted}> </span> : null}
1060
1131
  {checks ? <span fg={statusColor(pullRequest.checkStatus)}>{checks}</span> : null}
1061
- {statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
1132
+ {statusParts.length > 0 ? <span fg={colors.muted}> </span> : null}
1062
1133
  <span fg={colors.muted}>{opened}</span>
1063
1134
  </TextLine>
1064
1135
  )
@@ -1080,10 +1151,7 @@ const DetailHeader = ({
1080
1151
  {showStats ? (
1081
1152
  <>
1082
1153
  <span fg={colors.muted}>{" ".repeat(statsGap)}</span>
1083
- <span fg={colors.status.passing}>+{pullRequest.additions}</span>
1084
- <span fg={colors.muted}> </span>
1085
- <span fg={colors.status.failing}>-{pullRequest.deletions}</span>
1086
- <span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
1154
+ <DiffStats pullRequest={pullRequest} />
1087
1155
  </>
1088
1156
  ) : null}
1089
1157
  </TextLine>
@@ -1251,6 +1319,14 @@ const PullRequestDiffPane = ({
1251
1319
  loadingIndicator: string
1252
1320
  scrollRef: React.Ref<ScrollBoxRenderable>
1253
1321
  }) => {
1322
+ const readyFiles = diffState?.status === "ready" ? diffState.files : []
1323
+ const safeIndex = readyFiles.length > 0 ? Math.max(0, Math.min(fileIndex, readyFiles.length - 1)) : 0
1324
+ const file = readyFiles[safeIndex] ?? null
1325
+ const diffHeight = useMemo(
1326
+ () => file ? patchRenderableLineCount(file.patch, view, wrapMode, paneWidth) : 1,
1327
+ [file?.patch, view, wrapMode, paneWidth],
1328
+ )
1329
+
1254
1330
  if (!pullRequest) {
1255
1331
  return <LoadingPane content={{ title: "No pull request selected", hint: "Press esc to go back" }} width={paneWidth} height={height} />
1256
1332
  }
@@ -1268,10 +1344,7 @@ const PullRequestDiffPane = ({
1268
1344
  <span fg={colors.count}>#{pullRequest.number}</span>
1269
1345
  <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
1270
1346
  <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
1271
- <span fg={colors.status.passing}>+{pullRequest.additions}</span>
1272
- <span fg={colors.muted}> </span>
1273
- <span fg={colors.status.failing}>-{pullRequest.deletions}</span>
1274
- <span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
1347
+ <DiffStats pullRequest={pullRequest} />
1275
1348
  </TextLine>
1276
1349
  </box>
1277
1350
  <Divider width={paneWidth} />
@@ -1292,15 +1365,12 @@ const PullRequestDiffPane = ({
1292
1365
  )
1293
1366
  }
1294
1367
 
1295
- if (diffState.files.length === 0) {
1368
+ if (readyFiles.length === 0 || !file) {
1296
1369
  return <LoadingPane content={{ title: "No diff", hint: "This PR has no patch contents" }} width={paneWidth} height={height} />
1297
1370
  }
1298
1371
 
1299
- const safeIndex = Math.max(0, Math.min(fileIndex, diffState.files.length - 1))
1300
- const file = diffState.files[safeIndex]!
1301
- const fileCounter = `${safeIndex + 1}/${diffState.files.length}`
1372
+ const fileCounter = `${safeIndex + 1}/${readyFiles.length}`
1302
1373
  const fileNameWidth = Math.max(8, headerWidth - fileCounter.length - 2)
1303
- const diffHeight = patchRenderableLineCount(file.patch, view)
1304
1374
 
1305
1375
  return (
1306
1376
  <box height={height} flexDirection="column">
@@ -1309,10 +1379,7 @@ const PullRequestDiffPane = ({
1309
1379
  <span fg={colors.count}>#{pullRequest.number}</span>
1310
1380
  <span fg={colors.muted}> {shortRepoName(pullRequest.repository)}</span>
1311
1381
  <span fg={colors.muted}>{" ".repeat(headerGap)}</span>
1312
- <span fg={colors.status.passing}>+{pullRequest.additions}</span>
1313
- <span fg={colors.muted}> </span>
1314
- <span fg={colors.status.failing}>-{pullRequest.deletions}</span>
1315
- <span fg={colors.muted}> {pullRequest.changedFiles === 1 ? "1 file" : `${pullRequest.changedFiles} files`}</span>
1382
+ <DiffStats pullRequest={pullRequest} />
1316
1383
  </TextLine>
1317
1384
  </box>
1318
1385
  <box height={1} paddingLeft={1} paddingRight={1}>
@@ -1502,6 +1569,7 @@ export const App = () => {
1502
1569
  const wideBodyHeight = Math.max(8, (height ?? 24) - 4)
1503
1570
  const noticeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1504
1571
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1572
+ const diffPrefetchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
1505
1573
  const detailScrollRef = useRef<ScrollBoxRenderable | null>(null)
1506
1574
  const diffScrollRef = useRef<ScrollBoxRenderable | null>(null)
1507
1575
  const headerFooterWidth = Math.max(24, contentWidth - 2)
@@ -1523,6 +1591,9 @@ export const App = () => {
1523
1591
  if (pendingGTimeoutRef.current !== null) {
1524
1592
  clearTimeout(pendingGTimeoutRef.current)
1525
1593
  }
1594
+ if (diffPrefetchTimeoutRef.current !== null) {
1595
+ clearTimeout(diffPrefetchTimeoutRef.current)
1596
+ }
1526
1597
  }, [])
1527
1598
 
1528
1599
  const pullRequestLoad = AsyncResult.getOrElse(pullRequestResult, () => null)
@@ -1653,6 +1724,22 @@ export const App = () => {
1653
1724
  })
1654
1725
  }
1655
1726
 
1727
+ useEffect(() => {
1728
+ if (!selectedPullRequest || diffFullView) return
1729
+ if (diffPrefetchTimeoutRef.current !== null) {
1730
+ clearTimeout(diffPrefetchTimeoutRef.current)
1731
+ }
1732
+ diffPrefetchTimeoutRef.current = setTimeout(() => {
1733
+ loadPullRequestDiff(selectedPullRequest)
1734
+ }, 250)
1735
+ return () => {
1736
+ if (diffPrefetchTimeoutRef.current !== null) {
1737
+ clearTimeout(diffPrefetchTimeoutRef.current)
1738
+ diffPrefetchTimeoutRef.current = null
1739
+ }
1740
+ }
1741
+ }, [selectedIndex, selectedPullRequest?.url, diffFullView])
1742
+
1656
1743
  const openDiffView = () => {
1657
1744
  if (!selectedPullRequest) return
1658
1745
  setDiffFullView(true)
@@ -1883,7 +1970,7 @@ export const App = () => {
1883
1970
  return
1884
1971
  }
1885
1972
 
1886
- // Fullscreen detail mode: scroll with j/k, Ctrl-D/U, exit with Escape/Enter
1973
+ // Fullscreen detail mode handles its own navigation keys.
1887
1974
  if (detailFullView) {
1888
1975
  if (key.name === "escape" || (key.name === "return" || key.name === "enter")) {
1889
1976
  setDetailFullView(false)
package/src/index.tsx CHANGED
@@ -5,7 +5,13 @@ import { RegistryProvider } from "@effect/atom-react"
5
5
  import { createRoot } from "@opentui/react"
6
6
  import { App } from "./App.js"
7
7
 
8
- const renderer = await createCliRenderer({ exitOnCtrlC: false })
8
+ process.env.OTUI_USE_ALTERNATE_SCREEN = "true"
9
+
10
+ const renderer = await createCliRenderer({
11
+ exitOnCtrlC: false,
12
+ screenMode: "alternate-screen",
13
+ externalOutputMode: "passthrough",
14
+ })
9
15
 
10
16
  createRoot(renderer).render(
11
17
  <RegistryProvider>
@@ -3,52 +3,105 @@ import { config } from "../config.js"
3
3
  import type { CheckItem, PullRequestItem } from "../domain.js"
4
4
  import { CommandRunner, type CommandError, type JsonParseError } from "./CommandRunner.js"
5
5
 
6
- interface GitHubListPullRequest {
6
+ interface GitHubPullRequestNode {
7
7
  readonly number: number
8
8
  readonly title: string
9
9
  readonly body: string
10
- readonly labels: readonly {
11
- readonly name: string
12
- readonly color?: string | null
13
- }[]
10
+ readonly labels: {
11
+ readonly nodes: readonly {
12
+ readonly name: string
13
+ readonly color?: string | null
14
+ }[]
15
+ }
14
16
  readonly additions: number
15
17
  readonly deletions: number
16
18
  readonly changedFiles: number
17
19
  readonly isDraft: boolean
18
- readonly reviewDecision: string
19
- readonly statusCheckRollup: readonly {
20
- readonly name?: string | null
21
- readonly context?: string | null
22
- readonly status?: string | null
23
- readonly conclusion?: string | null
24
- readonly state?: string | null
25
- }[]
20
+ readonly reviewDecision: string | null
21
+ readonly statusCheckRollup?: {
22
+ readonly contexts: {
23
+ readonly nodes: readonly GraphQLCheckContext[]
24
+ }
25
+ } | null
26
26
  readonly state: string
27
27
  readonly createdAt: string
28
28
  readonly closedAt?: string | null
29
29
  readonly url: string
30
- }
31
-
32
- interface GitHubSearchPullRequest {
33
- readonly number: number
34
30
  readonly repository: {
35
31
  readonly nameWithOwner: string
36
32
  }
37
33
  }
38
34
 
35
+ type GraphQLCheckContext =
36
+ | {
37
+ readonly __typename: "CheckRun"
38
+ readonly name?: string | null
39
+ readonly status?: string | null
40
+ readonly conclusion?: string | null
41
+ }
42
+ | {
43
+ readonly __typename: "StatusContext"
44
+ readonly context?: string | null
45
+ readonly state?: string | null
46
+ }
47
+
48
+ interface GraphQLSearchResponse {
49
+ readonly data: {
50
+ readonly search: {
51
+ readonly nodes: readonly (GitHubPullRequestNode | null)[]
52
+ readonly pageInfo: {
53
+ readonly hasNextPage: boolean
54
+ readonly endCursor: string | null
55
+ }
56
+ }
57
+ }
58
+ }
59
+
39
60
  interface GitHubViewer {
40
61
  readonly login: string
41
62
  }
42
63
 
43
- const searchJsonFields = "repository,number"
44
- const detailJsonFields = "number,title,body,labels,additions,deletions,changedFiles,isDraft,reviewDecision,statusCheckRollup,state,createdAt,closedAt,url"
64
+ const pullRequestSearchQuery = `
65
+ query PullRequests($searchQuery: String!, $first: Int!, $after: String) {
66
+ search(query: $searchQuery, type: ISSUE, first: $first, after: $after) {
67
+ nodes {
68
+ ... on PullRequest {
69
+ number
70
+ title
71
+ body
72
+ isDraft
73
+ reviewDecision
74
+ additions
75
+ deletions
76
+ changedFiles
77
+ state
78
+ createdAt
79
+ closedAt
80
+ url
81
+ repository { nameWithOwner }
82
+ labels(first: 20) { nodes { name color } }
83
+ statusCheckRollup {
84
+ contexts(first: 100) {
85
+ nodes {
86
+ __typename
87
+ ... on CheckRun { name status conclusion }
88
+ ... on StatusContext { context state }
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+ pageInfo { hasNextPage endCursor }
95
+ }
96
+ }
97
+ `
45
98
 
46
99
  const normalizeDate = (value: string | null | undefined) => {
47
100
  if (!value || value.startsWith("0001-01-01")) return null
48
101
  return new Date(value)
49
102
  }
50
103
 
51
- const getReviewStatus = (item: GitHubListPullRequest): PullRequestItem["reviewStatus"] => {
104
+ const getReviewStatus = (item: GitHubPullRequestNode): PullRequestItem["reviewStatus"] => {
52
105
  if (item.isDraft) return "draft"
53
106
  if (item.reviewDecision === "APPROVED") return "approved"
54
107
  if (item.reviewDecision === "CHANGES_REQUESTED") return "changes"
@@ -73,8 +126,22 @@ const normalizeCheckConclusion = (raw?: string | null): CheckItem["conclusion"]
73
126
  return null
74
127
  }
75
128
 
76
- const getCheckInfo = (item: GitHubListPullRequest): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
77
- if (item.statusCheckRollup.length === 0) {
129
+ const getContextStatus = (context: GraphQLCheckContext): CheckItem["status"] => {
130
+ if (context.__typename === "CheckRun") return normalizeCheckStatus(context.status)
131
+ if (context.state === "PENDING") return "in_progress"
132
+ return "completed"
133
+ }
134
+
135
+ const getContextConclusion = (context: GraphQLCheckContext): CheckItem["conclusion"] => {
136
+ if (context.__typename === "CheckRun") return normalizeCheckConclusion(context.conclusion)
137
+ if (context.state === "SUCCESS") return "success"
138
+ if (context.state === "FAILURE" || context.state === "ERROR") return "failure"
139
+ return null
140
+ }
141
+
142
+ const getCheckInfo = (item: GitHubPullRequestNode): Pick<PullRequestItem, "checkStatus" | "checkSummary" | "checks"> => {
143
+ const contexts = item.statusCheckRollup?.contexts.nodes ?? []
144
+ if (contexts.length === 0) {
78
145
  return { checkStatus: "none", checkSummary: null, checks: [] }
79
146
  }
80
147
 
@@ -84,48 +151,46 @@ const getCheckInfo = (item: GitHubListPullRequest): Pick<PullRequestItem, "check
84
151
  let failing = false
85
152
  const checks: CheckItem[] = []
86
153
 
87
- for (const check of item.statusCheckRollup) {
88
- const name = check.name ?? check.context ?? "check"
154
+ for (const check of contexts) {
155
+ const name = check.__typename === "CheckRun" ? check.name ?? "check" : check.context ?? "check"
156
+ const status = getContextStatus(check)
157
+ const conclusion = getContextConclusion(check)
89
158
 
90
- checks.push({
91
- name,
92
- status: normalizeCheckStatus(check.status),
93
- conclusion: normalizeCheckConclusion(check.conclusion),
94
- })
159
+ checks.push({ name, status, conclusion })
95
160
 
96
- if (check.status === "COMPLETED") {
161
+ if (status === "completed") {
97
162
  completed += 1
98
163
  } else {
99
164
  pending = true
100
165
  }
101
166
 
102
- if (check.conclusion === "SUCCESS" || check.conclusion === "NEUTRAL" || check.conclusion === "SKIPPED") {
167
+ if (conclusion === "success" || conclusion === "neutral" || conclusion === "skipped") {
103
168
  successful += 1
104
- } else if (check.conclusion && check.conclusion !== "SUCCESS") {
169
+ } else if (conclusion) {
105
170
  failing = true
106
171
  }
107
172
  }
108
173
 
109
174
  if (pending) {
110
- return { checkStatus: "pending", checkSummary: `checks ${completed}/${item.statusCheckRollup.length}`, checks }
175
+ return { checkStatus: "pending", checkSummary: `checks ${completed}/${contexts.length}`, checks }
111
176
  }
112
177
 
113
178
  if (failing) {
114
- return { checkStatus: "failing", checkSummary: `checks ${successful}/${item.statusCheckRollup.length}`, checks }
179
+ return { checkStatus: "failing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
115
180
  }
116
181
 
117
- return { checkStatus: "passing", checkSummary: `checks ${successful}/${item.statusCheckRollup.length}`, checks }
182
+ return { checkStatus: "passing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
118
183
  }
119
184
 
120
- const parsePullRequest = (repository: string, item: GitHubListPullRequest): PullRequestItem => {
185
+ const parsePullRequest = (item: GitHubPullRequestNode): PullRequestItem => {
121
186
  const checkInfo = getCheckInfo(item)
122
187
 
123
188
  return {
124
- repository,
189
+ repository: item.repository.nameWithOwner,
125
190
  number: item.number,
126
191
  title: item.title,
127
192
  body: item.body,
128
- labels: item.labels.map((label) => ({
193
+ labels: item.labels.nodes.map((label) => ({
129
194
  name: label.name,
130
195
  color: label.color ? `#${label.color}` : null,
131
196
  })),
@@ -143,22 +208,7 @@ const parsePullRequest = (repository: string, item: GitHubListPullRequest): Pull
143
208
  }
144
209
  }
145
210
 
146
- const searchOpenArgs = (author: string) => [
147
- "search",
148
- "prs",
149
- "--author",
150
- author,
151
- "--state",
152
- "open",
153
- "--limit",
154
- String(config.prFetchLimit),
155
- "--sort",
156
- "created",
157
- "--order",
158
- "desc",
159
- "--json",
160
- searchJsonFields,
161
- ] as const
211
+ const searchQuery = (author: string) => `author:${author} is:pr is:open sort:created-desc`
162
212
 
163
213
  type GitHubError = CommandError | JsonParseError
164
214
 
@@ -177,18 +227,27 @@ export class GitHubService extends Context.Service<GitHubService, {
177
227
  const command = yield* CommandRunner
178
228
 
179
229
  const listOpenPullRequests = Effect.fn("GitHubService.listOpenPullRequests")(function*() {
180
- const searchResults = yield* command.runJson<readonly GitHubSearchPullRequest[]>("gh", [...searchOpenArgs(config.author)])
181
- const pullRequests = yield* Effect.forEach(
182
- searchResults,
183
- Effect.fn("GitHubService.loadPullRequestDetail")(function*(searchResult) {
184
- const repository = searchResult.repository.nameWithOwner
185
- const pullRequest = yield* command.runJson<GitHubListPullRequest>("gh", [
186
- "pr", "view", String(searchResult.number), "--repo", repository, "--json", detailJsonFields,
187
- ])
188
- return parsePullRequest(repository, pullRequest)
189
- }),
190
- { concurrency: 8 },
191
- )
230
+ const pullRequests: PullRequestItem[] = []
231
+ let cursor: string | null = null
232
+
233
+ while (pullRequests.length < config.prFetchLimit) {
234
+ const pageSize = Math.min(100, config.prFetchLimit - pullRequests.length)
235
+ const response: GraphQLSearchResponse = yield* command.runJson<GraphQLSearchResponse>("gh", [
236
+ "api", "graphql",
237
+ "-f", `query=${pullRequestSearchQuery}`,
238
+ "-F", `searchQuery=${searchQuery(config.author)}`,
239
+ "-F", `first=${pageSize}`,
240
+ ...(cursor ? ["-F", `after=${cursor}`] : []),
241
+ ])
242
+
243
+ for (const node of response.data.search.nodes) {
244
+ if (node) pullRequests.push(parsePullRequest(node))
245
+ }
246
+
247
+ if (!response.data.search.pageInfo.hasNextPage) break
248
+ cursor = response.data.search.pageInfo.endCursor
249
+ if (!cursor) break
250
+ }
192
251
 
193
252
  return pullRequests.sort((left, right) => right.createdAt.getTime() - left.createdAt.getTime())
194
253
  })