Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
797 linesCodeBlameRaw
1import { Bot, CircleCheck, CircleSlash, Pencil, Trash2 } from "lucide-react";
2import type { CSSProperties, ReactNode } from "react";
3import { Form, Link } from "react-router";
4
5import type { AgentRef, Comment, Issue, Pull, State } from "@g1t/contracts";
6
7import { mayChangeComment } from "../lib/comments";
8import { chipStyle } from "../lib/labels";
9import { repoAt } from "../lib/markdown-plugins";
10import { Markdown } from "./markdown";
11import { IssueIcon, PullIcon } from "./work-icons";
12import { MentionTextarea } from "./mention-textarea";
13import { ErrorText, SubmitButton, TimeAgo } from "./ui";
14import { Badge } from "./ui/badge";
15import {
16 AlertDialog,
17 AlertDialogAction,
18 AlertDialogCancel,
19 AlertDialogContent,
20 AlertDialogDescription,
21 AlertDialogFooter,
22 AlertDialogHeader,
23 AlertDialogTitle,
24 AlertDialogTrigger,
25} from "./ui/alert-dialog";
26import { Avatar } from "./ui/avatar";
27import { Button } from "./ui/button";
28import { Card } from "./ui/card";
29import { CheckboxOption } from "./ui/checkbox";
30import { Hint } from "./ui/hint";
31import { UserCard } from "./user-card";
32
33export { IssueIcon, PullIcon };
34
35/** Hues for the labels every repository starts with. */
36const LABEL_HUES: Record<string, number> = {
37 bug: 25,
38 feature: 150,
39 docs: 240,
40 chore: 80,
41 question: 310,
42};
43
44/** A stable hue for any other label, from its name. */
45function hueFor(name: string): number {
46 let hash = 0;
47 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) % 360;
48 return hash;
49}
50
51/**
52 * A label's chip: in its own color when the page knows it (the
53 * repository's labels, by name), else in a hue chosen from its name.
54 */
55export function Label({ name, color }: { name: string; color?: string | null }) {
56 const hue = LABEL_HUES[name] ?? hueFor(name);
57 const own = chipStyle(color);
58 const style: CSSProperties = Object.keys(own).length > 0 ? own : {
59 // Light text on a dark page, deep text on a light one (lib/theme.ts).
60 color: `light-dark(oklch(0.46 0.13 ${hue}), oklch(0.84 0.11 ${hue}))`,
61 borderColor: `light-dark(oklch(0.55 0.13 ${hue} / 0.35), oklch(0.84 0.11 ${hue} / 0.35))`,
62 backgroundColor: `light-dark(oklch(0.55 0.13 ${hue} / 0.08), oklch(0.84 0.11 ${hue} / 0.1))`,
63 };
64 return (
65 <span
66 style={style}
67 className="inline-flex shrink-0 items-center rounded-full border px-2 py-px text-xs font-medium"
68 >
69 {name}
70 </span>
71 );
72}
73
74/** The people an issue is assigned to, as overlapping avatars. */
75export function AssigneeStack({ people }: { people: string[] }) {
76 if (people.length === 0) return null;
77 const shown = people.slice(0, 3);
78 return (
79 <Hint label={`Assigned to ${people.join(", ")}`}>
80 <span className="flex shrink-0 items-center">
81 <span className="sr-only">Assigned to {people.join(", ")}</span>
82 {shown.map((name, index) => (
83 <span key={name} className={`rounded-full ring-2 ring-bg ${index > 0 ? "-ml-1.5" : ""}`} aria-hidden>
84 <Avatar name={name} size={18} />
85 </span>
86 ))}
87 {people.length > shown.length && (
88 <span className="ml-1 text-xs text-faint" aria-hidden>
89 +{people.length - shown.length}
90 </span>
91 )}
92 </span>
93 </Hint>
94 );
95}
96
97/** The agent working on an issue now. */
98export function Assignee({ agent }: { agent: string }) {
99 return (
100 <Hint label={`Assigned to ${agent}`}>
101 <Badge tone="accent" className="text-xs">
102 <Bot size={12} />
103 <span className="sr-only">Assigned to </span>
104 {agent}
105 </Badge>
106 </Hint>
107 );
108}
109
110const PILL = "inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium";
111
112/** The large state badge at the top of an issue. */
113export function IssueState({ issue }: { issue: Pick<Issue, "state" | "reason"> }) {
114 const style =
115 issue.state === "open"
116 ? "border-success/30 bg-success/10 text-success"
117 : issue.reason === "not_planned"
118 ? "border-line bg-surface text-muted"
119 : "border-merged/30 bg-merged/10 text-merged";
120 return (
121 <span className={`${PILL} ${style}`}>
122 <IssueIcon issue={issue} size={14} />
123 {issue.state === "open"
124 ? "Open"
125 : issue.reason === "not_planned"
126 ? "Closed as not planned"
127 : "Closed"}
128 </span>
129 );
130}
131
132const PULL_STYLES: Record<Pull["status"], string> = {
133 draft: "border-line bg-surface text-muted",
134 open: "border-success/30 bg-success/10 text-success",
135 merged: "border-merged/30 bg-merged/10 text-merged",
136 closed: "border-danger/30 bg-danger/10 text-danger",
137};
138
139const PULL_NAMES: Record<Pull["status"], string> = {
140 draft: "Draft",
141 open: "Open",
142 merged: "Merged",
143 closed: "Closed",
144};
145
146export function PullState({ status }: { status: Pull["status"] }) {
147 return (
148 <span className={`${PILL} ${PULL_STYLES[status]}`}>
149 <PullIcon status={status} size={14} />
150 {PULL_NAMES[status]}
151 </span>
152 );
153}
154
155/** The open and closed switch above a list. `to` is the list's own path. */
156export function StateTabs({
157 to,
158 state,
159 query = "",
160 action,
161}: {
162 to: string;
163 state: State;
164 /** Other query parameters to keep, e.g. `label=bug`. */
165 query?: string;
166 action?: ReactNode;
167}) {
168 const link = (value: State) =>
169 `${to}?${[value === "open" ? "" : "state=closed", query].filter(Boolean).join("&")}`;
170 const tab = (value: State, label: string) => (
171 <Link
172 to={link(value)}
173 aria-current={state === value ? "page" : undefined}
174 className={
175 "inline-flex h-full items-center rounded-[5px] px-2.5 text-sm transition-colors " +
176 (state === value ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg")
177 }
178 >
179 {label}
180 </Link>
181 );
182 return (
183 <div className="flex flex-wrap items-center justify-between gap-3">
184 {/* A filter, the height of the filters beside it: the section's views above it outweigh it. */}
185 <Card tone="plain" radius="lg" className="flex h-8 gap-0.5 rounded-md p-0.5">
186
187 {tab("open", "Open")}
188 {tab("closed", "Closed")}
189 </Card>
190 {action}
191 </div>
192 );
193}
194
195const VERDICTS = {
196 approve: { label: "approved these changes", style: "text-success" },
197 request_changes: { label: "requested changes", style: "text-danger" },
198} as const;
199
200/**
201 * A person's name (or `children`) linking to their profile at `/u/<name>`.
202 * g1t, and names that are not usernames, stay plain text.
203 */
204export function PersonLink({
205 name,
206 className,
207 label,
208 children,
209}: {
210 name: string;
211 className?: string;
212 label?: string;
213 children?: ReactNode;
214}) {
215 // g1t itself has no profile; its name carries a small label instead, and
216 // its card says what it is.
217 if (name === "g1t" && !children) {
218 return (
219 <UserCard username="g1t">
220 <span tabIndex={0} className="inline-flex items-baseline gap-1 rounded outline-none focus-visible:ring-2 focus-visible:ring-accent">
221 <span className={className}>g1t</span>
222 <span className="rounded border border-line px-1 text-[0.625rem] leading-[1.35] font-medium text-muted">bot</span>
223 </span>
224 </UserCard>
225 );
226 }
227 if (name === "g1t") {
228 return (
229 <UserCard username="g1t">
230 <span className={className}>{children}</span>
231 </UserCard>
232 );
233 }
234 // Ghost, a deleted account, has no profile and no card.
235 if (name === "ghost" || !/^[a-z0-9-]{1,39}$/i.test(name)) {
236 return <span className={className}>{children ?? name}</span>;
237 }
238 return (
239 <UserCard username={name}>
240 <Link to={`/u/${name.toLowerCase()}`} className={className} aria-label={label}>
241 {children ?? name}
242 </Link>
243 </UserCard>
244 );
245}
246
247/**
248 * Comments in order, then the box to add one. On a pull request, `review`
249 * says where its changes are shown and whether the viewer may give a
250 * verdict.
251 */
252/**
253 * One entry in a conversation: who, what they did and when, then what they
254 * wrote. The author's avatar sits beside it, as on any forge.
255 */
256export function TimelineItem({
257 author,
258 agent,
259 action,
260 at,
261 edited,
262 aside,
263 children,
264}: {
265 author: string;
266 /**
267 * Set when one of the workspace's former agents wrote it, as itself:
268 * shown by its name with an Agent badge, and whom it acted for, in place
269 * of `author`.
270 */
271 agent?: { ref: AgentRef; actingFor?: string | null };
272 /** What they did, after their name: "commented", "opened this". */
273 action: ReactNode;
274 at?: string;
275 /** When what they wrote was last edited, if it was. */
276 edited?: string | null;
277 /** Shown at the right of the header. */
278 aside?: ReactNode;
279 children?: ReactNode;
280}) {
281 const face = (size: number) =>
282 agent ? (
283 <Avatar name={agent.ref.displayName} agent size={size} />
284 ) : (
285 <Avatar name={author} size={size} />
286 );
287 return (
288 <div className="flex gap-3">
289 <span className="mt-1 hidden shrink-0 sm:block">
290 {agent ? (
291 face(32)
292 ) : (
293 <PersonLink name={author} label={`${author}'s profile`}>
294 {face(32)}
295 </PersonLink>
296 )}
297 </span>
298 <Card asChild className="min-w-0 grow overflow-hidden">
299 <article>
300 <header className="flex flex-wrap items-center gap-x-2 gap-y-1 border-b border-line bg-raised/40 px-4 py-2 text-sm text-muted">
301 <span className="sm:hidden">{face(18)}</span>
302 {agent ? (
303 <AgentName agent={agent.ref} />
304 ) : (
305 <PersonLink name={author} className="font-medium text-fg hover:underline" />
306 )}
307 {action}
308 {at && <TimeAgo at={at} />}
309 {agent?.actingFor && (
310 <span className="text-xs text-faint">
311 on behalf of{" "}
312 <PersonLink name={agent.actingFor} className="hover:text-fg hover:underline">
313 @{agent.actingFor}
314 </PersonLink>
315 </span>
316 )}
317 {edited && (
318 <Hint
319 label={
320 <>
321 Edited <TimeAgo at={edited} />
322 </>
323 }
324 >
325 <span tabIndex={0} className="text-xs text-faint">
326 edited
327 </span>
328 </Hint>
329 )}
330 {aside && <span className="ml-auto min-w-0">{aside}</span>}
331 </header>
332 {children && <div className="px-4 py-3">{children}</div>}
333 </article>
334 </Card>
335 </div>
336 );
337}
338
339/** A former agent's name, with an Agent badge. */
340function AgentName({ agent }: { agent: AgentRef }) {
341 return (
342 <span className="inline-flex items-center gap-1.5">
343 <span className="font-medium text-fg">{agent.displayName}</span>
344 <Badge tone="accent" className="px-1.5 text-[0.625rem] leading-[1.35]">
345 Agent
346 </Badge>
347 </span>
348 );
349}
350
351/** That an agent's review is advisory, and why it counts toward nothing. */
352function AdvisoryChip() {
353 return (
354 <Hint label="Agent reviews don't count toward required approvals">
355 <span tabIndex={0} className="inline-flex rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent">
356 <Badge tone="neutral">Advisory</Badge>
357 </span>
358 </Hint>
359 );
360}
361
362type Review = { changesUrl: string; canJudge: boolean };
363
364/**
365 * Text with each `#12` linked to the issue or pull request of that number,
366 * and each file named in backticks shown as code, linked to its diff when
367 * `filesUrl` says where the changes are.
368 */
369function WithReferences({ text, base, filesUrl }: { text: string; base?: string; filesUrl?: string }) {
370 if (!base) return <>{text}</>;
371 return (
372 <>
373 {text.split(/(#\d+|`[^`\n]+`)/).map((part, index) =>
374 /^`[^`]+`$/.test(part) ? (
375 filesUrl ? (
376 <Link
377 key={index}
378 to={`${filesUrl}#file-${part.slice(1, -1)}`}
379 className="font-mono text-xs text-fg hover:underline"
380 >
381 {part.slice(1, -1)}
382 </Link>
383 ) : (
384 <code key={index} className="font-mono text-xs text-fg">
385 {part.slice(1, -1)}
386 </code>
387 )
388 ) : /^#\d+$/.test(part) ? (
389 // Issues and pull requests share numbers; the issue page forwards.
390 <Link
391 key={index}
392 to={`${base}/issues/${part.slice(1)}`}
393 className="font-medium text-fg hover:underline"
394 >
395 {part}
396 </Link>
397 ) : (
398 part
399 ),
400 )}
401 </>
402 );
403}
404
405/**
406 * One line on the conversation's rail: a mark on the rail (a dot unless
407 * `mark` says otherwise), who, what and when.
408 */
409function TimelineRow({ comment, mark, children }: { comment: Comment; mark?: ReactNode; children: ReactNode }) {
410 return (
411 <div className="flex items-center gap-3 text-sm text-muted">
412 <span className="hidden w-8 shrink-0 justify-center sm:flex">
413 {mark ?? <span className="size-2 rounded-full border border-line-strong bg-bg" />}
414 </span>
415 <span className="flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5">
416 {comment.agent ? (
417 <>
418 <Avatar name={comment.agent.displayName} agent size={16} />
419 <AgentName agent={comment.agent} />
420 </>
421 ) : (
422 <>
423 <Avatar name={comment.author.username} size={16} />
424 <PersonLink name={comment.author.username} className="font-medium text-fg hover:underline" />
425 </>
426 )}
427 {children}
428 {comment.actingFor && (
429 <span className="text-xs text-faint">
430 on behalf of{" "}
431 <PersonLink name={comment.actingFor.username} className="hover:text-fg hover:underline">
432 @{comment.actingFor.username}
433 </PersonLink>
434 </span>
435 )}
436 <span className="text-faint">
437 · <TimeAgo at={comment.createdAt} />
438 </span>
439 </span>
440 </div>
441 );
442}
443
444/** Something that happened, as one line on the conversation's rail. */
445function TimelineEvent({ comment, base, filesUrl }: { comment: Comment; base?: string; filesUrl?: string }) {
446 return (
447 <TimelineRow comment={comment}>
448 <span>
449 <WithReferences text={comment.body} base={base} filesUrl={filesUrl} />
450 </span>
451 </TimelineRow>
452 );
453}
454
455/** A review's verdict, approved or changes requested, in its own colour. */
456function Verdict({ verdict }: { verdict: keyof typeof VERDICTS }) {
457 const { label, style } = VERDICTS[verdict];
458 return (
459 <span className={`flex items-center gap-1 font-medium ${style}`}>
460 {verdict === "approve" ? <CircleCheck size={14} /> : <CircleSlash size={14} />}
461 {label}
462 </span>
463 );
464}
465
466/**
467 * A review that gave a verdict and said nothing else: one line on the rail,
468 * not a comment with an empty body. There is nothing to edit, and a
469 * verdict is not deleted, so it has no actions.
470 */
471function TimelineVerdict({ comment, verdict }: { comment: Comment; verdict: keyof typeof VERDICTS }) {
472 return (
473 <TimelineRow
474 comment={comment}
475 mark={
476 verdict === "approve" ? (
477 <CircleCheck size={16} className="text-success" />
478 ) : (
479 <CircleSlash size={16} className="text-danger" />
480 )
481 }
482 >
483 <span className={VERDICTS[verdict].style}>{VERDICTS[verdict].label}</span>
484 {comment.advisory && <AdvisoryChip />}
485 </TimelineRow>
486 );
487}
488
489/** Edit and Delete, under a comment the viewer may change. */
490function CommentActions({
491 comment,
492 can,
493 error,
494}: {
495 comment: Comment;
496 can: { edit: boolean; delete: boolean };
497 error?: string | null;
498}) {
499 return (
500 <div className="mt-3 border-t border-line pt-2 text-sm">
501 <div className="flex flex-wrap items-center gap-1">
502 {can.edit && (
503 <details className="open:order-last open:w-full">
504 <summary className="inline-flex cursor-pointer list-none items-center gap-1 rounded-md px-1.5 py-1 text-xs text-faint hover:text-fg">
505 <Pencil size={12} />
506 Edit
507 </summary>
508 <Form method="post" className="mt-2 space-y-2">
509 <input type="hidden" name="action" value="edit-comment" />
510 <input type="hidden" name="comment" value={comment.id} />
511 <MentionTextarea name="body" rows={4} defaultValue={comment.body} />
512 <SubmitButton match={{ action: "edit-comment", comment: comment.id }} pending="Saving…">
513 Save
514 </SubmitButton>
515 </Form>
516 </details>
517 )}
518 {can.delete && (
519 <AlertDialog>
520 <AlertDialogTrigger asChild>
521 <Button
522 type="button"
523 variant="ghost"
524 size="inline"
525 className="gap-1 px-1.5 py-1 text-xs text-faint hover:text-danger font-normal"
526 >
527 <Trash2 size={12} />
528 Delete
529 </Button>
530 </AlertDialogTrigger>
531 <AlertDialogContent>
532 <Form method="post" className="grid gap-4">
533 <input type="hidden" name="action" value="delete-comment" />
534 <input type="hidden" name="comment" value={comment.id} />
535 <AlertDialogHeader>
536 <AlertDialogTitle>Delete this comment?</AlertDialogTitle>
537 <AlertDialogDescription>It is removed from the conversation for everyone. This cannot be undone.</AlertDialogDescription>
538 </AlertDialogHeader>
539 <AlertDialogFooter>
540 <AlertDialogCancel>Cancel</AlertDialogCancel>
541 <AlertDialogAction asChild>
542 <button type="submit">
543 <Trash2 size={14} />
544 Delete comment
545 </button>
546 </AlertDialogAction>
547 </AlertDialogFooter>
548 </Form>
549 </AlertDialogContent>
550 </AlertDialog>
551 )}
552 </div>
553 {error && <ErrorText>{error}</ErrorText>}
554 </div>
555 );
556}
557
558/**
559 * The conversation of an issue or a pull request, oldest first: what
560 * people and agents wrote, and between those, what happened.
561 */
562export function CommentList({
563 comments,
564 review,
565 base,
566 viewerId,
567 canModerate = false,
568 failed,
569}: {
570 comments: Comment[];
571 review?: Review;
572 /** The repository's path, so that `#12` in an event can be linked. */
573 base?: string;
574 /** The signed-in viewer, who may edit and delete their own comments. */
575 viewerId?: string | null;
576 /** Maintain and up: may edit and delete anyone's comments. */
577 canModerate?: boolean;
578 /** Why editing or deleting a comment was refused, under that comment. */
579 failed?: { comment: string; error?: string | null } | null;
580}) {
581 return (
582 <>
583 {comments.map((comment) => {
584 if (comment.kind === "event") {
585 return <TimelineEvent key={comment.id} comment={comment} base={base} filesUrl={review?.changesUrl} />;
586 }
587 const verdict = comment.verdict;
588 // A verdict with nothing said and no line it is about is one line, not a card.
589 if (verdict && !comment.body.trim() && !comment.path) {
590 return <TimelineVerdict key={comment.id} comment={comment} verdict={verdict} />;
591 }
592 const can = mayChangeComment(comment, viewerId, canModerate);
593 return (
594 <TimelineItem
595 key={comment.id}
596 author={comment.author.username}
597 agent={
598 comment.agent && {
599 ref: comment.agent,
600 actingFor: comment.actingFor?.username,
601 }
602 }
603 at={comment.createdAt}
604 action={
605 <>
606 {verdict ? (
607 <Verdict verdict={verdict} />
608 ) : (
609 <span>{comment.advisory ? "reviewed" : "commented"}</span>
610 )}
611 {comment.advisory && <AdvisoryChip />}
612 </>
613 }
614 edited={comment.editedAt}
615 aside={
616 comment.path &&
617 review && (
618 <Link
619 to={`${review.changesUrl}#file-${comment.path}`}
620 className="block truncate font-mono text-xs text-faint hover:text-fg"
621 >
622 {comment.path}
623 {comment.line != null && `:${comment.line}`}
624 </Link>
625 )
626 }
627 >
628 {comment.body && <Markdown source={comment.body} repo={repoAt(base)} />}
629 {(can.edit || can.delete) && (
630 <CommentActions comment={comment} can={can} error={failed?.comment === comment.id ? failed.error : null} />
631 )}
632 </TimelineItem>
633 );
634 })}
635 </>
636 );
637}
638
639/** Where a signed-in person writes a comment, or a review. */
640export function CommentForm({
641 author,
642 resetKey,
643 review,
644 handles,
645}: {
646 /** The viewer's username, or null if they are signed out. */
647 author: string | null;
648 /** Changes when a comment is added, which clears the box. */
649 resetKey: number;
650 review?: Review;
651 /** Teams the box offers to mention, as `@workspace/team`. */
652 handles?: readonly string[];
653}) {
654 if (!author) {
655 return (
656 <p className="text-sm text-muted">
657 <Link to="/login" className="text-fg underline underline-offset-4">
658 Sign in
659 </Link>{" "}
660 to comment.
661 </p>
662 );
663 }
664 return (
665 <div className="flex gap-3">
666 <span className="mt-1 hidden shrink-0 sm:block">
667 <Avatar name={author} size={32} />
668 </span>
669 <Form method="post" className="min-w-0 grow space-y-2" key={resetKey}>
670 <input type="hidden" name="action" value="comment" />
671 <MentionTextarea
672 name="body"
673 handles={handles}
674 rows={3}
675 placeholder={
676 review?.canJudge
677 ? "Leave a comment, or a review. Markdown works."
678 : "Leave a comment. Markdown works."
679 }
680 />
681 <div className="flex flex-wrap gap-2">
682 {/* A plain comment's verdict is none the action knows, so each button tells its own post apart. */}
683 <SubmitButton name="verdict" value="comment" match={{ action: "comment" }} pending="Commenting…">
684 Comment
685 </SubmitButton>
686 {review?.canJudge && (
687 <>
688 <SubmitButton variant="outline" name="verdict" value="approve" match={{ action: "comment" }} pending="Approving…">
689 <CircleCheck size={14} className="text-success" />
690 Approve
691 </SubmitButton>
692 <SubmitButton
693 variant="outline"
694 name="verdict"
695 value="request_changes"
696 match={{ action: "comment" }}
697 pending="Requesting changes…"
698 >
699 <CircleSlash size={14} className="text-danger" />
700 Request changes
701 </SubmitButton>
702 </>
703 )}
704 </div>
705 </Form>
706 </div>
707 );
708}
709
710/**
711 * Checkboxes for choosing people: the workspace's members, anyone already
712 * chosen, and a box for other usernames. Posts `name` for each ticked and
713 * `others` for the rest.
714 */
715export function PeoplePicker({
716 name,
717 members,
718 chosen,
719 placeholder = "Other usernames, comma separated",
720}: {
721 name: string;
722 members: string[];
723 chosen: string[];
724 placeholder?: string;
725}) {
726 const people = [...new Set([...members, ...chosen])];
727 return (
728 <>
729 {people.length > 0 && (
730 <div className="space-y-1.5">
731 {people.map((person) => (
732 <CheckboxOption
733 key={person}
734 name={name}
735 value={person}
736 defaultChecked={chosen.includes(person)}
737 className="items-center"
738 labelClassName="flex items-center gap-2"
739 label={
740 <>
741 <Avatar name={person} size={18} />
742 <span className="font-mono text-xs">{person}</span>
743 </>
744 }
745 />
746 ))}
747 </div>
748 )}
749 <input
750 name="others"
751 placeholder={placeholder}
752 autoComplete="off"
753 data-1p-ignore
754 className="w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
755 />
756 </>
757 );
758}
759
760/**
761 * Where each reviewer stands: their most recent verdict. An agent's review
762 * is advisory and is not one of them.
763 */
764export function verdicts(comments: Comment[]): { reviewer: string; verdict: NonNullable<Comment["verdict"]> }[] {
765 const latest = new Map<string, NonNullable<Comment["verdict"]>>();
766 for (const comment of comments) {
767 if (comment.verdict && !comment.agent && !comment.advisory) latest.set(comment.author.username, comment.verdict);
768 }
769 return [...latest].map(([reviewer, verdict]) => ({ reviewer, verdict }));
770}
771
772/** `2 files +12 −3`: the size of a pull request's change. */
773export function ChangeSize({ files }: { files: Pull["files"] }) {
774 if (files.length === 0) return null;
775 const additions = files.reduce((sum, file) => sum + file.additions, 0);
776 const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
777 return (
778 <span className="flex shrink-0 items-center gap-1.5 font-mono text-xs text-faint">
779 <span>
780 {files.length} {files.length === 1 ? "file" : "files"}
781 </span>
782 <span className="text-success">+{additions}</span>
783 <span className="text-danger">−{deletions}</span>
784 </span>
785 );
786}
787
788/** The words of a Markdown text, without its markup, for a one-line preview. */
789export function plainText(markdown: string): string {
790 return markdown
791 .replace(/```[\s\S]*?```/g, " ")
792 .replace(/^\s{0,3}(#{1,6}|[-*+]|\d+\.)\s+/gm, "")
793 .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
794 .replace(/[*_`~]/g, "")
795 .replace(/\s+/g, " ")
796 .trim();
797}