@kitlangton/ghui 0.1.7 → 0.1.9
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 +219 -1417
- package/src/config.ts +17 -8
- package/src/domain.ts +17 -0
- package/src/mergeActions.ts +90 -0
- package/src/observability.ts +46 -0
- package/src/services/CommandRunner.ts +6 -1
- package/src/services/GitHubService.ts +154 -14
- package/src/ui/DetailsPane.tsx +498 -0
- package/src/ui/FooterHints.tsx +145 -0
- package/src/ui/PullRequestDiffPane.tsx +148 -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 +249 -0
- package/src/ui/primitives.tsx +111 -0
- package/src/ui/pullRequests.ts +72 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import type { PullRequestLabel, PullRequestMergeInfo } from "../domain.js"
|
|
3
|
+
import { availableMergeActions } from "../mergeActions.js"
|
|
4
|
+
import { colors } from "./colors.js"
|
|
5
|
+
import { centerCell, Divider, fitCell, ModalFrame, PlainLine, TextLine } from "./primitives.js"
|
|
6
|
+
import { labelColor, shortRepoName } from "./pullRequests.js"
|
|
7
|
+
|
|
8
|
+
export interface LabelModalState {
|
|
9
|
+
readonly open: boolean
|
|
10
|
+
readonly repository: string | null
|
|
11
|
+
readonly query: string
|
|
12
|
+
readonly selectedIndex: number
|
|
13
|
+
readonly availableLabels: readonly PullRequestLabel[]
|
|
14
|
+
readonly loading: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MergeModalState {
|
|
18
|
+
readonly open: boolean
|
|
19
|
+
readonly repository: string | null
|
|
20
|
+
readonly number: number | null
|
|
21
|
+
readonly selectedIndex: number
|
|
22
|
+
readonly loading: boolean
|
|
23
|
+
readonly running: boolean
|
|
24
|
+
readonly info: PullRequestMergeInfo | null
|
|
25
|
+
readonly error: string | null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const initialLabelModalState: LabelModalState = {
|
|
29
|
+
open: false,
|
|
30
|
+
repository: null,
|
|
31
|
+
query: "",
|
|
32
|
+
selectedIndex: 0,
|
|
33
|
+
availableLabels: [],
|
|
34
|
+
loading: false,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const initialMergeModalState: MergeModalState = {
|
|
38
|
+
open: false,
|
|
39
|
+
repository: null,
|
|
40
|
+
number: null,
|
|
41
|
+
selectedIndex: 0,
|
|
42
|
+
loading: false,
|
|
43
|
+
running: false,
|
|
44
|
+
info: null,
|
|
45
|
+
error: null,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const mergeUnavailableReason = (info: PullRequestMergeInfo | null) => {
|
|
49
|
+
if (!info) return "Loading merge status from GitHub."
|
|
50
|
+
if (info.state !== "open") return "This pull request is not open."
|
|
51
|
+
if (info.isDraft) return "Draft pull requests cannot be merged."
|
|
52
|
+
if (info.mergeable === "conflicting") return "This branch has merge conflicts."
|
|
53
|
+
return "No merge actions are currently available."
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const LabelModal = ({
|
|
57
|
+
state,
|
|
58
|
+
currentLabels,
|
|
59
|
+
modalWidth,
|
|
60
|
+
modalHeight,
|
|
61
|
+
offsetLeft,
|
|
62
|
+
offsetTop,
|
|
63
|
+
loadingIndicator,
|
|
64
|
+
}: {
|
|
65
|
+
state: LabelModalState
|
|
66
|
+
currentLabels: readonly PullRequestLabel[]
|
|
67
|
+
modalWidth: number
|
|
68
|
+
modalHeight: number
|
|
69
|
+
offsetLeft: number
|
|
70
|
+
offsetTop: number
|
|
71
|
+
loadingIndicator: string
|
|
72
|
+
}) => {
|
|
73
|
+
const innerWidth = Math.max(16, modalWidth - 2)
|
|
74
|
+
const contentWidth = Math.max(14, innerWidth - 2)
|
|
75
|
+
const rowWidth = innerWidth
|
|
76
|
+
const currentNames = new Set(currentLabels.map((l) => l.name.toLowerCase()))
|
|
77
|
+
const filtered = state.availableLabels.filter((label) =>
|
|
78
|
+
state.query.length === 0 || label.name.toLowerCase().includes(state.query.toLowerCase()),
|
|
79
|
+
)
|
|
80
|
+
const maxVisible = Math.max(1, modalHeight - 7)
|
|
81
|
+
const labelMessageTopRows = Math.max(0, Math.floor((maxVisible - 1) / 2))
|
|
82
|
+
const labelMessageBottomRows = Math.max(0, maxVisible - labelMessageTopRows - 1)
|
|
83
|
+
const selectedIndex = filtered.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, filtered.length - 1))
|
|
84
|
+
const scrollStart = Math.min(
|
|
85
|
+
Math.max(0, filtered.length - maxVisible),
|
|
86
|
+
Math.max(0, selectedIndex - maxVisible + 1),
|
|
87
|
+
)
|
|
88
|
+
const visibleLabels = filtered.slice(scrollStart, scrollStart + maxVisible)
|
|
89
|
+
const title = state.repository ? `Labels ${shortRepoName(state.repository)}` : "Labels"
|
|
90
|
+
const countText = state.loading ? "loading" : `${filtered.length}/${state.availableLabels.length}`
|
|
91
|
+
const headerGap = Math.max(1, contentWidth - title.length - countText.length)
|
|
92
|
+
const queryText = state.query.length > 0 ? state.query : "type to filter labels"
|
|
93
|
+
const queryPrefix = state.query.length > 0 ? "/ " : "/ "
|
|
94
|
+
const queryWidth = Math.max(1, contentWidth - queryPrefix.length)
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
|
|
98
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
99
|
+
<TextLine>
|
|
100
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
|
|
101
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
102
|
+
<span fg={colors.muted}>{countText}</span>
|
|
103
|
+
</TextLine>
|
|
104
|
+
</box>
|
|
105
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
106
|
+
<TextLine>
|
|
107
|
+
<span fg={colors.count}>{queryPrefix}</span>
|
|
108
|
+
<span fg={state.query.length > 0 ? colors.text : colors.muted}>{fitCell(queryText, queryWidth)}</span>
|
|
109
|
+
</TextLine>
|
|
110
|
+
</box>
|
|
111
|
+
<Divider width={innerWidth} />
|
|
112
|
+
<box height={maxVisible} flexDirection="column">
|
|
113
|
+
{state.loading ? (
|
|
114
|
+
<>
|
|
115
|
+
{Array.from({ length: labelMessageTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
|
|
116
|
+
<PlainLine text={centerCell(`${loadingIndicator} Loading labels`, rowWidth)} fg={colors.muted} />
|
|
117
|
+
{Array.from({ length: labelMessageBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
|
|
118
|
+
</>
|
|
119
|
+
) : visibleLabels.length === 0 ? (
|
|
120
|
+
<>
|
|
121
|
+
{Array.from({ length: labelMessageTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
|
|
122
|
+
<PlainLine text={centerCell(state.query.length > 0 ? "No matching labels" : "No labels found", rowWidth)} fg={colors.muted} />
|
|
123
|
+
{Array.from({ length: labelMessageBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
|
|
124
|
+
</>
|
|
125
|
+
) : (
|
|
126
|
+
visibleLabels.map((label, index) => {
|
|
127
|
+
const actualIndex = scrollStart + index
|
|
128
|
+
const isActive = currentNames.has(label.name.toLowerCase())
|
|
129
|
+
const isSelected = actualIndex === selectedIndex
|
|
130
|
+
const marker = isActive ? "✓" : " "
|
|
131
|
+
const nameWidth = Math.max(1, rowWidth - 5)
|
|
132
|
+
return (
|
|
133
|
+
<box key={label.name} height={1}>
|
|
134
|
+
<TextLine bg={isSelected ? colors.selectedBg : undefined} fg={isSelected ? colors.selectedText : colors.text}>
|
|
135
|
+
<span fg={isActive ? colors.status.passing : colors.muted}>{marker}</span>
|
|
136
|
+
<span> </span>
|
|
137
|
+
<span bg={labelColor(label)}> </span>
|
|
138
|
+
<span> {fitCell(label.name, nameWidth)}</span>
|
|
139
|
+
</TextLine>
|
|
140
|
+
</box>
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
)}
|
|
144
|
+
</box>
|
|
145
|
+
<Divider width={innerWidth} />
|
|
146
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
147
|
+
<TextLine>
|
|
148
|
+
<span fg={colors.count}>↑↓</span>
|
|
149
|
+
<span fg={colors.muted}> move </span>
|
|
150
|
+
<span fg={colors.count}>esc</span>
|
|
151
|
+
<span fg={colors.muted}> close</span>
|
|
152
|
+
{filtered.length > maxVisible ? <span fg={colors.muted}> {selectedIndex + 1}/{filtered.length}</span> : null}
|
|
153
|
+
</TextLine>
|
|
154
|
+
</box>
|
|
155
|
+
</ModalFrame>
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export const MergeModal = ({
|
|
160
|
+
state,
|
|
161
|
+
modalWidth,
|
|
162
|
+
modalHeight,
|
|
163
|
+
offsetLeft,
|
|
164
|
+
offsetTop,
|
|
165
|
+
loadingIndicator,
|
|
166
|
+
}: {
|
|
167
|
+
state: MergeModalState
|
|
168
|
+
modalWidth: number
|
|
169
|
+
modalHeight: number
|
|
170
|
+
offsetLeft: number
|
|
171
|
+
offsetTop: number
|
|
172
|
+
loadingIndicator: string
|
|
173
|
+
}) => {
|
|
174
|
+
const innerWidth = Math.max(16, modalWidth - 2)
|
|
175
|
+
const contentWidth = Math.max(14, innerWidth - 2)
|
|
176
|
+
const rowWidth = innerWidth
|
|
177
|
+
const options = availableMergeActions(state.info)
|
|
178
|
+
const selectedIndex = options.length === 0 ? 0 : Math.max(0, Math.min(state.selectedIndex, options.length - 1))
|
|
179
|
+
const title = state.info ? `Merge #${state.info.number}` : state.number ? `Merge #${state.number}` : "Merge"
|
|
180
|
+
const rightText = state.running ? "running" : state.loading ? "loading" : state.info?.autoMergeEnabled ? "auto on" : "manual"
|
|
181
|
+
const headerGap = Math.max(1, contentWidth - title.length - rightText.length)
|
|
182
|
+
const repo = state.info?.repository ?? state.repository
|
|
183
|
+
const statusLine = state.info
|
|
184
|
+
? `${shortRepoName(state.info.repository)} ${state.info.mergeable} ${state.info.reviewStatus} ${state.info.checkSummary ?? state.info.checkStatus}`
|
|
185
|
+
: repo ? shortRepoName(repo) : ""
|
|
186
|
+
const optionAreaHeight = Math.max(1, modalHeight - 7)
|
|
187
|
+
const optionRows = Math.max(1, Math.floor(optionAreaHeight / 2))
|
|
188
|
+
const visibleOptions = options.slice(0, optionRows)
|
|
189
|
+
const loadingTopRows = Math.max(0, Math.floor((optionAreaHeight - 1) / 2))
|
|
190
|
+
const loadingBottomRows = Math.max(0, optionAreaHeight - loadingTopRows - 1)
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<ModalFrame left={offsetLeft} top={offsetTop} width={modalWidth} height={modalHeight} junctionRows={[2, modalHeight - 4]}>
|
|
194
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
195
|
+
<TextLine>
|
|
196
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{title}</span>
|
|
197
|
+
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
198
|
+
<span fg={state.running || state.loading ? colors.status.pending : colors.muted}>{rightText}</span>
|
|
199
|
+
</TextLine>
|
|
200
|
+
</box>
|
|
201
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
202
|
+
<PlainLine text={fitCell(statusLine, contentWidth)} fg={colors.muted} />
|
|
203
|
+
</box>
|
|
204
|
+
<Divider width={innerWidth} />
|
|
205
|
+
<box height={optionAreaHeight} flexDirection="column">
|
|
206
|
+
{state.loading ? (
|
|
207
|
+
<>
|
|
208
|
+
{Array.from({ length: loadingTopRows }, (_, index) => <box key={`top-${index}`} height={1} />)}
|
|
209
|
+
<PlainLine text={centerCell(`${loadingIndicator} Loading merge status`, rowWidth)} fg={colors.muted} />
|
|
210
|
+
{Array.from({ length: loadingBottomRows }, (_, index) => <box key={`bottom-${index}`} height={1} />)}
|
|
211
|
+
</>
|
|
212
|
+
) : state.error ? (
|
|
213
|
+
<PlainLine text={centerCell(state.error, rowWidth)} fg={colors.error} />
|
|
214
|
+
) : visibleOptions.length === 0 ? (
|
|
215
|
+
<PlainLine text={centerCell(mergeUnavailableReason(state.info), rowWidth)} fg={colors.muted} />
|
|
216
|
+
) : (
|
|
217
|
+
visibleOptions.map((option, index) => {
|
|
218
|
+
const isSelected = index === selectedIndex
|
|
219
|
+
const titleColor = option.danger ? colors.error : isSelected ? colors.selectedText : colors.text
|
|
220
|
+
const titleWidth = Math.max(1, rowWidth - 1)
|
|
221
|
+
const descriptionWidth = Math.max(1, rowWidth - 1)
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<box key={option.action} height={2} flexDirection="column">
|
|
225
|
+
<TextLine bg={isSelected ? colors.selectedBg : undefined}>
|
|
226
|
+
<span fg={titleColor}> {fitCell(option.title, titleWidth)}</span>
|
|
227
|
+
</TextLine>
|
|
228
|
+
<TextLine bg={isSelected ? colors.selectedBg : undefined}>
|
|
229
|
+
<span fg={colors.muted}> {fitCell(option.description, descriptionWidth)}</span>
|
|
230
|
+
</TextLine>
|
|
231
|
+
</box>
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
)}
|
|
235
|
+
</box>
|
|
236
|
+
<Divider width={innerWidth} />
|
|
237
|
+
<box height={1} paddingLeft={1} paddingRight={1}>
|
|
238
|
+
<TextLine>
|
|
239
|
+
<span fg={colors.count}>↑↓</span>
|
|
240
|
+
<span fg={colors.muted}> move </span>
|
|
241
|
+
<span fg={colors.count}>enter</span>
|
|
242
|
+
<span fg={colors.muted}> confirm </span>
|
|
243
|
+
<span fg={colors.count}>esc</span>
|
|
244
|
+
<span fg={colors.muted}> close</span>
|
|
245
|
+
</TextLine>
|
|
246
|
+
</box>
|
|
247
|
+
</ModalFrame>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import type React from "react"
|
|
3
|
+
import { colors } from "./colors.js"
|
|
4
|
+
|
|
5
|
+
export const fitCell = (text: string, width: number, align: "left" | "right" = "left") => {
|
|
6
|
+
const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
|
|
7
|
+
return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const trimCell = (text: string, width: number) => text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
|
|
11
|
+
|
|
12
|
+
export const centerCell = (text: string, width: number) => {
|
|
13
|
+
const trimmed = text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text
|
|
14
|
+
const left = Math.floor((width - trimmed.length) / 2)
|
|
15
|
+
return `${" ".repeat(Math.max(0, left))}${trimmed}`.padEnd(width, " ")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const PlainLine = ({ text, fg = colors.text, bold = false }: { text: string; fg?: string; bold?: boolean }) => (
|
|
19
|
+
<box height={1}>
|
|
20
|
+
{bold ? (
|
|
21
|
+
<text wrapMode="none" truncate fg={fg} attributes={TextAttributes.BOLD}>
|
|
22
|
+
{text}
|
|
23
|
+
</text>
|
|
24
|
+
) : (
|
|
25
|
+
<text wrapMode="none" truncate fg={fg}>
|
|
26
|
+
{text}
|
|
27
|
+
</text>
|
|
28
|
+
)}
|
|
29
|
+
</box>
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => (
|
|
33
|
+
<box height={1}>
|
|
34
|
+
{bg ? (
|
|
35
|
+
<text wrapMode="none" truncate fg={fg} bg={bg}>
|
|
36
|
+
{children}
|
|
37
|
+
</text>
|
|
38
|
+
) : (
|
|
39
|
+
<text wrapMode="none" truncate fg={fg}>
|
|
40
|
+
{children}
|
|
41
|
+
</text>
|
|
42
|
+
)}
|
|
43
|
+
</box>
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
export const SectionTitle = ({ title }: { title: string }) => (
|
|
47
|
+
<TextLine>
|
|
48
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>
|
|
49
|
+
{title}
|
|
50
|
+
</span>
|
|
51
|
+
</TextLine>
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
export const Divider = ({ width, junctionAt, junctionChar }: { width: number; junctionAt?: number; junctionChar?: string }) => {
|
|
55
|
+
if (junctionAt === undefined || junctionChar === undefined || junctionAt < 0 || junctionAt >= width) {
|
|
56
|
+
return <PlainLine text={"─".repeat(Math.max(1, width))} fg={colors.separator} />
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return <PlainLine text={`${"─".repeat(junctionAt)}${junctionChar}${"─".repeat(Math.max(0, width - junctionAt - 1))}`} fg={colors.separator} />
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const SeparatorColumn = ({ height, junctionRows }: { height: number; junctionRows?: readonly number[] }) => {
|
|
63
|
+
const junctions = new Set(junctionRows)
|
|
64
|
+
return (
|
|
65
|
+
<box width={1} height={height} flexDirection="column">
|
|
66
|
+
{Array.from({ length: height }, (_, index) => (
|
|
67
|
+
<PlainLine key={index} text={junctions.has(index) ? "├" : "│"} fg={colors.separator} />
|
|
68
|
+
))}
|
|
69
|
+
</box>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const ModalFrame = ({
|
|
74
|
+
children,
|
|
75
|
+
left,
|
|
76
|
+
top,
|
|
77
|
+
width,
|
|
78
|
+
height,
|
|
79
|
+
junctionRows = [],
|
|
80
|
+
backgroundColor = "#1a1a2e",
|
|
81
|
+
}: {
|
|
82
|
+
children: React.ReactNode
|
|
83
|
+
left: number
|
|
84
|
+
top: number
|
|
85
|
+
width: number
|
|
86
|
+
height: number
|
|
87
|
+
junctionRows?: readonly number[]
|
|
88
|
+
backgroundColor?: string
|
|
89
|
+
}) => {
|
|
90
|
+
const innerWidth = Math.max(1, width - 2)
|
|
91
|
+
const innerHeight = Math.max(1, height - 2)
|
|
92
|
+
const junctions = new Set(junctionRows)
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<box position="absolute" left={left} top={top} width={width} height={height} flexDirection="column" backgroundColor={backgroundColor}>
|
|
96
|
+
<PlainLine text={`┌${"─".repeat(innerWidth)}┐`} fg={colors.separator} />
|
|
97
|
+
<box height={innerHeight} flexDirection="row">
|
|
98
|
+
<box width={1} height={innerHeight} flexDirection="column">
|
|
99
|
+
{Array.from({ length: innerHeight }, (_, index) => <PlainLine key={index} text={junctions.has(index) ? "├" : "│"} fg={colors.separator} />)}
|
|
100
|
+
</box>
|
|
101
|
+
<box width={innerWidth} height={innerHeight} flexDirection="column">
|
|
102
|
+
{children}
|
|
103
|
+
</box>
|
|
104
|
+
<box width={1} height={innerHeight} flexDirection="column">
|
|
105
|
+
{Array.from({ length: innerHeight }, (_, index) => <PlainLine key={index} text={junctions.has(index) ? "┤" : "│"} fg={colors.separator} />)}
|
|
106
|
+
</box>
|
|
107
|
+
</box>
|
|
108
|
+
<PlainLine text={`└${"─".repeat(innerWidth)}┘`} fg={colors.separator} />
|
|
109
|
+
</box>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { PullRequestItem, PullRequestLabel } from "../domain.js"
|
|
2
|
+
import { colors } from "./colors.js"
|
|
3
|
+
|
|
4
|
+
export const shortRepoName = (repository: string) => repository.split("/")[1] ?? repository
|
|
5
|
+
|
|
6
|
+
export const repoColor = (repository: string) => colors.repos[shortRepoName(repository) as keyof typeof colors.repos] ?? colors.repos.default
|
|
7
|
+
|
|
8
|
+
export const reviewLabel = (pullRequest: PullRequestItem) => {
|
|
9
|
+
if (pullRequest.reviewStatus === "draft") return "draft"
|
|
10
|
+
if (pullRequest.reviewStatus === "approved") return "approved"
|
|
11
|
+
if (pullRequest.reviewStatus === "changes") return "changes"
|
|
12
|
+
if (pullRequest.reviewStatus === "review") return "review"
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const checkLabel = (pullRequest: PullRequestItem) => pullRequest.checkSummary
|
|
17
|
+
|
|
18
|
+
export const statusColor = (status: PullRequestItem["reviewStatus"] | PullRequestItem["checkStatus"]) => colors.status[status]
|
|
19
|
+
|
|
20
|
+
export const reviewIcon = (pullRequest: PullRequestItem) => {
|
|
21
|
+
if (pullRequest.autoMergeEnabled) return "↻"
|
|
22
|
+
if (pullRequest.reviewStatus === "draft") return "◌"
|
|
23
|
+
if (pullRequest.reviewStatus === "approved") return "✓"
|
|
24
|
+
if (pullRequest.reviewStatus === "changes") return "!"
|
|
25
|
+
if (pullRequest.reviewStatus === "review") return "◐"
|
|
26
|
+
return "·"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const fallbackLabelColor = (name: string) => {
|
|
30
|
+
let hash = 0
|
|
31
|
+
for (const char of name) {
|
|
32
|
+
hash = (hash * 31 + char.charCodeAt(0)) >>> 0
|
|
33
|
+
}
|
|
34
|
+
const hue = hash % 360
|
|
35
|
+
return `hsl(${hue} 55% 35%)`
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const labelColor = (label: PullRequestLabel) => label.color ?? fallbackLabelColor(label.name)
|
|
39
|
+
|
|
40
|
+
export const labelTextColor = (color: string) => {
|
|
41
|
+
if (color.startsWith("#") && color.length === 7) {
|
|
42
|
+
const red = Number.parseInt(color.slice(1, 3), 16)
|
|
43
|
+
const green = Number.parseInt(color.slice(3, 5), 16)
|
|
44
|
+
const blue = Number.parseInt(color.slice(5, 7), 16)
|
|
45
|
+
const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255
|
|
46
|
+
return luminance > 0.6 ? "#111111" : "#f8fafc"
|
|
47
|
+
}
|
|
48
|
+
return "#f8fafc"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const groupBy = <T,>(items: readonly T[], getKey: (item: T) => string, orderedKeys: readonly string[] = []) => {
|
|
52
|
+
const groups = new Map<string, T[]>()
|
|
53
|
+
for (const item of items) {
|
|
54
|
+
const key = getKey(item)
|
|
55
|
+
const existing = groups.get(key)
|
|
56
|
+
if (existing) {
|
|
57
|
+
existing.push(item)
|
|
58
|
+
} else {
|
|
59
|
+
groups.set(key, [item])
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const order = new Map(orderedKeys.map((key, index) => [key, index]))
|
|
64
|
+
return [...groups.entries()].sort((left, right) => {
|
|
65
|
+
const leftIndex = order.get(left[0])
|
|
66
|
+
const rightIndex = order.get(right[0])
|
|
67
|
+
if (leftIndex !== undefined && rightIndex !== undefined) return leftIndex - rightIndex
|
|
68
|
+
if (leftIndex !== undefined) return -1
|
|
69
|
+
if (rightIndex !== undefined) return 1
|
|
70
|
+
return left[0].localeCompare(right[0])
|
|
71
|
+
})
|
|
72
|
+
}
|