@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 +1 -1
- package/src/App.tsx +129 -42
- package/src/index.tsx +7 -1
- package/src/services/GitHubService.ts +125 -66
package/package.json
CHANGED
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
|
|
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
|
|
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 =
|
|
445
|
-
let additions =
|
|
515
|
+
let deletions: number[] = []
|
|
516
|
+
let additions: number[] = []
|
|
446
517
|
|
|
447
518
|
const flushChangeBlock = () => {
|
|
448
|
-
if (deletions === 0 && additions === 0) return
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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
|
|
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(" ")}
|
|
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}>
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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 (
|
|
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
|
|
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
|
-
<
|
|
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
|
|
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
|
-
|
|
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
|
|
6
|
+
interface GitHubPullRequestNode {
|
|
7
7
|
readonly number: number
|
|
8
8
|
readonly title: string
|
|
9
9
|
readonly body: string
|
|
10
|
-
readonly labels:
|
|
11
|
-
readonly
|
|
12
|
-
|
|
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
|
|
20
|
-
readonly
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
44
|
-
|
|
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:
|
|
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
|
|
77
|
-
if (
|
|
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
|
|
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 (
|
|
161
|
+
if (status === "completed") {
|
|
97
162
|
completed += 1
|
|
98
163
|
} else {
|
|
99
164
|
pending = true
|
|
100
165
|
}
|
|
101
166
|
|
|
102
|
-
if (
|
|
167
|
+
if (conclusion === "success" || conclusion === "neutral" || conclusion === "skipped") {
|
|
103
168
|
successful += 1
|
|
104
|
-
} else if (
|
|
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}/${
|
|
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}/${
|
|
179
|
+
return { checkStatus: "failing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
|
|
115
180
|
}
|
|
116
181
|
|
|
117
|
-
return { checkStatus: "passing", checkSummary: `checks ${successful}/${
|
|
182
|
+
return { checkStatus: "passing", checkSummary: `checks ${successful}/${contexts.length}`, checks }
|
|
118
183
|
}
|
|
119
184
|
|
|
120
|
-
const parsePullRequest = (
|
|
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
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
})
|