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

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