g1t/apps/web/app/routes/repo/pull.tsx

954 lines38,858 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import {
3 Bot,
4 ChevronRight,
5 FileDiff,
6 GitBranch,
7 GitCommitHorizontal,
8 CircleCheck,
9 CircleSlash,
10 GitMerge,
11 Layers,
12 GitPullRequestArrow,
13 Hand,
14 Loader,
15 Radar,
16 Sparkles,
17 MessageSquare,
18 MessagesSquare,
19 StickyNote,
20 User,
21 Wrench,
22} from "lucide-react";
23import { useEffect, useState } from "react";
24import { Form, Link, redirect, useRevalidator } from "react-router";
25
26import { type Comparison, type SessionEntry, pullComparison } from "@g1t/contracts";
27
28import type { Route } from "./+types/pull";
29import { DiffView } from "../../components/diff-view";
30import { LifecyclePanel } from "../../components/lifecycle";
31import { Markdown } from "../../components/markdown";
32import {
33 Avatar,
34 Button,
35 ButtonLink,
36 CopyLine,
37 EmptyState,
38 ErrorText,
39 Textarea,
40 TimeAgo,
41} from "../../components/ui";
42import { ChecksPanel } from "../../components/checks";
43import {
44 CommentForm,
45 CommentList,
46 IssueIcon,
47 PeoplePicker,
48 PullState,
49 TimelineItem,
50 verdicts,
51} from "../../components/work";
52import { identity, repos, work } from "../../lib/services.server";
53import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
54
55const REFRESH_MS = 4000;
56const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
57const TABS = ["conversation", "session", "changes"] as const;
58type Tab = (typeof TABS)[number];
59
60export function meta({ loaderData, params }: Route.MetaArgs) {
61 const title = loaderData
62 ? `${loaderData.pull.title} · Pull request #${loaderData.pull.number} · `
63 : "";
64 return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
65}
66
67export async function loader({ params, context, request }: Route.LoaderArgs) {
68 const viewer = getViewer(context);
69 const path = { namespace: params.owner, name: params.repo };
70 const number = Number(params.number);
71 const asked = new URL(request.url).searchParams.get("tab");
72 const tab: Tab = TABS.find((name) => name === asked) ?? "conversation";
73
74 const member = (viewer?.workspaces ?? []).some(
75 (membership) => membership.slug === params.owner,
76 );
77 // At once: none of these depends on another.
78 const [found, repo, settings, agentsEnabled, members] = await Promise.all([
79 work.getPull(path, number, viewer),
80 repos.get(path, viewer),
81 work.getSettings(path, viewer),
82 env.RUNNER.enabled(viewer),
83 // A member picks reviewers and assignees from the workspace's people.
84 member ? identity.listMembers(params.owner, viewer) : null,
85 ]);
86 if (!found.ok) {
87 // Issues and pull requests share numbers; this one may be an issue.
88 const issue = await work.getIssue(path, number, viewer);
89 if (issue.ok) throw redirect(`/${params.owner}/${params.repo}/issues/${number}`);
90 throw new Response("Pull request not found.", { status: 404 });
91 }
92 const { pull } = found.value;
93 const range = pullComparison(pull);
94 const [session, comparison] = await Promise.all([
95 tab === "session" ? work.readSession(path, number, viewer) : null,
96 tab === "changes"
97 ? repos.compare(range.repoId, viewer, range.base, range.head)
98 : null,
99 ]);
100 return {
101 ...found.value,
102 tab,
103 session: session?.ok ? session.value : [],
104 // An empty comparison if it could not be made.
105 comparison: comparison && (comparison.ok ? comparison.value : EMPTY_COMPARISON),
106 viewer,
107 // Members of the repository's workspace can merge.
108 canMerge: member,
109 canManage: member || viewer?.id === pull.author.id,
110 // A catch-up is pushed as the viewer: a fork takes pushes only from
111 // whoever opened it, a branch from any member.
112 canUpdate: pull.fork ? viewer?.id === pull.author.id : member,
113 agentsEnabled,
114 members: members?.ok ? members.value.map((person) => person.username) : [],
115 requireUpToDate: settings.ok && settings.value.requireUpToDate,
116 mergeQueue: settings.ok && settings.value.mergeQueue,
117 requiredApprovals: settings.ok ? settings.value.requiredApprovals : 0,
118 canIgnoreChecks: !settings.ok || settings.value.allowIgnoringChecks,
119 defaultBranch: repo.ok ? repo.value.defaultBranch : "main",
120 };
121}
122
123export async function action({ request, params, context }: Route.ActionArgs) {
124 assertSameOrigin(request);
125 const user = requireUser(context, request);
126 const form = await request.formData();
127 const path = { namespace: params.owner, name: params.repo };
128 const number = Number(params.number);
129 const action = form.get("action");
130 const verdict = form.get("verdict");
131 const line = Number(form.get("line"));
132 /** Names ticked in a people picker, plus those typed beside it. */
133 const picked = (field: string) => [
134 ...form.getAll(field).map(String),
135 ...String(form.get("others") ?? "").split(/[\s,]+/),
136 ];
137 // Asking a g1t agent for its review records the request, then starts it.
138 if (action === "agent-review") {
139 const asked = await work.updatePull(user, path, number, {
140 reviewers: [...form.getAll("reviewer").map(String), "g1t-agent"],
141 });
142 if (!asked.ok) return { error: asked.error.message, action };
143 }
144 const result =
145 action === "merge"
146 ? await work.mergePull(user, path, number, {
147 keepIssueOpen: form.get("keepIssueOpen") === "on",
148 ignoreChecks: form.get("ignoreChecks") === "on",
149 })
150 : action === "unqueue"
151 ? await work.removeFromQueue(user, path, number)
152 : action === "close"
153 ? await work.closePull(user, path, number)
154 : action === "recheck"
155 ? await env.RUNNER.recheck(user, path, number)
156 : action === "update"
157 ? await env.RUNNER.update(user, path, number)
158 : action === "agent-review"
159 ? await env.RUNNER.review(user, path, number)
160 : action === "reviewers"
161 ? await work.updatePull(user, path, number, { reviewers: picked("reviewer") })
162 : action === "assign"
163 ? await work.updatePull(user, path, number, { assignees: picked("assignee") })
164 : action === "comment"
165 ? await work.addComment(user, path, number, {
166 body: String(form.get("body") ?? ""),
167 path: String(form.get("path") ?? "") || undefined,
168 line: line > 0 ? line : undefined,
169 verdict:
170 verdict === "approve" || verdict === "request_changes" ? verdict : undefined,
171 })
172 : await work.readyPull(user, path, number, String(form.get("summary") ?? ""));
173 return result.ok ? null : { error: result.error.message, action };
174}
175
176function TabLink({
177 to,
178 active,
179 children,
180}: {
181 to: string;
182 active: boolean;
183 children: React.ReactNode;
184}) {
185 return (
186 <Link
187 to={to}
188 preventScrollReset
189 className={
190 "-mb-px flex items-center gap-2 border-b-2 px-1 pb-2.5 text-sm transition-colors " +
191 (active
192 ? "border-accent font-medium text-fg"
193 : "border-transparent text-muted hover:text-fg")
194 }
195 >
196 {children}
197 </Link>
198 );
199}
200
201/** One step of the session, on the timeline's rail. */
202function Entry({ entry, agent }: { entry: SessionEntry; agent: string }) {
203 const isTool = entry.kind === "tool_call" || entry.kind === "tool_result";
204 const Icon =
205 entry.kind === "prompt"
206 ? User
207 : entry.kind === "note"
208 ? StickyNote
209 : isTool
210 ? Wrench
211 : Bot;
212 return (
213 <li className="relative pl-10">
214 <span
215 className={`absolute top-0.5 left-0 flex size-7 items-center justify-center rounded-full border bg-bg ${
216 entry.kind === "prompt"
217 ? "border-accent/50 text-accent"
218 : "border-line text-faint"
219 }`}
220 >
221 <Icon size={14} />
222 </span>
223 {isTool ? (
224 <details className="group rounded-lg border border-line bg-surface">
225 <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-1.5 text-sm">
226 <ChevronRight
227 size={14}
228 className="text-faint transition-transform group-open:rotate-90"
229 />
230 <span className="font-mono text-xs text-accent">
231 {entry.tool ?? "tool"}
232 </span>
233 <span className="truncate font-mono text-xs text-muted">
234 {entry.kind === "tool_result" ? "→ " : ""}
235 {entry.text.split("\n")[0]}
236 </span>
237 </summary>
238 <pre className="overflow-x-auto border-t border-line p-3 font-mono text-xs whitespace-pre-wrap text-muted">
239 {entry.text}
240 </pre>
241 </details>
242 ) : (
243 <div>
244 <p className="text-xs font-medium text-faint">
245 {entry.kind === "prompt"
246 ? "Prompt"
247 : entry.kind === "note"
248 ? "Note"
249 : agent}
250 </p>
251 {entry.kind === "message" ? (
252 <div className="mt-1">
253 <Markdown source={entry.text} />
254 </div>
255 ) : entry.kind === "prompt" ? (
256 <div className="mt-1.5 max-h-[32rem] overflow-y-auto rounded-xl border border-line bg-surface p-4">
257 <Markdown source={entry.text} />
258 </div>
259 ) : (
260 <p className="mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap">
261 {entry.text}
262 </p>
263 )}
264 </div>
265 )}
266 {entry.commit && (
267 <p className="mt-1.5 flex items-center gap-1 font-mono text-xs text-faint">
268 <GitCommitHorizontal size={12} />
269 {entry.commit.slice(0, 7)}
270 </p>
271 )}
272 </li>
273 );
274}
275
276/** The box at the foot of the conversation saying what stands before a merge. */
277function StatusBox({ children }: { children: React.ReactNode }) {
278 return (
279 <div className="flex gap-3">
280 <span className="hidden w-8 shrink-0 sm:block" />
281 <section className="min-w-0 grow divide-y divide-line overflow-hidden rounded-xl border border-line-strong bg-surface">
282 {children}
283 </section>
284 </div>
285 );
286}
287
288function StatusRow({
289 icon,
290 title,
291 children,
292}: {
293 icon: React.ReactNode;
294 title: string;
295 children?: React.ReactNode;
296}) {
297 return (
298 <div className="flex gap-3 px-4 py-3 text-sm">
299 <span className="mt-0.5 shrink-0">{icon}</span>
300 <div className="min-w-0">
301 <p className="font-medium">{title}</p>
302 {children && <div className="mt-0.5 text-muted">{children}</div>}
303 </div>
304 </div>
305 );
306}
307
308export default function PullPage({ loaderData, actionData, params }: Route.ComponentProps) {
309 const {
310 pull,
311 issue,
312 comments,
313 checks,
314 overlaps,
315 behind,
316 reviewPending,
317 lifecycle,
318 landing,
319 stalled,
320 requireUpToDate,
321 mergeQueue,
322 requiredApprovals,
323 canIgnoreChecks,
324 canUpdate,
325 agentsEnabled,
326 members,
327 tab,
328 session,
329 comparison,
330 viewer,
331 canMerge,
332 canManage,
333 defaultBranch,
334 } = loaderData;
335 const base = `/${params.owner}/${params.repo}`;
336 const here = `${base}/pull/${pull.number}`;
337 const remote = pull.fork
338 ? `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`
339 : `https://g1t.sh/${params.owner}/${params.repo}.git`;
340 const active = pull.status === "draft" || pull.status === "open";
341
342 // Follow an agent at work, or checks in progress, without a manual reload.
343 const revalidator = useRevalidator();
344 const working = pull.status === "draft";
345 const checking = checks?.status === "queued" || checks?.status === "running";
346 const reviews = verdicts(comments);
347 // What stands between this pull request and a merge, if anything.
348 const unchecked = checks && checks.status !== "passed";
349 // Pull requests for other issues changing the same files will conflict;
350 // ones for the same issue are alternatives, and expected to.
351 const collisions = overlaps.filter((other) => other.issue == null || other.issue !== pull.issue);
352 const review = {
353 changesUrl: here + "?tab=changes",
354 // Nobody reviews their own pull request.
355 canJudge: active && viewer != null && viewer.id !== pull.author.id,
356 };
357 // Everyone whose review was asked for, then anyone who reviewed unasked.
358 const reviewerNames = [
359 ...new Set([...pull.reviewers, ...reviews.map(({ reviewer }) => reviewer)]),
360 ];
361 const [submitted, setSubmitted] = useState<string | null>(null);
362 const catchingUp = submitted === "update" && behind;
363 // g1t is taking a step of its own accord, so the page will change.
364 const moving =
365 lifecycle != null && lifecycle.stage !== "ready" && lifecycle.stage !== "needs_you";
366 useEffect(() => {
367 if (!working && !checking && !reviewPending && !catchingUp && !moving && !landing) return;
368 const timer = setInterval(() => {
369 if (document.visibilityState === "visible") revalidator.revalidate();
370 }, REFRESH_MS);
371 return () => clearInterval(timer);
372 }, [working, checking, reviewPending, catchingUp, moving, landing, revalidator]);
373
374 return (
375 // The changes get the whole width; people and settings are a tab away.
376 <div className={`grid gap-8 ${tab === "changes" ? "" : "lg:grid-cols-[1fr_19rem]"}`}>
377 <div className="min-w-0">
378 <h2 className="text-2xl font-semibold tracking-tight text-balance">
379 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
380 </h2>
381 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
382 <PullState status={pull.status} />
383 <span className="flex items-center gap-2">
384 <Avatar
385 name={(pull.status === "merged" && pull.mergedBy) || pull.author.username}
386 size={18}
387 />
388 <span>
389 <span className="font-medium text-fg">
390 {(pull.status === "merged" && pull.mergedBy) || pull.author.username}
391 </span>{" "}
392 {pull.status === "merged" ? "merged" : "wants to merge"}
393 {pull.branch && (
394 <>
395 {" "}
396 <span className="font-mono text-fg">{pull.branch}</span>
397 </>
398 )}{" "}
399 into <span className="font-mono text-fg">{defaultBranch}</span>
400 </span>
401 </span>
402 {/* A pull request from a branch was made by its author, not an agent. */}
403 {!pull.branch && (
404 <span className="flex items-center gap-1.5 font-mono text-xs">
405 <Bot size={14} />
406 {pull.agent}
407 {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
408 </span>
409 )}
410 </div>
411
412 {reviews.length > 0 && (
413 <p className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
414 {reviews.map(({ reviewer, verdict }) => (
415 <span
416 key={reviewer}
417 className={`flex items-center gap-1.5 ${
418 verdict === "approve" ? "text-accent" : "text-danger"
419 }`}
420 >
421 {verdict === "approve" ? <CircleCheck size={15} /> : <CircleSlash size={15} />}
422 {verdict === "approve" ? "Approved by" : "Changes requested by"}{" "}
423 <span className="font-medium">{reviewer}</span>
424 </span>
425 ))}
426 </p>
427 )}
428
429 {lifecycle && <LifecyclePanel lifecycle={lifecycle} />}
430
431 {issue && (
432 <Link
433 to={`${base}/issues/${issue.number}`}
434 className="mt-4 flex items-center gap-2.5 rounded-xl border border-line bg-surface px-4 py-2.5 text-sm transition-colors hover:border-line-strong"
435 >
436 <IssueIcon issue={issue} />
437 <span className="text-muted">
438 {pull.status === "merged" && issue.resolvedBy === pull.number
439 ? "Resolved"
440 : "For issue"}
441 </span>
442 <span className="truncate font-medium">{issue.title}</span>
443 <span className="text-faint">#{issue.number}</span>
444 {issue.pullCount > 1 && (
445 <span className="ml-auto shrink-0 text-xs text-faint">
446 1 of {issue.pullCount} pull requests
447 </span>
448 )}
449 </Link>
450 )}
451
452 {pull.status === "merged" && (
453 <p className="mt-4 flex items-center gap-2.5 rounded-xl border border-merged/40 bg-merged/5 px-4 py-2.5 text-sm">
454 <GitMerge size={16} className="shrink-0 text-merged" />
455 <span>
456 Merged into <span className="font-mono">{defaultBranch}</span> by{" "}
457 <span className="font-medium">{pull.mergedBy}</span>{" "}
458 {/* Not an account: the repository's settings said to merge it. */}
459 {pull.mergedBy === "g1t" && "automatically, once it was ready, "}
460 {pull.mergedAt && <TimeAgo at={pull.mergedAt} />}
461 {pull.headCommit && (
462 <span className="font-mono text-muted"> · {pull.headCommit.slice(0, 7)}</span>
463 )}
464 </span>
465 </p>
466 )}
467 {pull.supersededBy != null && (
468 <p className="mt-4 rounded-xl border border-line bg-surface px-4 py-2.5 text-sm text-muted">
469 Closed because{" "}
470 <Link
471 to={`${base}/pull/${pull.supersededBy}`}
472 className="font-medium text-fg hover:underline"
473 >
474 #{pull.supersededBy}
475 </Link>{" "}
476 was merged for this issue instead.
477 </p>
478 )}
479
480 {collisions.length > 0 && active && (
481 <div className="mt-4 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
482 <p className="flex items-center gap-2.5 font-medium">
483 <Radar size={16} className="shrink-0 text-info" />
484 Other work is changing the same files
485 </p>
486 <ul className="mt-2 space-y-1.5">
487 {collisions.map((other) => (
488 <li key={other.number} className="flex flex-wrap items-baseline gap-x-2 text-muted">
489 <Link
490 to={`${base}/pull/${other.number}`}
491 className="font-medium text-fg hover:underline"
492 >
493 {other.title} <span className="font-normal text-faint">#{other.number}</span>
494 </Link>
495 <span className="font-mono text-xs">{other.paths.join(", ")}</span>
496 </li>
497 ))}
498 </ul>
499 <p className="mt-2 text-xs text-faint">
500 Whichever merges second will have to catch up, and may conflict.
501 </p>
502 </div>
503 )}
504
505 <nav className="mt-8 flex gap-6 border-b border-line">
506 <TabLink to={here} active={tab === "conversation"}>
507 <MessageSquare size={15} />
508 Conversation
509 </TabLink>
510 <TabLink to={here + "?tab=session"} active={tab === "session"}>
511 <MessagesSquare size={15} />
512 Session
513 </TabLink>
514 <TabLink to={here + "?tab=changes"} active={tab === "changes"}>
515 <FileDiff size={15} />
516 Changes
517 </TabLink>
518 </nav>
519 <div className="mt-5">
520 {comparison ? (
521 <DiffView
522 comparison={comparison}
523 review={{
524 comments: comments.filter((comment) => comment.path),
525 canComment: Boolean(viewer),
526 }}
527 />
528 ) : tab === "session" ? (
529 session.length === 0 ? (
530 <EmptyState title="Nothing recorded yet">
531 The agent's prompts, reasoning and tool calls appear here as it
532 works.
533 </EmptyState>
534 ) : (
535 <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line">
536 {session.map((entry) => (
537 <Entry key={entry.seq} entry={entry} agent={pull.agent} />
538 ))}
539 </ol>
540 )
541 ) : (
542 <div className="space-y-4">
543 <TimelineItem
544 author={pull.author.username}
545 at={pull.createdAt}
546 action={
547 <span>
548 opened this pull request
549 {!pull.branch && (
550 <>
551 {" "}
552 with <span className="font-mono text-xs">{pull.agent}</span>
553 </>
554 )}
555 </span>
556 }
557 >
558 {pull.body ? (
559 <Markdown source={pull.body} repo={{ namespace: params.owner, name: params.repo }} />
560 ) : (
561 <p className="text-sm text-muted">
562 {working
563 ? "No description yet. It is written when the pull request is marked ready for review."
564 : "No description."}
565 </p>
566 )}
567 {canManage && pull.status === "open" && (
568 <details className="mt-3 border-t border-line pt-3 text-sm">
569 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
570 Edit description
571 </summary>
572 <Form method="post" className="mt-3 space-y-2">
573 <Textarea
574 name="summary"
575 rows={6}
576 placeholder="What changed and why"
577 defaultValue={pull.body ?? ""}
578 />
579 <Button type="submit">Save</Button>
580 </Form>
581 </details>
582 )}
583 </TimelineItem>
584
585 <CommentList comments={comments} review={review} base={base} />
586
587 {pull.status === "draft" && (
588 <StatusBox>
589 <StatusRow icon={<Loader size={16} className="text-faint" />} title="This is a draft">
590 It is still being worked on. It can be reviewed and merged once it is
591 marked ready.
592 </StatusRow>
593 {canManage && (
594 <Form method="post" className="space-y-2 px-4 py-3">
595 <Textarea name="summary" rows={3} placeholder="What changed and why" />
596 <Button type="submit">Mark ready for review</Button>
597 </Form>
598 )}
599 </StatusBox>
600 )}
601
602 {pull.status === "open" && (
603 <StatusBox>
604 {checks && (
605 <StatusRow
606 icon={
607 checks.status === "passed" ? (
608 <CircleCheck size={16} className="text-accent" />
609 ) : checking ? (
610 <Loader size={16} className="animate-spin text-faint" />
611 ) : (
612 <CircleSlash size={16} className="text-danger" />
613 )
614 }
615 title={
616 checks.status === "passed"
617 ? "Acceptance checks passed"
618 : checking
619 ? "Acceptance checks are running"
620 : checks.status === "failed"
621 ? "Acceptance checks failed"
622 : "Acceptance checks could not be run"
623 }
624 >
625 {checks.results.length > 0 &&
626 `${checks.results.filter((result) => result.passed).length} of ${checks.results.length} passed, in a clean sandbox.`}
627 </StatusRow>
628 )}
629 <StatusRow
630 icon={
631 reviews.some(({ verdict }) => verdict === "request_changes") ? (
632 <CircleSlash size={16} className="text-danger" />
633 ) : reviews.length > 0 ? (
634 <CircleCheck size={16} className="text-accent" />
635 ) : (
636 <MessageSquare size={16} className="text-faint" />
637 )
638 }
639 title={
640 reviews.length === 0
641 ? "No reviews yet"
642 : reviews
643 .map(
644 ({ reviewer, verdict }) =>
645 `${verdict === "approve" ? "Approved by" : "Changes requested by"} ${reviewer}`,
646 )
647 .join(" · ")
648 }
649 >
650 {reviewPending && "A g1t agent is reviewing it now. "}
651 {requiredApprovals > 0 &&
652 `This repository requires ${requiredApprovals} approving ${
653 requiredApprovals === 1 ? "review" : "reviews"
654 } before merging.`}
655 </StatusRow>
656 {landing ? (
657 <StatusRow
658 icon={<Loader size={16} className="animate-spin text-accent" />}
659 title="Merging"
660 >
661 {defaultBranch} has moved, so g1t is bringing this up to date first. It
662 lands as soon as that is done. Watch it in the Session tab.
663 </StatusRow>
664 ) : behind ? (
665 <StatusRow
666 icon={<GitPullRequestArrow size={16} className="text-info" />}
667 title={`${defaultBranch} has moved since this was made`}
668 >
669 {requireUpToDate
670 ? "This repository requires pull requests to be up to date, so it has to catch up before it can merge."
671 : "That does not stop it merging: it is brought up to date as part of the merge."}
672 {canUpdate && agentsEnabled && (
673 <Form
674 method="post"
675 className="mt-2"
676 onSubmit={() => setSubmitted("update")}
677 >
678 <input type="hidden" name="action" value="update" />
679 <Button variant="quiet" type="submit" disabled={catchingUp}>
680 {catchingUp ? "Catching up…" : `Catch up with ${defaultBranch} now`}
681 </Button>
682 </Form>
683 )}
684 {actionData?.action === "update" && (
685 <ErrorText>{actionData.error}</ErrorText>
686 )}
687 </StatusRow>
688 ) : (
689 pull.headCommit && (
690 <StatusRow
691 icon={<CircleCheck size={16} className="text-accent" />}
692 title={`Up to date with ${defaultBranch}`}
693 />
694 )
695 )}
696 {stalled && !lifecycle && (
697 <StatusRow icon={<Hand size={16} className="text-warn" />} title="Needs you">
698 {stalled}
699 </StatusRow>
700 )}
701 {canMerge && lifecycle?.stage === "queued" && (
702 <Form method="post" className="flex flex-wrap items-center gap-3 px-4 py-3">
703 <ButtonLink to={`${base}/queue`}>
704 <Layers size={15} />
705 See the queue
706 </ButtonLink>
707 <Button variant="quiet" type="submit" name="action" value="unqueue">
708 Remove from the queue
709 </Button>
710 </Form>
711 )}
712 {canMerge && !landing && lifecycle?.stage !== "queued" && (
713 <Form method="post" className="space-y-3 px-4 py-3">
714 {issue?.state === "open" && (
715 <label className="flex items-start gap-2 text-xs text-muted">
716 <input
717 type="checkbox"
718 name="keepIssueOpen"
719 className="mt-0.5 accent-accent"
720 />
721 <span>Keep #{issue.number} open. This is only part of the work.</span>
722 </label>
723 )}
724 {unchecked && canIgnoreChecks && (
725 <label className="flex items-start gap-2 text-xs text-muted">
726 <input
727 type="checkbox"
728 name="ignoreChecks"
729 className="mt-0.5 accent-accent"
730 />
731 <span>
732 Merge although the checks{" "}
733 {checking ? "have not finished" : "did not pass"}.
734 </span>
735 </label>
736 )}
737 <div className="flex flex-wrap items-center gap-3">
738 <Button
739 variant="accent"
740 type="submit"
741 name="action"
742 value="merge"
743 disabled={behind && requireUpToDate}
744 >
745 {mergeQueue ? <Layers size={15} /> : <GitMerge size={15} />}
746 {mergeQueue ? "Add to the merge queue" : `Merge into ${defaultBranch}`}
747 </Button>
748 <span className="text-xs text-muted">
749 {mergeQueue
750 ? `Tested together with everything ahead of it, then lands on ${defaultBranch}.`
751 : issue?.state === "open"
752 ? `Closes issue #${issue.number}, and any other pull requests still open for it.`
753 : `Lands its commits on ${defaultBranch}.`}
754 </span>
755 </div>
756 {actionData?.action === "merge" && (
757 <ErrorText>{actionData.error}</ErrorText>
758 )}
759 </Form>
760 )}
761 </StatusBox>
762 )}
763
764 <CommentForm
765 author={viewer?.username ?? null}
766 resetKey={comments.length}
767 review={review}
768 />
769 {canManage && active && (
770 <Form method="post" className="flex justify-end">
771 <Button variant="quiet" type="submit" name="action" value="close">
772 Close pull request
773 </Button>
774 </Form>
775 )}
776 {actionData &&
777 !["merge", "comment", "recheck", "update", "agent-review", "reviewers", "assign"].includes(
778 String(actionData.action),
779 ) && <ErrorText>{actionData.error}</ErrorText>}
780 </div>
781 )}
782 {actionData?.action === "comment" && (
783 <div className="mt-2">
784 <ErrorText>{actionData.error}</ErrorText>
785 </div>
786 )}
787 </div>
788 </div>
789
790 <aside className={tab === "changes" ? "hidden" : "space-y-6"}>
791 <ChecksPanel
792 run={checks}
793 commands={issue?.checks ?? []}
794 canRerun={canManage && pull.status === "open"}
795 />
796 {actionData?.action === "recheck" && <ErrorText>{actionData.error}</ErrorText>}
797
798 <section>
799 <h3 className="text-sm font-medium">Reviewers</h3>
800 <ul className="mt-2 space-y-1.5 text-sm">
801 {reviewerNames.map((name) => {
802 const verdict = reviews.find(({ reviewer }) => reviewer === name)?.verdict;
803 const pending = name === "g1t-agent" && reviewPending;
804 return (
805 <li key={name} className="flex items-center gap-2 px-1">
806 {name === "g1t-agent" ? (
807 <Sparkles size={16} className="shrink-0 text-accent" />
808 ) : (
809 <Avatar name={name} size={20} />
810 )}
811 <span className="grow truncate font-mono text-xs">{name}</span>
812 {pending ? (
813 <span className="flex items-center gap-1.5 text-xs text-muted">
814 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
815 Reviewing
816 </span>
817 ) : verdict === "approve" ? (
818 <span className="flex items-center gap-1 text-xs text-accent">
819 <CircleCheck size={13} /> Approved
820 </span>
821 ) : verdict === "request_changes" ? (
822 <span className="flex items-center gap-1 text-xs text-danger">
823 <CircleSlash size={13} /> Changes requested
824 </span>
825 ) : (
826 <span className="text-xs text-faint">Review requested</span>
827 )}
828 </li>
829 );
830 })}
831 {reviewerNames.length === 0 && (
832 <li className="px-1 text-xs text-faint">No reviews requested yet.</li>
833 )}
834 </ul>
835 {pull.status === "open" && canManage && (
836 <div className="mt-3 space-y-2">
837 {agentsEnabled && !reviewPending && (
838 <Form method="post">
839 <input type="hidden" name="action" value="agent-review" />
840 {pull.reviewers.map((name) => (
841 <input key={name} type="hidden" name="reviewer" value={name} />
842 ))}
843 <div className="*:w-full">
844 <Button variant="quiet" type="submit">
845 <Sparkles size={14} className="text-accent" />
846 Request review from g1t agent
847 </Button>
848 </div>
849 </Form>
850 )}
851 <details>
852 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
853 Request review from people
854 </summary>
855 <Form method="post" className="mt-2 space-y-2" key={pull.reviewers.join()}>
856 <input type="hidden" name="action" value="reviewers" />
857 {pull.reviewers.includes("g1t-agent") && (
858 <input type="hidden" name="reviewer" value="g1t-agent" />
859 )}
860 <PeoplePicker
861 name="reviewer"
862 members={members.filter((name) => name !== pull.author.username)}
863 chosen={pull.reviewers.filter((name) => name !== "g1t-agent")}
864 />
865 <Button variant="quiet" type="submit">
866 Save reviewers
867 </Button>
868 </Form>
869 </details>
870 </div>
871 )}
872 {["agent-review", "reviewers"].includes(String(actionData?.action)) && (
873 <ErrorText>{actionData?.error}</ErrorText>
874 )}
875 </section>
876
877 <section>
878 <h3 className="text-sm font-medium">Assignees</h3>
879 <ul className="mt-2 space-y-1.5 text-sm">
880 {pull.assignees.map((name) => (
881 <li key={name} className="flex items-center gap-2 px-1">
882 <Avatar name={name} size={20} />
883 <span className="grow truncate font-mono text-xs">{name}</span>
884 </li>
885 ))}
886 {pull.assignees.length === 0 && (
887 <li className="px-1 text-xs text-faint">No one yet.</li>
888 )}
889 </ul>
890 {viewer && canManage && active && (
891 <div className="mt-3 space-y-2">
892 {!pull.assignees.includes(viewer.username) && (
893 <Form method="post">
894 <input type="hidden" name="action" value="assign" />
895 {pull.assignees.map((name) => (
896 <input key={name} type="hidden" name="assignee" value={name} />
897 ))}
898 <input type="hidden" name="assignee" value={viewer.username} />
899 <div className="*:w-full">
900 <Button variant="quiet" type="submit">
901 Assign yourself
902 </Button>
903 </div>
904 </Form>
905 )}
906 <details>
907 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
908 Assign people
909 </summary>
910 <Form method="post" className="mt-2 space-y-2" key={pull.assignees.join()}>
911 <input type="hidden" name="action" value="assign" />
912 <PeoplePicker name="assignee" members={members} chosen={pull.assignees} />
913 <Button variant="quiet" type="submit">
914 Save assignees
915 </Button>
916 </Form>
917 </details>
918 </div>
919 )}
920 {actionData?.action === "assign" && <ErrorText>{actionData.error}</ErrorText>}
921 </section>
922
923 <section>
924 <h3 className="text-sm font-medium">Working copy</h3>
925 {pull.branch ? (
926 <>
927 <p className="mt-1 text-xs text-muted">
928 <GitBranch size={13} className="mr-1 inline align-[-2px]" />
929 Branch <span className="font-mono text-fg">{pull.branch}</span> of this
930 repository. Pushes to it show up here.
931 </p>
932 <div className="mt-2">
933 <CopyLine text={`git clone -b ${pull.branch} ${remote}`} />
934 </div>
935 </>
936 ) : (
937 <>
938 <p className="mt-1 text-xs text-muted">
939 This fork belongs to the pull request. Pushes to it show up here.
940 </p>
941 <div className="mt-2">
942 <CopyLine text={`git clone ${remote}`} />
943 </div>
944 </>
945 )}
946 <p className="mt-3 flex items-center gap-1.5 font-mono text-xs text-faint">
947 <GitCommitHorizontal size={13} />
948 {pull.headCommit?.slice(0, 12) ?? "no commits pushed yet"}
949 </p>
950 </section>
951 </aside>
952 </div>
953 );
954}