| 1 | "use client"; |
| 2 | |
| 3 | import { useRouter } from |
| 4 | import { useAction } |
| 5 | import { type ReactNode, |
| 6 | import { toast } |
| 7 | |
| 8 | import { |
| 9 | addPendingComment, |
| 10 | discardPendingReview, |
| 11 | submitReview, |
| 12 | } from "@/components/pull-requests/actions"; |
| 13 | import { DiffView } |
| 14 | import { ReviewThread } |
| 15 | import { Button } |
| 16 | import { Field, FieldLabel |
| 17 | import { RadioGroup, RadioGroupItem |
| 18 | import { Spinner } |
| 19 | import { Textarea } |
| 20 | import type { LineAnnotations, |
| 21 | import type { |
| 22 | PullRequestFile, |
| 23 | RepositoryBlob, |
| 24 | PullRequestReview, |
| 25 | ReviewComment, |
| 26 | } from "@/types/pull-request"; |
| 27 | |
| 28 | type Verdict |
| 29 | type LineTarget |
| 30 | type LineAnchor |
| 31 | type Viewing |
| 32 | username: string |
| 33 | repo: string |
| 34 | number: number |
| 35 | viewer: string |
| 36 | canModerate: boolean |
| 37 | }; |
| 38 | |
| 39 | /** The diff with the threads on the head it shows, the viewer's pending comments, and the form that submits them as one review. */ |
| 40 | export function ReviewDiff({ |
| 41 | from, |
| 42 | to, |
| 43 | patchUrl, |
| 44 | files, |
| 45 | reviews, |
| 46 | pending, |
| 47 | canReview, |
| 48 | isAuthor, |
| 49 | blobUrl, |
| 50 | ...viewing |
| 51 | }: Viewing |
| 52 | from: string |
| 53 | to: string |
| 54 | /** Blob endpoint of the head repository, which a suggestion is prefilled from; null once that repository is gone. */ |
| 55 | blobUrl: string |
| 56 | patchUrl: string |
| 57 | files: PullRequestFile |
| 58 | /** Submitted reviews. Threads on another head are outdated and stay in the conversation. */ |
| 59 | reviews: PullRequestReview |
| 60 | /** The viewer's unsubmitted review. */ |
| 61 | pending: PullRequestReview |
| 62 | /** Signed in, and the request is still open. */ |
| 63 | canReview: boolean |
| 64 | isAuthor: boolean |
| 65 | }) { |
| 66 | const [target, |
| 67 | |
| 68 | const onLineComment = |
| 69 | const startSide = |
| 70 | const side = |
| 71 | // A drag upwards arrives end first. |
| 72 | const [startLine, |
| 73 | startSide === side |
| 74 | ? [range.end |
| 75 | : [range.start |
| 76 | setTarget( |
| 77 | startLine === line |
| 78 | ? { path, |
| 79 | : { path, |
| 80 | ); |
| 81 | }, []); |
| 82 | |
| 83 | const annotations: |
| 84 | const annotate = |
| 85 | annotations[at.path |
| 86 | annotations[at.path |
| 87 | side: at.side |
| 88 | lineNumber: at.line |
| 89 | metadata: node, |
| 90 | }); |
| 91 | }; |
| 92 | for (const review |
| 93 | for (const thread |
| 94 | if (thread.commitSha |
| 95 | annotate( |
| 96 | thread, |
| 97 | <ReviewThread |
| 98 | {...viewing} |
| 99 | thread={thread} |
| 100 | pending={review === |
| 101 | canApply={ |
| 102 | canReview && (viewing |
| 103 | } |
| 104 | />, |
| 105 | ); |
| 106 | } |
| 107 | } |
| 108 | if (target) { |
| 109 | annotate( |
| 110 | target, |
| 111 | <LineCommentForm |
| 112 | {...viewing} |
| 113 | target={target} |
| 114 | hasPending={pending !== |
| 115 | fileUrl={ |
| 116 | blobUrl && |
| 117 | `${blobUrl}?${new |
| 118 | } |
| 119 | onDone={() => |
| 120 | />, |
| 121 | ); |
| 122 | } |
| 123 | |
| 124 | return ( |
| 125 | <div className="flex flex-col gap-4 |
| 126 | {canReview && ( |
| 127 | <SubmitReviewForm |
| 128 | {...viewing} |
| 129 | pendingCount={pending?.comments |
| 130 | isAuthor={isAuthor} |
| 131 | /> |
| 132 | )} |
| 133 | |
| 134 | <DiffView |
| 135 | from={from} |
| 136 | to={to} |
| 137 | patchUrl={patchUrl} |
| 138 | files={files} |
| 139 | annotations={annotations} |
| 140 | onLineComment={canReview ? |
| 141 | /> |
| 142 | </div> |
| 143 | ); |
| 144 | } |
| 145 | |
| 146 | function LineCommentForm({ |
| 147 | username, |
| 148 | repo, |
| 149 | number, |
| 150 | target, |
| 151 | hasPending, |
| 152 | fileUrl, |
| 153 | onDone, |
| 154 | }: Viewing |
| 155 | target: LineTarget |
| 156 | hasPending: boolean |
| 157 | /** The file at the head, for prefilling a suggestion with the lines it replaces. */ |
| 158 | fileUrl: string |
| 159 | onDone: () |
| 160 | }) { |
| 161 | const router = |
| 162 | const [body, |
| 163 | const callbacks = |
| 164 | onSuccess: () => |
| 165 | onDone(); |
| 166 | router.refresh(); |
| 167 | }, |
| 168 | onError: ({ error |
| 169 | toast.error(error |
| 170 | }; |
| 171 | const single = |
| 172 | const add = |
| 173 | const busy = |
| 174 | const comment = |
| 175 | const first = |
| 176 | const suggestable = |
| 177 | fileUrl !== null && |
| 178 | target.side === |
| 179 | (target.startSide ?? |
| 180 | |
| 181 | // Starts from the lines being replaced, so the reviewer edits them rather than retyping; an empty block still works if the file cannot be read. |
| 182 | async function suggest |
| 183 | const lines = |
| 184 | ? await fetch( |
| 185 | .then((response) |
| 186 | .then((blob: |
| 187 | blob?.encoding === |
| 188 | ? blob.content |
| 189 | .split(/\r?\n |
| 190 | .slice(first - |
| 191 | .join("\n") |
| 192 | : "", |
| 193 | ) |
| 194 | .catch(() => |
| 195 | : ""; |
| 196 | setBody(`${body}${ |
| 197 | } |
| 198 | |
| 199 | return ( |
| 200 | <form |
| 201 | className="flex flex-col gap-2 bg-background p-3 font-sans" |
| 202 | onSubmit={(event) |
| 203 | event.preventDefault(); |
| 204 | add.execute({ |
| 205 | }} |
| 206 | > |
| 207 | {target.startLine && |
| 208 | <p className="text-xs text-muted-foreground |
| 209 | Commenting on lines {first}–{target |
| 210 | </p> |
| 211 | )} |
| 212 | <Textarea |
| 213 | value={body} |
| 214 | onChange={(event) |
| 215 | rows={3} |
| 216 | maxLength={20000} |
| 217 | placeholder="Leave a comment. Markdown is supported." |
| 218 | autoFocus |
| 219 | disabled={busy} |
| 220 | /> |
| 221 | <div className="flex flex-wrap justify-end gap-2 |
| 222 | {suggestable && ( |
| 223 | <Button |
| 224 | type="button" |
| 225 | variant="ghost" |
| 226 | size="sm" |
| 227 | className="mr-auto" |
| 228 | disabled={busy} |
| 229 | onClick={suggest} |
| 230 | > |
| 231 | Suggest a change |
| 232 | </Button> |
| 233 | )} |
| 234 | <Button type=" |
| 235 | Cancel |
| 236 | </Button> |
| 237 | {!hasPending && ( |
| 238 | <Button |
| 239 | type="button" |
| 240 | variant="outline" |
| 241 | size="sm" |
| 242 | disabled={busy || |
| 243 | onClick={() => |
| 244 | single.execute({ |
| 245 | username, |
| 246 | repo, |
| 247 | number, |
| 248 | state: "commented", |
| 249 | comments: [comment], |
| 250 | }) |
| 251 | } |
| 252 | > |
| 253 | Add single comment |
| 254 | </Button> |
| 255 | )} |
| 256 | <Button type=" |
| 257 | {busy && <Spinner |
| 258 | {hasPending ? "Add review comment |
| 259 | </Button> |
| 260 | </div> |
| 261 | </form> |
| 262 | ); |
| 263 | } |
| 264 | |
| 265 | function SubmitReviewForm({ |
| 266 | username, |
| 267 | repo, |
| 268 | number, |
| 269 | pendingCount, |
| 270 | isAuthor, |
| 271 | }: Viewing |
| 272 | const router = |
| 273 | const [summary, |
| 274 | const [verdict, |
| 275 | |
| 276 | const submit = |
| 277 | onSuccess: () => |
| 278 | setSummary(""); |
| 279 | setVerdict("commented"); |
| 280 | toast.success("Review submitted. |
| 281 | router.refresh(); |
| 282 | }, |
| 283 | onError: ({ error |
| 284 | toast.error(error |
| 285 | }); |
| 286 | |
| 287 | const discard = |
| 288 | onSuccess: () => |
| 289 | onError: ({ error |
| 290 | toast.error(error |
| 291 | }); |
| 292 | |
| 293 | return ( |
| 294 | <form |
| 295 | className="flex flex-col gap-3 rounded-lg border px-4 py-3" |
| 296 | onSubmit={(event) |
| 297 | event.preventDefault(); |
| 298 | submit.execute({ |
| 299 | username, |
| 300 | repo, |
| 301 | number, |
| 302 | state: verdict, |
| 303 | body: summary, |
| 304 | }); |
| 305 | }} |
| 306 | > |
| 307 | <Textarea |
| 308 | value={summary} |
| 309 | onChange={(event) |
| 310 | rows={3} |
| 311 | maxLength={20000} |
| 312 | placeholder="Summarize your review. Markdown is supported." |
| 313 | disabled={submit.isExecuting |
| 314 | /> |
| 315 | <RadioGroup |
| 316 | value={verdict} |
| 317 | onValueChange={(value) |
| 318 | className="flex flex-wrap gap-4" |
| 319 | > |
| 320 | {( |
| 321 | [ |
| 322 | ["commented", "Comment" |
| 323 | ["approved", "Approve" |
| 324 | ["changes_requested", "Request changes" |
| 325 | ] as const |
| 326 | ) |
| 327 | // You cannot pass judgement on your own request. |
| 328 | .filter(([value |
| 329 | .map(([value |
| 330 | <Field key={ |
| 331 | <RadioGroupItem value={ |
| 332 | <FieldLabel htmlFor={ |
| 333 | {label} |
| 334 | </FieldLabel> |
| 335 | </Field> |
| 336 | ))} |
| 337 | </RadioGroup> |
| 338 | <div className="flex flex-wrap justify-end gap-2 |
| 339 | {pendingCount > 0 |
| 340 | <Button |
| 341 | type="button" |
| 342 | variant="outline" |
| 343 | size="sm" |
| 344 | disabled={discard.isExecuting |
| 345 | onClick={() => |
| 346 | > |
| 347 | {discard.isExecuting && |
| 348 | Discard review |
| 349 | </Button> |
| 350 | )} |
| 351 | <Button |
| 352 | type="submit" |
| 353 | size="sm" |
| 354 | disabled={ |
| 355 | submit.isExecuting || |
| 356 | (verdict === "commented |
| 357 | } |
| 358 | > |
| 359 | {submit.isExecuting && |
| 360 | Submit review |
| 361 | {pendingCount > 0 |
| 362 | ` (${pendingCount} comment${ |
| 363 | </Button> |
| 364 | </div> |
| 365 | </form> |
| 366 | ); |
| 367 | } |