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

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