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

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