@reviewflow/ui 0.0.0-stage → 6.21.0
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/LICENSE +15 -0
- package/README.md +95 -2
- package/package.json +102 -4
- package/src/board/bucket-section.tsx +94 -0
- package/src/board/dataSource.ts +44 -0
- package/src/board/facts.ts +183 -0
- package/src/board/index.ts +12 -0
- package/src/board/pull-request-board.tsx +239 -0
- package/src/board/pull-request-row.tsx +181 -0
- package/src/components/branch-diff/diff-themes.ts +6 -0
- package/src/components/branch-diff/diff-worker-pool.tsx +12 -0
- package/src/components/branch-diff/diff-worker-pool.web.tsx +56 -0
- package/src/components/branch-diff/diff-worker.ts +7 -0
- package/src/components/branch-diff/use-collapsed-files.ts +49 -0
- package/src/components/branch-diff/use-diff-settings.tsx +97 -0
- package/src/components/branch-diff/whitespace-button.tsx +32 -0
- package/src/components/copyable-code-block.tsx +101 -0
- package/src/components/label-pill.tsx +84 -0
- package/src/components/link-in-row.ts +6 -0
- package/src/components/markdown-suggestion.tsx +60 -0
- package/src/components/markdown-text.tsx +1067 -0
- package/src/components/pull-request-actions-merge.tsx +341 -0
- package/src/components/pull-request-actions-review.tsx +218 -0
- package/src/components/pull-request-checks.tsx +86 -0
- package/src/components/pull-request-commit-files.ts +143 -0
- package/src/components/pull-request-commits.tsx +221 -0
- package/src/components/pull-request-conversation-card.tsx +220 -0
- package/src/components/pull-request-conversation-edit.tsx +147 -0
- package/src/components/pull-request-conversation-event.tsx +267 -0
- package/src/components/pull-request-conversation-person.tsx +28 -0
- package/src/components/pull-request-conversation-thread.tsx +158 -0
- package/src/components/pull-request-diff-annotations.tsx +443 -0
- package/src/components/pull-request-diff-collapse.ts +43 -0
- package/src/components/pull-request-diff-data.ts +174 -0
- package/src/components/pull-request-diff-impacts-summary.tsx +97 -0
- package/src/components/pull-request-diff-impacts.ts +45 -0
- package/src/components/pull-request-diff-jump.ts +87 -0
- package/src/components/pull-request-diff-lines.ts +103 -0
- package/src/components/pull-request-diff-overview.tsx +416 -0
- package/src/components/pull-request-diff-view.tsx +438 -0
- package/src/components/pull-request-diff-viewer.tsx +1374 -0
- package/src/components/pull-request-files.tsx +533 -0
- package/src/components/pull-request-markdown-input.tsx +91 -0
- package/src/components/pull-request-panel.tsx +1270 -0
- package/src/components/pull-request-part-notice.tsx +82 -0
- package/src/components/pull-request-reactions.tsx +178 -0
- package/src/components/pull-request-review.tsx +164 -0
- package/src/components/pull-request-revisions.tsx +273 -0
- package/src/components/pull-request-screen.tsx +299 -0
- package/src/components/pull-request-suggestion-actions.tsx +201 -0
- package/src/components/pull-request-timeline.tsx +270 -0
- package/src/components/repository-attachment-banner.tsx +42 -0
- package/src/components/scroll-parent.ts +50 -0
- package/src/css.d.ts +1 -0
- package/src/dataSource.ts +242 -0
- package/src/editor/alerts.ts +105 -0
- package/src/editor/code-editor.native.tsx +25 -0
- package/src/editor/code-editor.tsx +112 -0
- package/src/editor/codeEditorExtensions.ts +65 -0
- package/src/editor/codeHighlight.ts +71 -0
- package/src/editor/commands.ts +175 -0
- package/src/editor/decorations.ts +425 -0
- package/src/editor/editable-file.tsx +224 -0
- package/src/editor/emojiCompletion.ts +110 -0
- package/src/editor/extensions.ts +149 -0
- package/src/editor/frontmatter.ts +76 -0
- package/src/editor/inlineFormat.ts +114 -0
- package/src/editor/lineTypes.ts +55 -0
- package/src/editor/markdown-editor.css +511 -0
- package/src/editor/markdown-editor.native.tsx +29 -0
- package/src/editor/markdown-editor.tsx +295 -0
- package/src/editor/nodeClasses.ts +69 -0
- package/src/index.ts +75 -0
- package/src/markdown-github/flatten.ts +49 -0
- package/src/markdown-github/html-tokens.ts +93 -0
- package/src/markdown-github/pair-html.ts +354 -0
- package/src/markdown-github/safe-url.ts +15 -0
- package/src/markdown-github/table-columns.ts +23 -0
- package/src/markdown-github/toggle-task.ts +164 -0
- package/src/nativewind-env.d.ts +1 -0
- package/src/prStatus.ts +90 -0
- package/src/services/PullRequestDraftsProvider.tsx +179 -0
- package/src/services/PullRequestReviewDraftProvider.tsx +178 -0
- package/src/services/PullRequestSuggestionBatchProvider.tsx +112 -0
- package/src/services/emojify.ts +13 -0
- package/src/services/errorToMessage.ts +6 -0
- package/src/services/expiringDraftStore.ts +102 -0
- package/src/services/formatDataAge.ts +12 -0
- package/src/services/formatRelativeDate.ts +56 -0
- package/src/services/githubImages.tsx +33 -0
- package/src/services/isViewerAuthor.ts +7 -0
- package/src/slots.tsx +34 -0
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PullRequestCandidatesResponse,
|
|
3
|
+
PullRequestDetail,
|
|
4
|
+
PullRequestDetailResponse,
|
|
5
|
+
PullRequestEdit,
|
|
6
|
+
PullRequestReviewState,
|
|
7
|
+
PullRequestSummary,
|
|
8
|
+
} from "@reviewflow/sdk";
|
|
9
|
+
import type { Accent } from "alouette";
|
|
10
|
+
import {
|
|
11
|
+
AccentScope,
|
|
12
|
+
ActionButton,
|
|
13
|
+
Badge,
|
|
14
|
+
Box,
|
|
15
|
+
Button,
|
|
16
|
+
Checkbox,
|
|
17
|
+
CheckboxGroup,
|
|
18
|
+
Code,
|
|
19
|
+
ErrorMessage,
|
|
20
|
+
FormEditableSection,
|
|
21
|
+
FormField,
|
|
22
|
+
Icon,
|
|
23
|
+
IconButton,
|
|
24
|
+
InputText,
|
|
25
|
+
Tab,
|
|
26
|
+
Tabs,
|
|
27
|
+
Text,
|
|
28
|
+
View,
|
|
29
|
+
WarningMessage,
|
|
30
|
+
} from "alouette";
|
|
31
|
+
import { ArrowClockwiseRegularIcon } from "alouette-icons/phosphor-icons/ArrowClockwise";
|
|
32
|
+
import { ChatCircleDotsRegularIcon } from "alouette-icons/phosphor-icons/ChatCircleDots";
|
|
33
|
+
import type { ReactNode } from "react";
|
|
34
|
+
import { useState } from "react";
|
|
35
|
+
import { usePullRequestDataSource } from "../dataSource.ts";
|
|
36
|
+
import type { PrStatus } from "../prStatus.ts";
|
|
37
|
+
import {
|
|
38
|
+
prStatus,
|
|
39
|
+
prStatusAccents,
|
|
40
|
+
prStatusLabels,
|
|
41
|
+
reviewflowPrStatus,
|
|
42
|
+
} from "../prStatus.ts";
|
|
43
|
+
import { emojify } from "../services/emojify.ts";
|
|
44
|
+
import { errorToMessage } from "../services/errorToMessage.ts";
|
|
45
|
+
import { formatDataAge } from "../services/formatDataAge.ts";
|
|
46
|
+
import { LabelPill } from "./label-pill.tsx";
|
|
47
|
+
import {
|
|
48
|
+
PullRequestMergeButton,
|
|
49
|
+
PullRequestMergeStatus,
|
|
50
|
+
PullRequestReopen,
|
|
51
|
+
} from "./pull-request-actions-merge.tsx";
|
|
52
|
+
import { CheckLine } from "./pull-request-checks.tsx";
|
|
53
|
+
import { PullRequestCommits } from "./pull-request-commits.tsx";
|
|
54
|
+
import { PersonAvatar } from "./pull-request-conversation-person.tsx";
|
|
55
|
+
import { PullRequestFiles } from "./pull-request-files.tsx";
|
|
56
|
+
import {
|
|
57
|
+
PullRequestPartError,
|
|
58
|
+
PullRequestPartPending,
|
|
59
|
+
PullRequestPartReading,
|
|
60
|
+
} from "./pull-request-part-notice.tsx";
|
|
61
|
+
import { PullRequestReview } from "./pull-request-review.tsx";
|
|
62
|
+
import { PullRequestTimeline } from "./pull-request-timeline.tsx";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* What the action components take: one edit written through the data source,
|
|
66
|
+
* then GitHub's state read again.
|
|
67
|
+
*/
|
|
68
|
+
const useEditAndRefresh = ({
|
|
69
|
+
detail,
|
|
70
|
+
onEdited,
|
|
71
|
+
}: EditableProps): ((edit: PullRequestEdit) => Promise<void>) => {
|
|
72
|
+
const { edit: writeEdit } = usePullRequestDataSource();
|
|
73
|
+
return async (edit) => {
|
|
74
|
+
await writeEdit(detail.pullRequest, edit);
|
|
75
|
+
await onEdited(edit);
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
interface EditableProps {
|
|
80
|
+
detail: PullRequestDetailResponse;
|
|
81
|
+
/** reads the pull request again once GitHub took the change */
|
|
82
|
+
onEdited: (edit: PullRequestEdit) => Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* --- header --- */
|
|
86
|
+
|
|
87
|
+
interface TitleEditorProps extends EditableProps {
|
|
88
|
+
initial: string;
|
|
89
|
+
onDone: () => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function TitleEditor({
|
|
93
|
+
detail,
|
|
94
|
+
onEdited,
|
|
95
|
+
initial,
|
|
96
|
+
onDone,
|
|
97
|
+
}: TitleEditorProps): ReactNode {
|
|
98
|
+
const editAndRefresh = useEditAndRefresh({ detail, onEdited });
|
|
99
|
+
const [title, setTitle] = useState(initial);
|
|
100
|
+
return (
|
|
101
|
+
<View className="flex-row flex-wrap items-center gap-xs">
|
|
102
|
+
<InputText
|
|
103
|
+
aria-label="Title"
|
|
104
|
+
className="min-w-0 flex-1"
|
|
105
|
+
value={title}
|
|
106
|
+
onChangeText={setTitle}
|
|
107
|
+
/>
|
|
108
|
+
<Button
|
|
109
|
+
text="Cancel"
|
|
110
|
+
accent="neutral"
|
|
111
|
+
variant="soft"
|
|
112
|
+
size="sm"
|
|
113
|
+
onPress={onDone}
|
|
114
|
+
/>
|
|
115
|
+
<ActionButton
|
|
116
|
+
text="Save"
|
|
117
|
+
accent="brand"
|
|
118
|
+
variant="tonal"
|
|
119
|
+
size="sm"
|
|
120
|
+
disabled={title.trim() === ""}
|
|
121
|
+
errorToMessage={errorToMessage}
|
|
122
|
+
onPress={async () => {
|
|
123
|
+
const edit: PullRequestEdit = { kind: "title", title };
|
|
124
|
+
await editAndRefresh(edit);
|
|
125
|
+
onDone();
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
</View>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
interface TitleTextProps {
|
|
133
|
+
number: number;
|
|
134
|
+
title: string;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function TitleText({ number, title }: TitleTextProps): ReactNode {
|
|
138
|
+
return (
|
|
139
|
+
<Text className="min-w-0 flex-1 font-heading-bold text-xl">
|
|
140
|
+
<Text className="font-heading text-2xl text-muted">
|
|
141
|
+
{`#${String(number)} `}
|
|
142
|
+
</Text>
|
|
143
|
+
{emojify(title)}
|
|
144
|
+
</Text>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface MergeLineProps {
|
|
149
|
+
/** null when the source does not know enough to say */
|
|
150
|
+
status: PrStatus | null;
|
|
151
|
+
author: string | null;
|
|
152
|
+
baseRef: string | null;
|
|
153
|
+
headRef: string | null;
|
|
154
|
+
/** what follows the branches: sizes and age, which only GitHub's read has */
|
|
155
|
+
children?: ReactNode;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function MergeLine({
|
|
159
|
+
status,
|
|
160
|
+
author,
|
|
161
|
+
baseRef,
|
|
162
|
+
headRef,
|
|
163
|
+
children,
|
|
164
|
+
}: MergeLineProps): ReactNode {
|
|
165
|
+
return (
|
|
166
|
+
<View className="flex-row flex-wrap items-center gap-xs">
|
|
167
|
+
{status === null ? null : (
|
|
168
|
+
<Badge accent={prStatusAccents[status]}>{prStatusLabels[status]}</Badge>
|
|
169
|
+
)}
|
|
170
|
+
<Text className="text-sm font-body-bold">
|
|
171
|
+
{author ?? "unknown author"}
|
|
172
|
+
</Text>
|
|
173
|
+
{baseRef === null || headRef === null ? null : (
|
|
174
|
+
<>
|
|
175
|
+
<Text className="text-xs text-muted">
|
|
176
|
+
{status === "merged" ? "merged into" : "wants to merge into"}
|
|
177
|
+
</Text>
|
|
178
|
+
<Code className="text-sm">{baseRef}</Code>
|
|
179
|
+
<Text className="text-xs text-muted">from</Text>
|
|
180
|
+
<Code className="text-sm">{headRef}</Code>
|
|
181
|
+
</>
|
|
182
|
+
)}
|
|
183
|
+
{children}
|
|
184
|
+
</View>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The header from a summary the data source holds, while GitHub is read: the
|
|
190
|
+
* same title and branch line, without what only GitHub's answer carries.
|
|
191
|
+
*/
|
|
192
|
+
export function PullRequestSummaryHeader({
|
|
193
|
+
summary,
|
|
194
|
+
}: {
|
|
195
|
+
summary: PullRequestSummary;
|
|
196
|
+
}): ReactNode {
|
|
197
|
+
return (
|
|
198
|
+
<View className="gap-sm">
|
|
199
|
+
<TitleText number={summary.number} title={summary.title} />
|
|
200
|
+
<MergeLine
|
|
201
|
+
status={reviewflowPrStatus(summary)}
|
|
202
|
+
author={summary.author}
|
|
203
|
+
baseRef={summary.baseRef}
|
|
204
|
+
headRef={summary.headRef}
|
|
205
|
+
/>
|
|
206
|
+
</View>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The header: the title and its number, Edit and the way out beside
|
|
212
|
+
* them, and one line saying whose branch goes where.
|
|
213
|
+
*/
|
|
214
|
+
function Header({ detail, onEdited }: EditableProps): ReactNode {
|
|
215
|
+
const { pullRequest } = detail;
|
|
216
|
+
const [editing, setEditing] = useState(false);
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<View className="gap-sm">
|
|
220
|
+
{editing ? (
|
|
221
|
+
<TitleEditor
|
|
222
|
+
detail={detail}
|
|
223
|
+
initial={pullRequest.title}
|
|
224
|
+
onEdited={onEdited}
|
|
225
|
+
onDone={() => {
|
|
226
|
+
setEditing(false);
|
|
227
|
+
}}
|
|
228
|
+
/>
|
|
229
|
+
) : (
|
|
230
|
+
<View className="flex-row flex-wrap items-start gap-m">
|
|
231
|
+
<TitleText number={pullRequest.number} title={pullRequest.title} />
|
|
232
|
+
<View className="flex-row items-center gap-xs">
|
|
233
|
+
{pullRequest.viewerCanUpdate ? (
|
|
234
|
+
<Button
|
|
235
|
+
text="Edit"
|
|
236
|
+
accent="neutral"
|
|
237
|
+
variant="tonal"
|
|
238
|
+
size="sm"
|
|
239
|
+
aria-label="Edit the title"
|
|
240
|
+
onPress={() => {
|
|
241
|
+
setEditing(true);
|
|
242
|
+
}}
|
|
243
|
+
/>
|
|
244
|
+
) : null}
|
|
245
|
+
</View>
|
|
246
|
+
</View>
|
|
247
|
+
)}
|
|
248
|
+
<MergeLine
|
|
249
|
+
status={prStatus(pullRequest)}
|
|
250
|
+
author={pullRequest.author?.login ?? null}
|
|
251
|
+
baseRef={pullRequest.baseRef}
|
|
252
|
+
headRef={pullRequest.headRef}
|
|
253
|
+
>
|
|
254
|
+
<Text className="text-sm text-muted">·</Text>
|
|
255
|
+
<View className="flex-row gap-xxs">
|
|
256
|
+
<AccentScope accent="success">
|
|
257
|
+
<Text className="text-sm font-body-bold text-accent">
|
|
258
|
+
{`+${String(pullRequest.additions)}`}
|
|
259
|
+
</Text>
|
|
260
|
+
</AccentScope>
|
|
261
|
+
<AccentScope accent="danger">
|
|
262
|
+
<Text className="text-sm font-body-bold text-accent">
|
|
263
|
+
{`−${String(pullRequest.deletions)}`}
|
|
264
|
+
</Text>
|
|
265
|
+
</AccentScope>
|
|
266
|
+
</View>
|
|
267
|
+
<Text className="text-xs text-muted">
|
|
268
|
+
{`· updated ${formatDataAge(pullRequest.updatedAt)}`}
|
|
269
|
+
</Text>
|
|
270
|
+
</MergeLine>
|
|
271
|
+
</View>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/* --- conversation --- */
|
|
276
|
+
|
|
277
|
+
interface SurfaceProps {
|
|
278
|
+
title: string;
|
|
279
|
+
children: ReactNode;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function Surface({ title, children }: SurfaceProps): ReactNode {
|
|
283
|
+
return (
|
|
284
|
+
<Box className="surface min-w-0 gap-m p-m">
|
|
285
|
+
<Text className="font-heading-bold text-lg">{title}</Text>
|
|
286
|
+
{children}
|
|
287
|
+
</Box>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The passing checks are a count: what needs reading is what failed or has
|
|
293
|
+
* not finished, and a list of forty green lines buries it.
|
|
294
|
+
*/
|
|
295
|
+
function Checks({
|
|
296
|
+
pullRequest,
|
|
297
|
+
}: {
|
|
298
|
+
pullRequest: PullRequestDetail;
|
|
299
|
+
}): ReactNode {
|
|
300
|
+
const { value, error } = usePullRequestDataSource().usePart(
|
|
301
|
+
pullRequest,
|
|
302
|
+
"checks",
|
|
303
|
+
);
|
|
304
|
+
if (value === null) {
|
|
305
|
+
return <PullRequestPartPending error={error} what="the checks" />;
|
|
306
|
+
}
|
|
307
|
+
const { checks, checksTotal } = value;
|
|
308
|
+
if (checksTotal === 0) {
|
|
309
|
+
return <Text className="text-sm text-muted">no checks</Text>;
|
|
310
|
+
}
|
|
311
|
+
const unsettled = checks.filter((check) => check.outcome !== "passed");
|
|
312
|
+
const passed = checks.length - unsettled.length;
|
|
313
|
+
const unlisted = checksTotal - checks.length;
|
|
314
|
+
|
|
315
|
+
return (
|
|
316
|
+
<View className="gap-xs">
|
|
317
|
+
{unsettled.map((check) => (
|
|
318
|
+
<CheckLine key={check.name} check={check} />
|
|
319
|
+
))}
|
|
320
|
+
<Text className="text-sm text-muted">
|
|
321
|
+
{`${String(passed)} passing${unlisted > 0 ? `, ${String(unlisted)} more not listed` : ""}`}
|
|
322
|
+
</Text>
|
|
323
|
+
</View>
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Above every tab: a conflict is the one thing that blocks the merge whatever is being read. */
|
|
328
|
+
function ConflictWarning({
|
|
329
|
+
pullRequest,
|
|
330
|
+
}: {
|
|
331
|
+
pullRequest: PullRequestDetail;
|
|
332
|
+
}): ReactNode {
|
|
333
|
+
if (pullRequest.mergeable !== "conflicting") return null;
|
|
334
|
+
return (
|
|
335
|
+
<WarningMessage>
|
|
336
|
+
{`This branch has conflicts with ${pullRequest.baseRef} that must be resolved.`}
|
|
337
|
+
</WarningMessage>
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Counted from the threads GitHub listed, the ones the conversation shows and
|
|
343
|
+
* resolves: reviewflow's own count would lag a resolve made here until its
|
|
344
|
+
* webhook lands. Past the 100 threads read, it undercounts.
|
|
345
|
+
*/
|
|
346
|
+
function UnresolvedThreads({
|
|
347
|
+
pullRequest,
|
|
348
|
+
}: {
|
|
349
|
+
pullRequest: PullRequestDetail;
|
|
350
|
+
}): ReactNode {
|
|
351
|
+
const { value } = usePullRequestDataSource().usePart(pullRequest, "timeline");
|
|
352
|
+
const unresolved = (value?.timeline ?? []).filter(
|
|
353
|
+
(item) => item.kind === "thread" && !item.resolved,
|
|
354
|
+
).length;
|
|
355
|
+
if (unresolved === 0) return null;
|
|
356
|
+
return (
|
|
357
|
+
<View className="flex-row items-start gap-xs">
|
|
358
|
+
<AccentScope accent="warning">
|
|
359
|
+
<View className="h-[20px] justify-center">
|
|
360
|
+
<Icon
|
|
361
|
+
icon={<ChatCircleDotsRegularIcon />}
|
|
362
|
+
size={16}
|
|
363
|
+
className="text-accent"
|
|
364
|
+
/>
|
|
365
|
+
</View>
|
|
366
|
+
</AccentScope>
|
|
367
|
+
<Text className="min-w-0 flex-1 text-sm">
|
|
368
|
+
{`${String(unresolved)} unresolved ${unresolved === 1 ? "thread" : "threads"}`}
|
|
369
|
+
</Text>
|
|
370
|
+
</View>
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* GitHub's merge box, minus its buttons, at the top of the sidebar: what
|
|
376
|
+
* stands between the pull request and a review. On a draft that is the
|
|
377
|
+
* operator's own move, so it leads.
|
|
378
|
+
*/
|
|
379
|
+
function Status({ detail, onEdited }: EditableProps): ReactNode {
|
|
380
|
+
const { pullRequest } = detail;
|
|
381
|
+
const editAndRefresh = useEditAndRefresh({ detail, onEdited });
|
|
382
|
+
if (pullRequest.state === "merged") return null;
|
|
383
|
+
if (pullRequest.state === "closed") {
|
|
384
|
+
return (
|
|
385
|
+
<View className="gap-sm">
|
|
386
|
+
<Text className="font-heading-bold text-xl">Status</Text>
|
|
387
|
+
<Text className="text-sm">This pull request is closed.</Text>
|
|
388
|
+
</View>
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<View className="gap-sm">
|
|
394
|
+
<Text className="font-heading-bold text-xl">Status</Text>
|
|
395
|
+
{pullRequest.state === "draft" && pullRequest.viewerCanUpdate ? (
|
|
396
|
+
<View className="items-start gap-xs">
|
|
397
|
+
<Text className="text-sm">
|
|
398
|
+
This pull request is still a work in progress.
|
|
399
|
+
</Text>
|
|
400
|
+
<ActionButton
|
|
401
|
+
text="Ready for review"
|
|
402
|
+
accent="brand"
|
|
403
|
+
variant="tonal"
|
|
404
|
+
size="sm"
|
|
405
|
+
errorToMessage={errorToMessage}
|
|
406
|
+
onPress={async () => {
|
|
407
|
+
const edit: PullRequestEdit = { kind: "draft", draft: false };
|
|
408
|
+
await editAndRefresh(edit);
|
|
409
|
+
}}
|
|
410
|
+
/>
|
|
411
|
+
</View>
|
|
412
|
+
) : null}
|
|
413
|
+
{pullRequest.state === "draft" && !pullRequest.viewerCanUpdate ? (
|
|
414
|
+
<Text className="text-sm">
|
|
415
|
+
This pull request is still a work in progress.
|
|
416
|
+
</Text>
|
|
417
|
+
) : null}
|
|
418
|
+
<Checks pullRequest={pullRequest} />
|
|
419
|
+
<UnresolvedThreads pullRequest={pullRequest} />
|
|
420
|
+
<PullRequestMergeStatus detail={detail} onEdit={editAndRefresh} />
|
|
421
|
+
</View>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Every check of the head commit, failures first, the passing ones too. */
|
|
426
|
+
function ChecksTab({
|
|
427
|
+
pullRequest,
|
|
428
|
+
}: {
|
|
429
|
+
pullRequest: PullRequestDetail;
|
|
430
|
+
}): ReactNode {
|
|
431
|
+
const { value, error, reading } = usePullRequestDataSource().usePart(
|
|
432
|
+
pullRequest,
|
|
433
|
+
"checks",
|
|
434
|
+
);
|
|
435
|
+
if (value === null) {
|
|
436
|
+
return <PullRequestPartPending error={error} what="the checks" />;
|
|
437
|
+
}
|
|
438
|
+
const { checks, checksTotal } = value;
|
|
439
|
+
const unlisted = checksTotal - checks.length;
|
|
440
|
+
return (
|
|
441
|
+
<Surface title={`Checks (${String(checksTotal)})`}>
|
|
442
|
+
<PullRequestPartReading reading={reading} />
|
|
443
|
+
<PullRequestPartError error={error} />
|
|
444
|
+
{checksTotal === 0 ? (
|
|
445
|
+
<Text className="text-sm text-muted">no checks</Text>
|
|
446
|
+
) : (
|
|
447
|
+
<View className="gap-xs">
|
|
448
|
+
{checks.map((check) => (
|
|
449
|
+
<CheckLine key={check.name} check={check} />
|
|
450
|
+
))}
|
|
451
|
+
{unlisted > 0 ? (
|
|
452
|
+
<Text className="text-sm text-muted">
|
|
453
|
+
{`${String(unlisted)} more not listed`}
|
|
454
|
+
</Text>
|
|
455
|
+
) : null}
|
|
456
|
+
</View>
|
|
457
|
+
)}
|
|
458
|
+
</Surface>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/* --- sidebar --- */
|
|
463
|
+
|
|
464
|
+
interface CandidatesProps extends EditableProps {
|
|
465
|
+
/** what the editors offer; null until GitHub answered, the editors read-only */
|
|
466
|
+
candidates: PullRequestCandidatesResponse | null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const reviewStateLabel = (state: PullRequestReviewState): string => {
|
|
470
|
+
switch (state) {
|
|
471
|
+
case "approved":
|
|
472
|
+
return "approved";
|
|
473
|
+
case "changesRequested":
|
|
474
|
+
return "changes requested";
|
|
475
|
+
case "commented":
|
|
476
|
+
return "commented";
|
|
477
|
+
case "dismissed":
|
|
478
|
+
return "dismissed";
|
|
479
|
+
case "pending":
|
|
480
|
+
return "pending";
|
|
481
|
+
default: {
|
|
482
|
+
const unlabelled: never = state;
|
|
483
|
+
return unlabelled;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const reviewStateAccent = (state: PullRequestReviewState): Accent => {
|
|
489
|
+
if (state === "approved") return "success";
|
|
490
|
+
if (state === "changesRequested") return "danger";
|
|
491
|
+
return "info";
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
const userValue = (login: string): string => `user:${login}`;
|
|
495
|
+
const teamValue = (slug: string): string => `team:${slug}`;
|
|
496
|
+
|
|
497
|
+
const requestedValues = (pullRequest: PullRequestDetail): string[] =>
|
|
498
|
+
pullRequest.requestedReviewers.map((reviewer) =>
|
|
499
|
+
reviewer.kind === "user"
|
|
500
|
+
? userValue(reviewer.login)
|
|
501
|
+
: teamValue(reviewer.slug),
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
/** What the selection changed, in the request's terms. */
|
|
505
|
+
const reviewersEdit = (
|
|
506
|
+
pullRequest: PullRequestDetail,
|
|
507
|
+
selected: string[],
|
|
508
|
+
): PullRequestEdit => {
|
|
509
|
+
const before = new Set(requestedValues(pullRequest));
|
|
510
|
+
const after = new Set(selected);
|
|
511
|
+
const added = selected.filter((value) => !before.has(value));
|
|
512
|
+
const removed = [...before].filter((value) => !after.has(value));
|
|
513
|
+
const names = (values: string[], prefix: string): string[] =>
|
|
514
|
+
values
|
|
515
|
+
.filter((value) => value.startsWith(prefix))
|
|
516
|
+
.map((value) => value.slice(prefix.length));
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
kind: "reviewers",
|
|
520
|
+
add: names(added, "user:"),
|
|
521
|
+
remove: names(removed, "user:"),
|
|
522
|
+
addTeams: names(added, "team:"),
|
|
523
|
+
removeTeams: names(removed, "team:"),
|
|
524
|
+
};
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
interface ReviewerLineProps {
|
|
528
|
+
name: string;
|
|
529
|
+
avatarUrl: string | null;
|
|
530
|
+
/** null: asked, not answered yet — or asked again since */
|
|
531
|
+
state: PullRequestReviewState | null;
|
|
532
|
+
/** asks the reviewer again; absent where the gesture is not offered */
|
|
533
|
+
onRerequest?: () => Promise<void>;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function RerequestButton({
|
|
537
|
+
name,
|
|
538
|
+
onRerequest,
|
|
539
|
+
}: {
|
|
540
|
+
name: string;
|
|
541
|
+
onRerequest: () => Promise<void>;
|
|
542
|
+
}): ReactNode {
|
|
543
|
+
const [error, setError] = useState<string | null>(null);
|
|
544
|
+
const [pending, setPending] = useState(false);
|
|
545
|
+
return (
|
|
546
|
+
<>
|
|
547
|
+
<IconButton
|
|
548
|
+
icon={<ArrowClockwiseRegularIcon />}
|
|
549
|
+
aria-label={`Re-request a review from ${name}`}
|
|
550
|
+
variant="soft"
|
|
551
|
+
size="sm"
|
|
552
|
+
disabled={pending}
|
|
553
|
+
onPress={() => {
|
|
554
|
+
setPending(true);
|
|
555
|
+
setError(null);
|
|
556
|
+
onRerequest()
|
|
557
|
+
.catch((error_: unknown) => {
|
|
558
|
+
setError(errorToMessage(error_));
|
|
559
|
+
})
|
|
560
|
+
.finally(() => {
|
|
561
|
+
setPending(false);
|
|
562
|
+
});
|
|
563
|
+
}}
|
|
564
|
+
/>
|
|
565
|
+
{error === null ? null : (
|
|
566
|
+
<View className="basis-full">
|
|
567
|
+
<ErrorMessage>{error}</ErrorMessage>
|
|
568
|
+
</View>
|
|
569
|
+
)}
|
|
570
|
+
</>
|
|
571
|
+
);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function ReviewerLine({
|
|
575
|
+
name,
|
|
576
|
+
avatarUrl,
|
|
577
|
+
state,
|
|
578
|
+
onRerequest,
|
|
579
|
+
}: ReviewerLineProps): ReactNode {
|
|
580
|
+
return (
|
|
581
|
+
<View className="flex-row flex-wrap items-center gap-xs">
|
|
582
|
+
<PersonAvatar person={{ login: name, avatarUrl }} />
|
|
583
|
+
<Text className="min-w-0 flex-1 text-sm" numberOfLines={1}>
|
|
584
|
+
{name}
|
|
585
|
+
</Text>
|
|
586
|
+
{state === null ? (
|
|
587
|
+
<Badge accent="warning" size="sm">
|
|
588
|
+
awaiting
|
|
589
|
+
</Badge>
|
|
590
|
+
) : (
|
|
591
|
+
<Badge accent={reviewStateAccent(state)} size="sm">
|
|
592
|
+
{reviewStateLabel(state)}
|
|
593
|
+
</Badge>
|
|
594
|
+
)}
|
|
595
|
+
{onRerequest === undefined ? null : (
|
|
596
|
+
<RerequestButton name={name} onRerequest={onRerequest} />
|
|
597
|
+
)}
|
|
598
|
+
</View>
|
|
599
|
+
);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* GitHub's reviewers list: each person once — awaiting when asked (again),
|
|
604
|
+
* else their latest review — teams as asked.
|
|
605
|
+
*/
|
|
606
|
+
function ReviewersSummary({
|
|
607
|
+
detail,
|
|
608
|
+
candidates,
|
|
609
|
+
onEdit,
|
|
610
|
+
}: {
|
|
611
|
+
detail: PullRequestDetailResponse;
|
|
612
|
+
candidates: PullRequestCandidatesResponse | null;
|
|
613
|
+
onEdit: (edit: PullRequestEdit) => Promise<void>;
|
|
614
|
+
}): ReactNode {
|
|
615
|
+
const { pullRequest, viewer } = detail;
|
|
616
|
+
// a review carries only a login: its avatar is the candidate lists'
|
|
617
|
+
const avatarOf = (login: string): string | null =>
|
|
618
|
+
[
|
|
619
|
+
...(candidates?.suggestedReviewers ?? []),
|
|
620
|
+
...(candidates?.reviewerCandidates ?? []),
|
|
621
|
+
...(candidates?.assigneeCandidates ?? []),
|
|
622
|
+
].find((person) => person.login === login)?.avatarUrl ?? null;
|
|
623
|
+
const requestedLogins = new Set(
|
|
624
|
+
pullRequest.requestedReviewers.flatMap((reviewer) =>
|
|
625
|
+
reviewer.kind === "user" ? [reviewer.login] : [],
|
|
626
|
+
),
|
|
627
|
+
);
|
|
628
|
+
const answered = pullRequest.reviews.filter(
|
|
629
|
+
(review) => !requestedLogins.has(review.author),
|
|
630
|
+
);
|
|
631
|
+
if (pullRequest.requestedReviewers.length === 0 && answered.length === 0) {
|
|
632
|
+
return <Text className="text-sm text-muted">No reviews</Text>;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
return (
|
|
636
|
+
<View className="gap-xxs">
|
|
637
|
+
{answered.map((review) => (
|
|
638
|
+
<ReviewerLine
|
|
639
|
+
key={review.author}
|
|
640
|
+
name={review.author}
|
|
641
|
+
avatarUrl={avatarOf(review.author)}
|
|
642
|
+
state={review.state}
|
|
643
|
+
onRerequest={
|
|
644
|
+
pullRequest.viewerCanUpdate &&
|
|
645
|
+
review.author !== viewer &&
|
|
646
|
+
pullRequest.state !== "merged"
|
|
647
|
+
? async () => {
|
|
648
|
+
await onEdit({
|
|
649
|
+
kind: "reviewers",
|
|
650
|
+
add: [review.author],
|
|
651
|
+
remove: [],
|
|
652
|
+
addTeams: [],
|
|
653
|
+
removeTeams: [],
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
: undefined
|
|
657
|
+
}
|
|
658
|
+
/>
|
|
659
|
+
))}
|
|
660
|
+
{pullRequest.requestedReviewers.map((reviewer) =>
|
|
661
|
+
reviewer.kind === "user" ? (
|
|
662
|
+
<ReviewerLine
|
|
663
|
+
key={userValue(reviewer.login)}
|
|
664
|
+
name={reviewer.login}
|
|
665
|
+
avatarUrl={reviewer.avatarUrl}
|
|
666
|
+
state={null}
|
|
667
|
+
/>
|
|
668
|
+
) : (
|
|
669
|
+
<ReviewerLine
|
|
670
|
+
key={teamValue(reviewer.slug)}
|
|
671
|
+
name={reviewer.name}
|
|
672
|
+
avatarUrl={null}
|
|
673
|
+
state={null}
|
|
674
|
+
/>
|
|
675
|
+
),
|
|
676
|
+
)}
|
|
677
|
+
</View>
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
interface ReviewersValues {
|
|
682
|
+
members: string[];
|
|
683
|
+
teams: string[];
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Past this many people, the groups are tabs rather than stacked. */
|
|
687
|
+
const maxStackedReviewers = 5;
|
|
688
|
+
|
|
689
|
+
type ReviewerGroup = "members" | "teams";
|
|
690
|
+
|
|
691
|
+
const isReviewerGroup = (value: string): value is ReviewerGroup =>
|
|
692
|
+
value === "members" || value === "teams";
|
|
693
|
+
|
|
694
|
+
interface ReviewerGroupsProps {
|
|
695
|
+
tabbed: boolean;
|
|
696
|
+
/** each group's field, null when it offers no one */
|
|
697
|
+
members: ReactNode;
|
|
698
|
+
teams: ReactNode;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function ReviewerGroups({
|
|
702
|
+
tabbed,
|
|
703
|
+
members,
|
|
704
|
+
teams,
|
|
705
|
+
}: ReviewerGroupsProps): ReactNode {
|
|
706
|
+
const [tab, setTab] = useState<ReviewerGroup>(() =>
|
|
707
|
+
members === null ? "teams" : "members",
|
|
708
|
+
);
|
|
709
|
+
|
|
710
|
+
if (!tabbed) {
|
|
711
|
+
return (
|
|
712
|
+
<View className="gap-m">
|
|
713
|
+
{members}
|
|
714
|
+
{teams}
|
|
715
|
+
</View>
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
return (
|
|
720
|
+
<View className="gap-m">
|
|
721
|
+
<Tabs
|
|
722
|
+
aria-label="Reviewer groups"
|
|
723
|
+
value={tab}
|
|
724
|
+
onValueChange={(next) => {
|
|
725
|
+
if (isReviewerGroup(next)) setTab(next);
|
|
726
|
+
}}
|
|
727
|
+
>
|
|
728
|
+
{members === null ? null : <Tab value="members" label="Members" />}
|
|
729
|
+
{teams === null ? null : <Tab value="teams" label="Teams" />}
|
|
730
|
+
</Tabs>
|
|
731
|
+
{tab === "members" ? members : teams}
|
|
732
|
+
</View>
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
interface MemberCheckboxesProps {
|
|
737
|
+
title: string | null;
|
|
738
|
+
logins: string[];
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function MemberCheckboxes({ title, logins }: MemberCheckboxesProps): ReactNode {
|
|
742
|
+
if (logins.length === 0) return null;
|
|
743
|
+
return (
|
|
744
|
+
<View className="gap-xxs">
|
|
745
|
+
{title === null ? null : (
|
|
746
|
+
<Text className="font-body-bold text-xs text-muted">{title}</Text>
|
|
747
|
+
)}
|
|
748
|
+
{logins.map((login) => (
|
|
749
|
+
<Checkbox key={login} value={userValue(login)} label={login} />
|
|
750
|
+
))}
|
|
751
|
+
</View>
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function ReadOnlySection({
|
|
756
|
+
title,
|
|
757
|
+
children,
|
|
758
|
+
}: {
|
|
759
|
+
title: string;
|
|
760
|
+
children: ReactNode;
|
|
761
|
+
}): ReactNode {
|
|
762
|
+
return (
|
|
763
|
+
<View className="gap-sm">
|
|
764
|
+
<Text className="font-heading-bold text-xl">{title}</Text>
|
|
765
|
+
{children}
|
|
766
|
+
</View>
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Members: GitHub's suggestions grouped first, then anyone else already asked,
|
|
772
|
+
* then the repository's assignable users; those already asked lead each group.
|
|
773
|
+
* Teams: those already asked, then the rest.
|
|
774
|
+
*/
|
|
775
|
+
function Reviewers({
|
|
776
|
+
detail,
|
|
777
|
+
candidates,
|
|
778
|
+
onEdited,
|
|
779
|
+
}: CandidatesProps): ReactNode {
|
|
780
|
+
const { pullRequest } = detail;
|
|
781
|
+
const editAndRefresh = useEditAndRefresh({ detail, onEdited });
|
|
782
|
+
const suggestedLogins = (candidates?.suggestedReviewers ?? []).map(
|
|
783
|
+
({ login }) => login,
|
|
784
|
+
);
|
|
785
|
+
const reviewerCandidates = candidates?.reviewerCandidates ?? [];
|
|
786
|
+
const teamCandidates = candidates?.teamCandidates ?? [];
|
|
787
|
+
const requestedLogins = pullRequest.requestedReviewers.flatMap((reviewer) =>
|
|
788
|
+
reviewer.kind === "user" ? [reviewer.login] : [],
|
|
789
|
+
);
|
|
790
|
+
const requestedTeams = pullRequest.requestedReviewers.flatMap((reviewer) =>
|
|
791
|
+
reviewer.kind === "team" ? [reviewer] : [],
|
|
792
|
+
);
|
|
793
|
+
const requestedSuggested = requestedLogins.filter((login) =>
|
|
794
|
+
suggestedLogins.includes(login),
|
|
795
|
+
);
|
|
796
|
+
const requestedMembers = requestedLogins.filter(
|
|
797
|
+
(login) => !suggestedLogins.includes(login),
|
|
798
|
+
);
|
|
799
|
+
const teams = [
|
|
800
|
+
...requestedTeams,
|
|
801
|
+
...teamCandidates.filter(
|
|
802
|
+
({ slug }) => !requestedTeams.some((team) => team.slug === slug),
|
|
803
|
+
),
|
|
804
|
+
];
|
|
805
|
+
const suggested = [
|
|
806
|
+
...requestedSuggested,
|
|
807
|
+
...suggestedLogins.filter((login) => !requestedLogins.includes(login)),
|
|
808
|
+
];
|
|
809
|
+
const otherMembers = [
|
|
810
|
+
...requestedMembers,
|
|
811
|
+
...reviewerCandidates
|
|
812
|
+
.map(({ login }) => login)
|
|
813
|
+
.filter(
|
|
814
|
+
(login) =>
|
|
815
|
+
!requestedLogins.includes(login) && !suggestedLogins.includes(login),
|
|
816
|
+
),
|
|
817
|
+
];
|
|
818
|
+
const memberCount = suggested.length + otherMembers.length;
|
|
819
|
+
|
|
820
|
+
if (!pullRequest.viewerCanUpdate || candidates === null) {
|
|
821
|
+
return (
|
|
822
|
+
<ReadOnlySection title="Reviewers">
|
|
823
|
+
<ReviewersSummary
|
|
824
|
+
detail={detail}
|
|
825
|
+
candidates={candidates}
|
|
826
|
+
onEdit={editAndRefresh}
|
|
827
|
+
/>
|
|
828
|
+
</ReadOnlySection>
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
return (
|
|
833
|
+
<FormEditableSection<ReviewersValues>
|
|
834
|
+
title="Reviewers"
|
|
835
|
+
editAriaLabel="Edit the requested reviewers"
|
|
836
|
+
cancelLabel="Cancel"
|
|
837
|
+
submitLabel="Save"
|
|
838
|
+
submitErrorToMessage={errorToMessage}
|
|
839
|
+
defaultValues={{
|
|
840
|
+
members: requestedLogins.map(userValue),
|
|
841
|
+
teams: requestedTeams.map(({ slug }) => teamValue(slug)),
|
|
842
|
+
}}
|
|
843
|
+
render={({ control }) => (
|
|
844
|
+
<ReviewerGroups
|
|
845
|
+
tabbed={memberCount > maxStackedReviewers}
|
|
846
|
+
members={
|
|
847
|
+
memberCount === 0 ? null : (
|
|
848
|
+
<FormField
|
|
849
|
+
control={control}
|
|
850
|
+
name="members"
|
|
851
|
+
label="Members"
|
|
852
|
+
render={({ field, labelId }) => (
|
|
853
|
+
<CheckboxGroup
|
|
854
|
+
values={field.value}
|
|
855
|
+
aria-labelledby={labelId}
|
|
856
|
+
onValuesChange={field.onChange}
|
|
857
|
+
>
|
|
858
|
+
<View className="gap-sm">
|
|
859
|
+
<MemberCheckboxes title="Suggested" logins={suggested} />
|
|
860
|
+
<MemberCheckboxes
|
|
861
|
+
title={suggested.length === 0 ? null : "Others"}
|
|
862
|
+
logins={otherMembers}
|
|
863
|
+
/>
|
|
864
|
+
</View>
|
|
865
|
+
</CheckboxGroup>
|
|
866
|
+
)}
|
|
867
|
+
/>
|
|
868
|
+
)
|
|
869
|
+
}
|
|
870
|
+
teams={
|
|
871
|
+
teams.length === 0 ? null : (
|
|
872
|
+
<FormField
|
|
873
|
+
control={control}
|
|
874
|
+
name="teams"
|
|
875
|
+
label="Teams"
|
|
876
|
+
render={({ field, labelId }) => (
|
|
877
|
+
<CheckboxGroup
|
|
878
|
+
values={field.value}
|
|
879
|
+
aria-labelledby={labelId}
|
|
880
|
+
onValuesChange={field.onChange}
|
|
881
|
+
>
|
|
882
|
+
<View className="gap-xxs">
|
|
883
|
+
{teams.map((team) => (
|
|
884
|
+
<Checkbox
|
|
885
|
+
key={team.slug}
|
|
886
|
+
value={teamValue(team.slug)}
|
|
887
|
+
label={team.name}
|
|
888
|
+
/>
|
|
889
|
+
))}
|
|
890
|
+
</View>
|
|
891
|
+
</CheckboxGroup>
|
|
892
|
+
)}
|
|
893
|
+
/>
|
|
894
|
+
)
|
|
895
|
+
}
|
|
896
|
+
/>
|
|
897
|
+
)}
|
|
898
|
+
onSubmit={async ({ members: selectedMembers, teams: selectedTeams }) => {
|
|
899
|
+
const edit = reviewersEdit(pullRequest, [
|
|
900
|
+
...selectedMembers,
|
|
901
|
+
...selectedTeams,
|
|
902
|
+
]);
|
|
903
|
+
await editAndRefresh(edit);
|
|
904
|
+
}}
|
|
905
|
+
>
|
|
906
|
+
<ReviewersSummary
|
|
907
|
+
detail={detail}
|
|
908
|
+
candidates={candidates}
|
|
909
|
+
onEdit={editAndRefresh}
|
|
910
|
+
/>
|
|
911
|
+
</FormEditableSection>
|
|
912
|
+
);
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/** Where GitHub puts it: under the reviewers, the rare way back. */
|
|
916
|
+
function ConvertToDraft({ detail, onEdited }: EditableProps): ReactNode {
|
|
917
|
+
const { pullRequest } = detail;
|
|
918
|
+
const editAndRefresh = useEditAndRefresh({ detail, onEdited });
|
|
919
|
+
if (pullRequest.state !== "open" || !pullRequest.viewerCanUpdate) return null;
|
|
920
|
+
return (
|
|
921
|
+
<View className="flex-row flex-wrap items-center gap-xs">
|
|
922
|
+
<Text className="text-xs text-muted">Still in progress?</Text>
|
|
923
|
+
<ActionButton
|
|
924
|
+
text="Convert to draft"
|
|
925
|
+
accent="neutral"
|
|
926
|
+
variant="tonal"
|
|
927
|
+
size="sm"
|
|
928
|
+
errorToMessage={errorToMessage}
|
|
929
|
+
onPress={async () => {
|
|
930
|
+
const edit: PullRequestEdit = { kind: "draft", draft: true };
|
|
931
|
+
await editAndRefresh(edit);
|
|
932
|
+
}}
|
|
933
|
+
/>
|
|
934
|
+
</View>
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
interface AssigneesValues {
|
|
939
|
+
assignees: string[];
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function AssigneesSummary({
|
|
943
|
+
pullRequest,
|
|
944
|
+
}: {
|
|
945
|
+
pullRequest: PullRequestDetail;
|
|
946
|
+
}): ReactNode {
|
|
947
|
+
if (pullRequest.assignees.length === 0) {
|
|
948
|
+
return <Text className="text-sm text-muted">No one assigned</Text>;
|
|
949
|
+
}
|
|
950
|
+
return (
|
|
951
|
+
<View className="gap-xs">
|
|
952
|
+
{pullRequest.assignees.map((assignee) => (
|
|
953
|
+
<View key={assignee.login} className="flex-row items-center gap-xs">
|
|
954
|
+
<PersonAvatar person={assignee} />
|
|
955
|
+
<Text className="min-w-0 flex-1 text-sm" numberOfLines={1}>
|
|
956
|
+
{assignee.login}
|
|
957
|
+
</Text>
|
|
958
|
+
</View>
|
|
959
|
+
))}
|
|
960
|
+
</View>
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Current assignees first, then the repository's assignable users. */
|
|
965
|
+
function Assignees({
|
|
966
|
+
detail,
|
|
967
|
+
candidates,
|
|
968
|
+
onEdited,
|
|
969
|
+
}: CandidatesProps): ReactNode {
|
|
970
|
+
const { pullRequest } = detail;
|
|
971
|
+
const editAndRefresh = useEditAndRefresh({ detail, onEdited });
|
|
972
|
+
const current = pullRequest.assignees.map(({ login }) => login);
|
|
973
|
+
const offered = [
|
|
974
|
+
...new Set([
|
|
975
|
+
...current,
|
|
976
|
+
...(candidates?.assigneeCandidates ?? []).map(({ login }) => login),
|
|
977
|
+
]),
|
|
978
|
+
];
|
|
979
|
+
|
|
980
|
+
if (!pullRequest.viewerCanUpdate || candidates === null) {
|
|
981
|
+
return (
|
|
982
|
+
<ReadOnlySection title="Assignees">
|
|
983
|
+
<AssigneesSummary pullRequest={pullRequest} />
|
|
984
|
+
</ReadOnlySection>
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
return (
|
|
989
|
+
<FormEditableSection<AssigneesValues>
|
|
990
|
+
title="Assignees"
|
|
991
|
+
editAriaLabel="Edit the pull request's assignees"
|
|
992
|
+
cancelLabel="Cancel"
|
|
993
|
+
submitLabel="Save"
|
|
994
|
+
submitErrorToMessage={errorToMessage}
|
|
995
|
+
defaultValues={{ assignees: current }}
|
|
996
|
+
render={({ control }) => (
|
|
997
|
+
<FormField
|
|
998
|
+
control={control}
|
|
999
|
+
name="assignees"
|
|
1000
|
+
label="Assign to"
|
|
1001
|
+
render={({ field, labelId }) => (
|
|
1002
|
+
<CheckboxGroup
|
|
1003
|
+
values={field.value}
|
|
1004
|
+
aria-labelledby={labelId}
|
|
1005
|
+
onValuesChange={field.onChange}
|
|
1006
|
+
>
|
|
1007
|
+
<View className="gap-xxs">
|
|
1008
|
+
{offered.map((login) => (
|
|
1009
|
+
<Checkbox key={login} value={login} label={login} />
|
|
1010
|
+
))}
|
|
1011
|
+
</View>
|
|
1012
|
+
</CheckboxGroup>
|
|
1013
|
+
)}
|
|
1014
|
+
/>
|
|
1015
|
+
)}
|
|
1016
|
+
onSubmit={async ({ assignees }) => {
|
|
1017
|
+
const before = new Set(current);
|
|
1018
|
+
const after = new Set(assignees);
|
|
1019
|
+
const edit: PullRequestEdit = {
|
|
1020
|
+
kind: "assignees",
|
|
1021
|
+
add: assignees.filter((login) => !before.has(login)),
|
|
1022
|
+
remove: current.filter((login) => !after.has(login)),
|
|
1023
|
+
};
|
|
1024
|
+
await editAndRefresh(edit);
|
|
1025
|
+
}}
|
|
1026
|
+
>
|
|
1027
|
+
<AssigneesSummary pullRequest={pullRequest} />
|
|
1028
|
+
</FormEditableSection>
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
interface LabelsValues {
|
|
1033
|
+
labels: string[];
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function Labels({ detail, candidates, onEdited }: CandidatesProps): ReactNode {
|
|
1037
|
+
const { pullRequest } = detail;
|
|
1038
|
+
const editAndRefresh = useEditAndRefresh({ detail, onEdited });
|
|
1039
|
+
const current = pullRequest.labels.map(({ name }) => name);
|
|
1040
|
+
const offered = [
|
|
1041
|
+
...new Set([
|
|
1042
|
+
...current,
|
|
1043
|
+
...(candidates?.repoLabels ?? []).map(({ name }) => name),
|
|
1044
|
+
]),
|
|
1045
|
+
];
|
|
1046
|
+
const summary =
|
|
1047
|
+
pullRequest.labels.length === 0 ? (
|
|
1048
|
+
<Text className="text-sm text-muted">None yet</Text>
|
|
1049
|
+
) : (
|
|
1050
|
+
<View className="flex-row flex-wrap gap-xxs">
|
|
1051
|
+
{pullRequest.labels.map((label) => (
|
|
1052
|
+
<LabelPill key={label.name} label={label} />
|
|
1053
|
+
))}
|
|
1054
|
+
</View>
|
|
1055
|
+
);
|
|
1056
|
+
|
|
1057
|
+
if (!pullRequest.viewerCanUpdate || candidates === null) {
|
|
1058
|
+
return <ReadOnlySection title="Labels">{summary}</ReadOnlySection>;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
return (
|
|
1062
|
+
<FormEditableSection<LabelsValues>
|
|
1063
|
+
title="Labels"
|
|
1064
|
+
editAriaLabel="Edit the pull request's labels"
|
|
1065
|
+
cancelLabel="Cancel"
|
|
1066
|
+
submitLabel="Save"
|
|
1067
|
+
submitErrorToMessage={errorToMessage}
|
|
1068
|
+
defaultValues={{ labels: current }}
|
|
1069
|
+
render={({ control }) => (
|
|
1070
|
+
<FormField
|
|
1071
|
+
control={control}
|
|
1072
|
+
name="labels"
|
|
1073
|
+
label="Apply labels"
|
|
1074
|
+
render={({ field, labelId }) => (
|
|
1075
|
+
<CheckboxGroup
|
|
1076
|
+
values={field.value}
|
|
1077
|
+
aria-labelledby={labelId}
|
|
1078
|
+
onValuesChange={field.onChange}
|
|
1079
|
+
>
|
|
1080
|
+
<View className="gap-xxs">
|
|
1081
|
+
{offered.map((name) => (
|
|
1082
|
+
<Checkbox key={name} value={name} label={emojify(name)} />
|
|
1083
|
+
))}
|
|
1084
|
+
</View>
|
|
1085
|
+
</CheckboxGroup>
|
|
1086
|
+
)}
|
|
1087
|
+
/>
|
|
1088
|
+
)}
|
|
1089
|
+
onSubmit={async ({ labels }) => {
|
|
1090
|
+
const before = new Set(current);
|
|
1091
|
+
const after = new Set(labels);
|
|
1092
|
+
const edit: PullRequestEdit = {
|
|
1093
|
+
kind: "labels",
|
|
1094
|
+
add: labels.filter((name) => !before.has(name)),
|
|
1095
|
+
remove: current.filter((name) => !after.has(name)),
|
|
1096
|
+
};
|
|
1097
|
+
await editAndRefresh(edit);
|
|
1098
|
+
}}
|
|
1099
|
+
>
|
|
1100
|
+
{summary}
|
|
1101
|
+
</FormEditableSection>
|
|
1102
|
+
);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/** The status, then GitHub's sidebar order: reviewers, assignees, labels. */
|
|
1106
|
+
function Sidebar({ detail, candidates, onEdited }: CandidatesProps): ReactNode {
|
|
1107
|
+
return (
|
|
1108
|
+
<View className="gap-xxl md:w-[296px] md:shrink-0">
|
|
1109
|
+
<Status detail={detail} onEdited={onEdited} />
|
|
1110
|
+
|
|
1111
|
+
<View className="gap-xs">
|
|
1112
|
+
<Reviewers
|
|
1113
|
+
detail={detail}
|
|
1114
|
+
candidates={candidates}
|
|
1115
|
+
onEdited={onEdited}
|
|
1116
|
+
/>
|
|
1117
|
+
<ConvertToDraft detail={detail} onEdited={onEdited} />
|
|
1118
|
+
</View>
|
|
1119
|
+
|
|
1120
|
+
<Assignees detail={detail} candidates={candidates} onEdited={onEdited} />
|
|
1121
|
+
|
|
1122
|
+
<Labels detail={detail} candidates={candidates} onEdited={onEdited} />
|
|
1123
|
+
</View>
|
|
1124
|
+
);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
export type PullRequestTab =
|
|
1128
|
+
| "checks"
|
|
1129
|
+
| "commits"
|
|
1130
|
+
| "files"
|
|
1131
|
+
| "review"
|
|
1132
|
+
| "timeline";
|
|
1133
|
+
|
|
1134
|
+
export const isPullRequestTab = (value: unknown): value is PullRequestTab =>
|
|
1135
|
+
value === "checks" ||
|
|
1136
|
+
value === "commits" ||
|
|
1137
|
+
value === "timeline" ||
|
|
1138
|
+
value === "files" ||
|
|
1139
|
+
value === "review";
|
|
1140
|
+
|
|
1141
|
+
/** The timeline opens on the description, as GitHub's first comment. */
|
|
1142
|
+
function TimelineTab({
|
|
1143
|
+
detail,
|
|
1144
|
+
candidates,
|
|
1145
|
+
onEdited,
|
|
1146
|
+
}: CandidatesProps): ReactNode {
|
|
1147
|
+
return (
|
|
1148
|
+
<View className="flex-col gap-l md:flex-row md:items-start">
|
|
1149
|
+
<View className="min-w-0 gap-m md:flex-1">
|
|
1150
|
+
<PullRequestTimeline detail={detail} onEdited={onEdited} />
|
|
1151
|
+
</View>
|
|
1152
|
+
<Sidebar detail={detail} candidates={candidates} onEdited={onEdited} />
|
|
1153
|
+
</View>
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
/** Beside the tabs, so the merge and the reopen are in reach from every tab. */
|
|
1158
|
+
function PullRequestActions({ detail, onEdited }: EditableProps): ReactNode {
|
|
1159
|
+
const onEdit = useEditAndRefresh({ detail, onEdited });
|
|
1160
|
+
return (
|
|
1161
|
+
<View className="flex-row flex-wrap items-center gap-xs">
|
|
1162
|
+
<PullRequestReopen detail={detail} onEdit={onEdit} />
|
|
1163
|
+
<PullRequestMergeButton detail={detail} onEdit={onEdit} />
|
|
1164
|
+
</View>
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
export interface PullRequestPanelProps extends CandidatesProps {
|
|
1169
|
+
/** why the editors stay read-only, when GitHub refused what they offer */
|
|
1170
|
+
candidatesError: string | null;
|
|
1171
|
+
tab: PullRequestTab;
|
|
1172
|
+
onTabChange: (tab: PullRequestTab) => void;
|
|
1173
|
+
/** the commit whose diff is open on the Commits tab; absent: the tab holds it */
|
|
1174
|
+
openCommit?: string | null;
|
|
1175
|
+
onOpenCommitChange?: (oid: string | null) => void;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* One pull request as GitHub reports it now, laid out as GitHub's own page —
|
|
1180
|
+
* the header, then Timeline (the description, a status box and a narrow
|
|
1181
|
+
* sidebar), Checks and Files changed — with the edits the operator makes
|
|
1182
|
+
* before asking for a review. Every edit is GitHub's to refuse, and the panel
|
|
1183
|
+
* re-reads the pull request after it, so nothing on screen is the request
|
|
1184
|
+
* echoed back.
|
|
1185
|
+
*/
|
|
1186
|
+
export function PullRequestPanel({
|
|
1187
|
+
detail,
|
|
1188
|
+
candidates,
|
|
1189
|
+
candidatesError,
|
|
1190
|
+
onEdited,
|
|
1191
|
+
tab,
|
|
1192
|
+
onTabChange,
|
|
1193
|
+
openCommit,
|
|
1194
|
+
onOpenCommitChange,
|
|
1195
|
+
}: PullRequestPanelProps): ReactNode {
|
|
1196
|
+
const { pullRequest, errors } = detail;
|
|
1197
|
+
const checks = usePullRequestDataSource().usePart(
|
|
1198
|
+
pullRequest,
|
|
1199
|
+
"checks",
|
|
1200
|
+
).value;
|
|
1201
|
+
return (
|
|
1202
|
+
<View className="gap-l">
|
|
1203
|
+
{[...new Set([...errors, ...(candidates?.errors ?? [])])].map((error) => (
|
|
1204
|
+
<WarningMessage key={error}>{error}</WarningMessage>
|
|
1205
|
+
))}
|
|
1206
|
+
{candidatesError === null ? null : (
|
|
1207
|
+
<WarningMessage>
|
|
1208
|
+
{`Reviewers, assignees and labels cannot be edited: ${candidatesError}`}
|
|
1209
|
+
</WarningMessage>
|
|
1210
|
+
)}
|
|
1211
|
+
<Header detail={detail} onEdited={onEdited} />
|
|
1212
|
+
<ConflictWarning pullRequest={pullRequest} />
|
|
1213
|
+
<View className="flex-row flex-wrap items-center justify-between gap-sm">
|
|
1214
|
+
<Tabs
|
|
1215
|
+
aria-label="Pull request sections"
|
|
1216
|
+
value={tab}
|
|
1217
|
+
onValueChange={(next) => {
|
|
1218
|
+
if (isPullRequestTab(next)) onTabChange(next);
|
|
1219
|
+
}}
|
|
1220
|
+
>
|
|
1221
|
+
<Tab value="timeline" label="Conversation" />
|
|
1222
|
+
<Tab
|
|
1223
|
+
value="commits"
|
|
1224
|
+
label={`Commits (${String(pullRequest.commitsTotal)})`}
|
|
1225
|
+
/>
|
|
1226
|
+
<Tab
|
|
1227
|
+
value="checks"
|
|
1228
|
+
label={
|
|
1229
|
+
checks === null
|
|
1230
|
+
? "Checks"
|
|
1231
|
+
: `Checks (${String(checks.checksTotal)})`
|
|
1232
|
+
}
|
|
1233
|
+
/>
|
|
1234
|
+
<Tab
|
|
1235
|
+
value="files"
|
|
1236
|
+
label={`Files changed (${String(pullRequest.changedFiles)})`}
|
|
1237
|
+
/>
|
|
1238
|
+
<Tab value="review" label="Review" />
|
|
1239
|
+
</Tabs>
|
|
1240
|
+
<PullRequestActions detail={detail} onEdited={onEdited} />
|
|
1241
|
+
</View>
|
|
1242
|
+
{(() => {
|
|
1243
|
+
if (tab === "commits") {
|
|
1244
|
+
return (
|
|
1245
|
+
<PullRequestCommits
|
|
1246
|
+
detail={detail}
|
|
1247
|
+
openCommit={openCommit}
|
|
1248
|
+
onEdited={onEdited}
|
|
1249
|
+
onOpenCommitChange={onOpenCommitChange}
|
|
1250
|
+
/>
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
if (tab === "checks") return <ChecksTab pullRequest={pullRequest} />;
|
|
1254
|
+
if (tab === "files") {
|
|
1255
|
+
return <PullRequestFiles detail={detail} onEdited={onEdited} />;
|
|
1256
|
+
}
|
|
1257
|
+
if (tab === "review") {
|
|
1258
|
+
return <PullRequestReview detail={detail} onEdited={onEdited} />;
|
|
1259
|
+
}
|
|
1260
|
+
return (
|
|
1261
|
+
<TimelineTab
|
|
1262
|
+
detail={detail}
|
|
1263
|
+
candidates={candidates}
|
|
1264
|
+
onEdited={onEdited}
|
|
1265
|
+
/>
|
|
1266
|
+
);
|
|
1267
|
+
})()}
|
|
1268
|
+
</View>
|
|
1269
|
+
);
|
|
1270
|
+
}
|