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

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