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

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