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

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