@reviewflow/ui 0.0.0-stage → 6.22.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 +101 -3
- 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,341 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PullRequestCommittingMergeMethod,
|
|
3
|
+
PullRequestDetail,
|
|
4
|
+
PullRequestMergeCommitMessage,
|
|
5
|
+
PullRequestMergeMethod,
|
|
6
|
+
PullRequestMergeState,
|
|
7
|
+
} from "@reviewflow/sdk";
|
|
8
|
+
import {
|
|
9
|
+
ActionButton,
|
|
10
|
+
Button,
|
|
11
|
+
FormItem,
|
|
12
|
+
InputText,
|
|
13
|
+
QuestionAlertDialog,
|
|
14
|
+
SplitButton,
|
|
15
|
+
SplitButtonOption,
|
|
16
|
+
Text,
|
|
17
|
+
TextArea,
|
|
18
|
+
View,
|
|
19
|
+
} from "alouette";
|
|
20
|
+
import type { ReactNode } from "react";
|
|
21
|
+
import { useState } from "react";
|
|
22
|
+
import { errorToMessage } from "../services/errorToMessage.ts";
|
|
23
|
+
import type { PullRequestActionProps } from "./pull-request-actions-review.tsx";
|
|
24
|
+
|
|
25
|
+
const isMergeMethod = (value: string): value is PullRequestMergeMethod =>
|
|
26
|
+
value === "merge" || value === "squash" || value === "rebase";
|
|
27
|
+
|
|
28
|
+
const mergeMethodLabel = (method: PullRequestMergeMethod): string => {
|
|
29
|
+
if (method === "squash") return "Squash and merge";
|
|
30
|
+
if (method === "rebase") return "Rebase and merge";
|
|
31
|
+
return "Create a merge commit";
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** null: GitHub would take the merge. `unstable` is non-required checks failing. */
|
|
35
|
+
const mergeBlockReason = (state: PullRequestMergeState): string | null => {
|
|
36
|
+
switch (state) {
|
|
37
|
+
case "clean":
|
|
38
|
+
case "hasHooks":
|
|
39
|
+
case "unstable":
|
|
40
|
+
return null;
|
|
41
|
+
case "behind":
|
|
42
|
+
return "The branch is behind its base: update it first.";
|
|
43
|
+
case "blocked":
|
|
44
|
+
return "Blocked: required reviews or checks are missing.";
|
|
45
|
+
case "dirty":
|
|
46
|
+
return "The branch has conflicts that must be resolved.";
|
|
47
|
+
case "draft":
|
|
48
|
+
return "A draft cannot be merged.";
|
|
49
|
+
case "unknown":
|
|
50
|
+
return "GitHub is still computing mergeability: refresh in a moment.";
|
|
51
|
+
default: {
|
|
52
|
+
const unhandled: never = state;
|
|
53
|
+
return unhandled;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
interface MergeButtonProps {
|
|
59
|
+
mergeMethods: readonly PullRequestMergeMethod[];
|
|
60
|
+
method: PullRequestMergeMethod | undefined;
|
|
61
|
+
blockReason: string | null;
|
|
62
|
+
onMethodChange: (method: PullRequestMergeMethod) => void;
|
|
63
|
+
onPress: () => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* `SplitButton` has no `disabledReason`: the Status says why anyway, so only
|
|
68
|
+
* the single-method `Button` carries it.
|
|
69
|
+
*/
|
|
70
|
+
function MergeButton({
|
|
71
|
+
mergeMethods,
|
|
72
|
+
method,
|
|
73
|
+
blockReason,
|
|
74
|
+
onMethodChange,
|
|
75
|
+
onPress,
|
|
76
|
+
}: MergeButtonProps): ReactNode {
|
|
77
|
+
if (method === undefined || mergeMethods.length < 2) {
|
|
78
|
+
return (
|
|
79
|
+
<Button
|
|
80
|
+
text={method === undefined ? "Merge" : mergeMethodLabel(method)}
|
|
81
|
+
accent="success"
|
|
82
|
+
variant="tonal"
|
|
83
|
+
size="sm"
|
|
84
|
+
disabledReason={blockReason}
|
|
85
|
+
onPress={onPress}
|
|
86
|
+
/>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<SplitButton
|
|
92
|
+
menuLabel="Merge method"
|
|
93
|
+
value={method}
|
|
94
|
+
accent="success"
|
|
95
|
+
variant="tonal"
|
|
96
|
+
size="sm"
|
|
97
|
+
disabled={blockReason !== null}
|
|
98
|
+
onValueChange={(next) => {
|
|
99
|
+
if (isMergeMethod(next)) onMethodChange(next);
|
|
100
|
+
}}
|
|
101
|
+
onPress={onPress}
|
|
102
|
+
>
|
|
103
|
+
{mergeMethods.includes("merge") ? (
|
|
104
|
+
<SplitButtonOption value="merge" label={mergeMethodLabel("merge")} />
|
|
105
|
+
) : null}
|
|
106
|
+
{mergeMethods.includes("squash") ? (
|
|
107
|
+
<SplitButtonOption value="squash" label={mergeMethodLabel("squash")} />
|
|
108
|
+
) : null}
|
|
109
|
+
{mergeMethods.includes("rebase") ? (
|
|
110
|
+
<SplitButtonOption value="rebase" label={mergeMethodLabel("rebase")} />
|
|
111
|
+
) : null}
|
|
112
|
+
</SplitButton>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const pullRequestBlockReason = (
|
|
117
|
+
pullRequest: PullRequestDetail,
|
|
118
|
+
): string | null =>
|
|
119
|
+
pullRequest.mergeMethods.length === 0
|
|
120
|
+
? "The repository allows no merge method."
|
|
121
|
+
: mergeBlockReason(pullRequest.mergeState);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* GitHub's merge box, the part read rather than pressed, for the Status: why
|
|
125
|
+
* the merge is refused, and Update branch when the base moved. `blocked` is
|
|
126
|
+
* left out: the checks and conversations listed beside it are the reason.
|
|
127
|
+
*/
|
|
128
|
+
export function PullRequestMergeStatus({
|
|
129
|
+
detail,
|
|
130
|
+
onEdit,
|
|
131
|
+
}: PullRequestActionProps): ReactNode {
|
|
132
|
+
const { pullRequest } = detail;
|
|
133
|
+
if (pullRequest.state !== "open") return null;
|
|
134
|
+
|
|
135
|
+
const blockReason =
|
|
136
|
+
pullRequest.mergeState === "blocked"
|
|
137
|
+
? null
|
|
138
|
+
: pullRequestBlockReason(pullRequest);
|
|
139
|
+
const unstable = pullRequest.mergeState === "unstable";
|
|
140
|
+
const canUpdateBranch =
|
|
141
|
+
pullRequest.viewerCanUpdateBranch || pullRequest.mergeState === "behind";
|
|
142
|
+
if (blockReason === null && !unstable && !canUpdateBranch) return null;
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<View className="items-start gap-xs">
|
|
146
|
+
{blockReason === null ? null : (
|
|
147
|
+
<Text className="text-xs text-muted">{blockReason}</Text>
|
|
148
|
+
)}
|
|
149
|
+
{unstable ? (
|
|
150
|
+
<Text className="text-xs text-muted">
|
|
151
|
+
Some checks that are not required are failing.
|
|
152
|
+
</Text>
|
|
153
|
+
) : null}
|
|
154
|
+
{canUpdateBranch ? (
|
|
155
|
+
<ActionButton
|
|
156
|
+
text="Update branch"
|
|
157
|
+
accent="neutral"
|
|
158
|
+
variant="tonal"
|
|
159
|
+
size="sm"
|
|
160
|
+
errorToMessage={errorToMessage}
|
|
161
|
+
onPress={async () => {
|
|
162
|
+
await onEdit({
|
|
163
|
+
kind: "updateBranch",
|
|
164
|
+
expectedHeadSha: pullRequest.headSha,
|
|
165
|
+
});
|
|
166
|
+
}}
|
|
167
|
+
/>
|
|
168
|
+
) : null}
|
|
169
|
+
</View>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const isCommittingMergeMethod = (
|
|
174
|
+
method: PullRequestMergeMethod,
|
|
175
|
+
): method is PullRequestCommittingMergeMethod => method !== "rebase";
|
|
176
|
+
|
|
177
|
+
interface CommitMessageDraft {
|
|
178
|
+
prefilled: PullRequestMergeCommitMessage;
|
|
179
|
+
draft: PullRequestMergeCommitMessage;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const editedCommitMessage = (
|
|
183
|
+
commit: CommitMessageDraft | null,
|
|
184
|
+
): PullRequestMergeCommitMessage | undefined => {
|
|
185
|
+
if (commit === null) return undefined;
|
|
186
|
+
const { prefilled, draft } = commit;
|
|
187
|
+
if (draft.title === prefilled.title && draft.body === prefilled.body) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
return draft;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
interface MergeCommitMessageFieldsProps {
|
|
194
|
+
value: PullRequestMergeCommitMessage;
|
|
195
|
+
onChange: (value: PullRequestMergeCommitMessage) => void;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function MergeCommitMessageFields({
|
|
199
|
+
value,
|
|
200
|
+
onChange,
|
|
201
|
+
}: MergeCommitMessageFieldsProps): ReactNode {
|
|
202
|
+
return (
|
|
203
|
+
<View className="gap-m">
|
|
204
|
+
<FormItem
|
|
205
|
+
label="Commit message"
|
|
206
|
+
render={({ labelId, describedBy, invalid, required }) => (
|
|
207
|
+
<InputText
|
|
208
|
+
aria-labelledby={labelId}
|
|
209
|
+
aria-describedby={describedBy}
|
|
210
|
+
aria-required={required}
|
|
211
|
+
invalid={invalid}
|
|
212
|
+
value={value.title}
|
|
213
|
+
onChangeText={(title) => {
|
|
214
|
+
onChange({ ...value, title });
|
|
215
|
+
}}
|
|
216
|
+
/>
|
|
217
|
+
)}
|
|
218
|
+
/>
|
|
219
|
+
<FormItem
|
|
220
|
+
label="Extended description"
|
|
221
|
+
render={({ labelId, describedBy, invalid, required }) => (
|
|
222
|
+
<TextArea
|
|
223
|
+
aria-labelledby={labelId}
|
|
224
|
+
aria-describedby={describedBy}
|
|
225
|
+
aria-required={required}
|
|
226
|
+
invalid={invalid}
|
|
227
|
+
value={value.body}
|
|
228
|
+
numberOfLines={6}
|
|
229
|
+
onChangeText={(body) => {
|
|
230
|
+
onChange({ ...value, body });
|
|
231
|
+
}}
|
|
232
|
+
/>
|
|
233
|
+
)}
|
|
234
|
+
/>
|
|
235
|
+
</View>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The merge itself: a method among what the repository allows, behind a
|
|
241
|
+
* confirmation. The button is an affordance read from a state that may be
|
|
242
|
+
* stale; GitHub's refusal, with the head sha the screen saw, is the guard.
|
|
243
|
+
* The confirmation shows the commit GitHub would write, sent only when edited
|
|
244
|
+
* so that an untouched one stays GitHub's own.
|
|
245
|
+
*/
|
|
246
|
+
export function PullRequestMergeButton({
|
|
247
|
+
detail,
|
|
248
|
+
onEdit,
|
|
249
|
+
}: PullRequestActionProps): ReactNode {
|
|
250
|
+
const { pullRequest } = detail;
|
|
251
|
+
const [chosen, setChosen] = useState<PullRequestMergeMethod | null>(null);
|
|
252
|
+
const [confirming, setConfirming] = useState(false);
|
|
253
|
+
const [commit, setCommit] = useState<CommitMessageDraft | null>(null);
|
|
254
|
+
if (pullRequest.state !== "open") return null;
|
|
255
|
+
|
|
256
|
+
const { mergeMethods } = pullRequest;
|
|
257
|
+
const method =
|
|
258
|
+
chosen !== null && mergeMethods.includes(chosen) ? chosen : mergeMethods[0];
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<>
|
|
262
|
+
<MergeButton
|
|
263
|
+
mergeMethods={mergeMethods}
|
|
264
|
+
method={method}
|
|
265
|
+
blockReason={pullRequestBlockReason(pullRequest)}
|
|
266
|
+
onMethodChange={setChosen}
|
|
267
|
+
onPress={() => {
|
|
268
|
+
if (method === undefined) return;
|
|
269
|
+
const prefilled = isCommittingMergeMethod(method)
|
|
270
|
+
? pullRequest.mergeCommitMessages[method]
|
|
271
|
+
: null;
|
|
272
|
+
setCommit(
|
|
273
|
+
prefilled === null ? null : { prefilled, draft: prefilled },
|
|
274
|
+
);
|
|
275
|
+
setConfirming(true);
|
|
276
|
+
}}
|
|
277
|
+
/>
|
|
278
|
+
{method === undefined ? null : (
|
|
279
|
+
<QuestionAlertDialog
|
|
280
|
+
visible={confirming}
|
|
281
|
+
accent="success"
|
|
282
|
+
title="Merge this pull request?"
|
|
283
|
+
confirmText={mergeMethodLabel(method)}
|
|
284
|
+
confirmDisabled={commit !== null && commit.draft.title.trim() === ""}
|
|
285
|
+
errorToMessage={errorToMessage}
|
|
286
|
+
onConfirm={async () => {
|
|
287
|
+
const commitMessage = editedCommitMessage(commit);
|
|
288
|
+
await onEdit({
|
|
289
|
+
kind: "merge",
|
|
290
|
+
method,
|
|
291
|
+
expectedHeadSha: pullRequest.headSha,
|
|
292
|
+
...(commitMessage === undefined ? {} : { commitMessage }),
|
|
293
|
+
});
|
|
294
|
+
setConfirming(false);
|
|
295
|
+
}}
|
|
296
|
+
onCancel={() => {
|
|
297
|
+
setConfirming(false);
|
|
298
|
+
}}
|
|
299
|
+
>
|
|
300
|
+
<View className="gap-m">
|
|
301
|
+
<Text>
|
|
302
|
+
{`#${String(pullRequest.number)} goes into ${pullRequest.baseRef} on GitHub, under your login.`}
|
|
303
|
+
</Text>
|
|
304
|
+
{commit === null ? null : (
|
|
305
|
+
<MergeCommitMessageFields
|
|
306
|
+
value={commit.draft}
|
|
307
|
+
onChange={(draft) => {
|
|
308
|
+
setCommit({ ...commit, draft });
|
|
309
|
+
}}
|
|
310
|
+
/>
|
|
311
|
+
)}
|
|
312
|
+
</View>
|
|
313
|
+
</QuestionAlertDialog>
|
|
314
|
+
)}
|
|
315
|
+
</>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Closing is under the comment box, since it requires saying why. */
|
|
320
|
+
export function PullRequestReopen({
|
|
321
|
+
detail,
|
|
322
|
+
onEdit,
|
|
323
|
+
}: PullRequestActionProps): ReactNode {
|
|
324
|
+
const { pullRequest } = detail;
|
|
325
|
+
if (!pullRequest.viewerCanUpdate || pullRequest.state !== "closed") {
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return (
|
|
330
|
+
<ActionButton
|
|
331
|
+
text="Reopen pull request"
|
|
332
|
+
accent="neutral"
|
|
333
|
+
variant="tonal"
|
|
334
|
+
size="sm"
|
|
335
|
+
errorToMessage={errorToMessage}
|
|
336
|
+
onPress={async () => {
|
|
337
|
+
await onEdit({ kind: "state", state: "open" });
|
|
338
|
+
}}
|
|
339
|
+
/>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
PullRequestDetailResponse,
|
|
3
|
+
PullRequestEdit,
|
|
4
|
+
PullRequestReviewEvent,
|
|
5
|
+
} from "@reviewflow/sdk";
|
|
6
|
+
import {
|
|
7
|
+
ActionButton,
|
|
8
|
+
Button,
|
|
9
|
+
Code,
|
|
10
|
+
IconButton,
|
|
11
|
+
Modal,
|
|
12
|
+
Radio,
|
|
13
|
+
RadioGroup,
|
|
14
|
+
Text,
|
|
15
|
+
View,
|
|
16
|
+
} from "alouette";
|
|
17
|
+
import { XRegularIcon } from "alouette-icons/phosphor-icons/X";
|
|
18
|
+
import type { ReactNode } from "react";
|
|
19
|
+
import { useState } from "react";
|
|
20
|
+
import {
|
|
21
|
+
useDraftText,
|
|
22
|
+
usePullRequestDrafts,
|
|
23
|
+
} from "../services/PullRequestDraftsProvider.tsx";
|
|
24
|
+
import { usePullRequestReviewDraft } from "../services/PullRequestReviewDraftProvider.tsx";
|
|
25
|
+
import { errorToMessage } from "../services/errorToMessage.ts";
|
|
26
|
+
import { PullRequestMarkdownInput } from "./pull-request-markdown-input.tsx";
|
|
27
|
+
|
|
28
|
+
export interface PullRequestActionProps {
|
|
29
|
+
detail: PullRequestDetailResponse;
|
|
30
|
+
/** sends one edit, then reads the pull request again */
|
|
31
|
+
onEdit: (edit: PullRequestEdit) => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const isReviewEvent = (value: string): value is PullRequestReviewEvent =>
|
|
35
|
+
value === "comment" || value === "approve" || value === "requestChanges";
|
|
36
|
+
|
|
37
|
+
const firstLine = (body: string): string => body.split("\n")[0] ?? "";
|
|
38
|
+
|
|
39
|
+
/** What GitHub would refuse, said before the press rather than after. */
|
|
40
|
+
const submitBlocker = (
|
|
41
|
+
event: PullRequestReviewEvent,
|
|
42
|
+
body: string,
|
|
43
|
+
commentCount: number,
|
|
44
|
+
): string | null => {
|
|
45
|
+
const empty = body.trim() === "";
|
|
46
|
+
if (event === "requestChanges" && empty) {
|
|
47
|
+
return "Requesting changes needs a summary.";
|
|
48
|
+
}
|
|
49
|
+
if (event === "comment" && empty && commentCount === 0) {
|
|
50
|
+
return "A comment review needs a summary or at least one inline comment.";
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* "Review changes", ending the Files changed tab's sticky toolbar: the verdict, a
|
|
57
|
+
* summary and the inline comments the diff collected, sent as one review.
|
|
58
|
+
* Brand while comments are pending, so an unsent review is not forgotten. Never
|
|
59
|
+
* offered to the author, whom GitHub lets comment but not review. A review
|
|
60
|
+
* pinned to a head since replaced only comments: a verdict is given on the
|
|
61
|
+
* code as it stands, which the server checks too.
|
|
62
|
+
*/
|
|
63
|
+
export function PullRequestReviewButton({
|
|
64
|
+
detail,
|
|
65
|
+
onEdit,
|
|
66
|
+
}: PullRequestActionProps): ReactNode {
|
|
67
|
+
const { pullRequest } = detail;
|
|
68
|
+
const { comments, remove, clear, shas, latest, follow } =
|
|
69
|
+
usePullRequestReviewDraft();
|
|
70
|
+
const [open, setOpen] = useState(false);
|
|
71
|
+
const [chosen, setChosen] = useState<PullRequestReviewEvent>("comment");
|
|
72
|
+
const drafts = usePullRequestDrafts();
|
|
73
|
+
const summary = useDraftText("review:body");
|
|
74
|
+
const body = summary.text;
|
|
75
|
+
const headMoved =
|
|
76
|
+
shas !== null && latest !== null && shas.head !== latest.head;
|
|
77
|
+
const event = headMoved ? "comment" : chosen;
|
|
78
|
+
const blocker = submitBlocker(event, body, comments.length);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<>
|
|
82
|
+
<Button
|
|
83
|
+
text={
|
|
84
|
+
comments.length === 0
|
|
85
|
+
? "Review changes"
|
|
86
|
+
: `Review changes (${String(comments.length)})`
|
|
87
|
+
}
|
|
88
|
+
accent={comments.length === 0 ? "neutral" : "brand"}
|
|
89
|
+
variant="tonal"
|
|
90
|
+
size="sm"
|
|
91
|
+
onPress={() => {
|
|
92
|
+
setOpen(true);
|
|
93
|
+
}}
|
|
94
|
+
/>
|
|
95
|
+
<Modal
|
|
96
|
+
visible={open}
|
|
97
|
+
title="Review changes"
|
|
98
|
+
size="lg"
|
|
99
|
+
accent="brand"
|
|
100
|
+
footer={
|
|
101
|
+
<>
|
|
102
|
+
<Button
|
|
103
|
+
text="Cancel"
|
|
104
|
+
accent="neutral"
|
|
105
|
+
variant="soft"
|
|
106
|
+
onPress={() => {
|
|
107
|
+
setOpen(false);
|
|
108
|
+
}}
|
|
109
|
+
/>
|
|
110
|
+
<ActionButton
|
|
111
|
+
text="Submit review"
|
|
112
|
+
accent="brand"
|
|
113
|
+
variant="tonal"
|
|
114
|
+
disabledReason={blocker}
|
|
115
|
+
errorMessageVariant="flat"
|
|
116
|
+
errorToMessage={errorToMessage}
|
|
117
|
+
onPress={async () => {
|
|
118
|
+
await onEdit({
|
|
119
|
+
kind: "review",
|
|
120
|
+
event,
|
|
121
|
+
body,
|
|
122
|
+
commitId: shas?.head ?? pullRequest.headSha,
|
|
123
|
+
comments: comments.map(
|
|
124
|
+
({ key: _key, ...comment }) => comment,
|
|
125
|
+
),
|
|
126
|
+
});
|
|
127
|
+
clear();
|
|
128
|
+
summary.discard(body);
|
|
129
|
+
setChosen("comment");
|
|
130
|
+
setOpen(false);
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
</>
|
|
134
|
+
}
|
|
135
|
+
onClose={() => {
|
|
136
|
+
setOpen(false);
|
|
137
|
+
}}
|
|
138
|
+
>
|
|
139
|
+
<View className="gap-m">
|
|
140
|
+
<PullRequestMarkdownInput
|
|
141
|
+
aria-label="Review summary"
|
|
142
|
+
value={body}
|
|
143
|
+
onChange={summary.setText}
|
|
144
|
+
/>
|
|
145
|
+
{/* TODO: rows stretch to the full width once alouette allows it: https://github.com/christophehurpeau/alouette/issues/397 */}
|
|
146
|
+
<RadioGroup
|
|
147
|
+
aria-label="Verdict"
|
|
148
|
+
value={event}
|
|
149
|
+
onValueChange={(next) => {
|
|
150
|
+
if (isReviewEvent(next)) setChosen(next);
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
<Radio value="comment" label="Comment" />
|
|
154
|
+
<Radio value="approve" label="Approve" disabled={headMoved} />
|
|
155
|
+
<Radio
|
|
156
|
+
value="requestChanges"
|
|
157
|
+
label="Request changes"
|
|
158
|
+
disabled={headMoved}
|
|
159
|
+
/>
|
|
160
|
+
</RadioGroup>
|
|
161
|
+
{headMoved && follow === null ? (
|
|
162
|
+
<Text className="text-sm text-muted">
|
|
163
|
+
New commits were pushed since this review started: it can only
|
|
164
|
+
comment. Approve or request changes once it is sent, on the latest
|
|
165
|
+
version.
|
|
166
|
+
</Text>
|
|
167
|
+
) : null}
|
|
168
|
+
{headMoved && follow !== null ? (
|
|
169
|
+
<Text className="text-sm text-muted">
|
|
170
|
+
New commits were pushed since this diff was shown: this review can
|
|
171
|
+
only comment. Show the latest changes to approve or request
|
|
172
|
+
changes.
|
|
173
|
+
</Text>
|
|
174
|
+
) : null}
|
|
175
|
+
{blocker === null ? null : (
|
|
176
|
+
<Text className="text-sm text-muted">{blocker}</Text>
|
|
177
|
+
)}
|
|
178
|
+
<View className="gap-xs">
|
|
179
|
+
<Text className="font-body-bold text-sm">
|
|
180
|
+
{`Inline comments (${String(comments.length)})`}
|
|
181
|
+
</Text>
|
|
182
|
+
{comments.length === 0 ? (
|
|
183
|
+
<Text className="text-sm text-muted">
|
|
184
|
+
None. Add them from the Files changed tab.
|
|
185
|
+
</Text>
|
|
186
|
+
) : (
|
|
187
|
+
comments.map((comment) => (
|
|
188
|
+
<View
|
|
189
|
+
key={comment.key}
|
|
190
|
+
className="flex-row items-center gap-xs"
|
|
191
|
+
>
|
|
192
|
+
<Code className="text-sm">{`${comment.path}:${String(comment.line)}`}</Code>
|
|
193
|
+
<Text className="min-w-0 flex-1 text-sm" numberOfLines={1}>
|
|
194
|
+
{firstLine(comment.body)}
|
|
195
|
+
</Text>
|
|
196
|
+
<IconButton
|
|
197
|
+
icon={<XRegularIcon />}
|
|
198
|
+
aria-label={`Remove the comment on ${comment.path}:${String(comment.line)}`}
|
|
199
|
+
variant="soft"
|
|
200
|
+
size="sm"
|
|
201
|
+
onPress={() => {
|
|
202
|
+
remove(comment.key);
|
|
203
|
+
}}
|
|
204
|
+
/>
|
|
205
|
+
</View>
|
|
206
|
+
))
|
|
207
|
+
)}
|
|
208
|
+
</View>
|
|
209
|
+
<Text className="text-xs text-muted">
|
|
210
|
+
{drafts === null
|
|
211
|
+
? "Pending comments live in memory until the review is sent: reloading or leaving this pull request drops them."
|
|
212
|
+
: `Pending comments and the summary are kept on this device until the review is sent, for ${String(drafts.retentionDays)} days after their last change.`}
|
|
213
|
+
</Text>
|
|
214
|
+
</View>
|
|
215
|
+
</Modal>
|
|
216
|
+
</>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { PullRequestCheck } from "@reviewflow/sdk";
|
|
2
|
+
import type { Accent } from "alouette";
|
|
3
|
+
import { AccentScope, ExternalLinkText, Icon, Text, View } from "alouette";
|
|
4
|
+
import { CheckCircleRegularIcon } from "alouette-icons/phosphor-icons/CheckCircle";
|
|
5
|
+
import { CircleNotchRegularIcon } from "alouette-icons/phosphor-icons/CircleNotch";
|
|
6
|
+
import { ClockRegularIcon } from "alouette-icons/phosphor-icons/Clock";
|
|
7
|
+
import { XCircleRegularIcon } from "alouette-icons/phosphor-icons/XCircle";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
|
|
10
|
+
const checkAccent = (outcome: PullRequestCheck["outcome"]): Accent => {
|
|
11
|
+
if (outcome === "failed") return "danger";
|
|
12
|
+
if (outcome === "pending") return "warning";
|
|
13
|
+
return "success";
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const checkLabel = (check: PullRequestCheck): string => {
|
|
17
|
+
if (check.outcome === "failed") return "failing";
|
|
18
|
+
if (check.running) return "running";
|
|
19
|
+
if (check.outcome === "pending") return "pending";
|
|
20
|
+
return "passing";
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function CheckGlyph({ check }: { check: PullRequestCheck }): ReactNode {
|
|
24
|
+
if (check.outcome === "failed") {
|
|
25
|
+
return (
|
|
26
|
+
<Icon icon={<XCircleRegularIcon />} size={16} className="text-accent" />
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
if (check.running) {
|
|
30
|
+
return (
|
|
31
|
+
<View className="motion-safe:animate-spin">
|
|
32
|
+
<Icon
|
|
33
|
+
icon={<CircleNotchRegularIcon />}
|
|
34
|
+
size={16}
|
|
35
|
+
className="text-accent"
|
|
36
|
+
/>
|
|
37
|
+
</View>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (check.outcome === "pending") {
|
|
41
|
+
return (
|
|
42
|
+
<Icon icon={<ClockRegularIcon />} size={16} className="text-accent" />
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return (
|
|
46
|
+
<Icon icon={<CheckCircleRegularIcon />} size={16} className="text-accent" />
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A glyph in a fixed column rather than a word badge: the sidebar is narrow,
|
|
52
|
+
* and the name and what the check says about itself wrap beside it instead of
|
|
53
|
+
* under it. The description of a passing check is left out — it says it passed.
|
|
54
|
+
*/
|
|
55
|
+
export function CheckLine({ check }: { check: PullRequestCheck }): ReactNode {
|
|
56
|
+
return (
|
|
57
|
+
<View className="flex-row items-start gap-xs">
|
|
58
|
+
<AccentScope accent={checkAccent(check.outcome)}>
|
|
59
|
+
<View
|
|
60
|
+
role="img"
|
|
61
|
+
aria-label={checkLabel(check)}
|
|
62
|
+
className="h-[20px] justify-center"
|
|
63
|
+
>
|
|
64
|
+
<CheckGlyph check={check} />
|
|
65
|
+
</View>
|
|
66
|
+
</AccentScope>
|
|
67
|
+
<View className="min-w-0 flex-1">
|
|
68
|
+
{check.url === null ? (
|
|
69
|
+
<Text className="text-sm">{check.name}</Text>
|
|
70
|
+
) : (
|
|
71
|
+
<ExternalLinkText
|
|
72
|
+
size="sm"
|
|
73
|
+
icon={null}
|
|
74
|
+
accent="neutral"
|
|
75
|
+
href={check.url}
|
|
76
|
+
text={check.name}
|
|
77
|
+
aria-label={`Open ${check.name}`}
|
|
78
|
+
/>
|
|
79
|
+
)}
|
|
80
|
+
{check.description !== null && check.outcome !== "passed" ? (
|
|
81
|
+
<Text className="text-xs text-muted">{check.description}</Text>
|
|
82
|
+
) : null}
|
|
83
|
+
</View>
|
|
84
|
+
</View>
|
|
85
|
+
);
|
|
86
|
+
}
|