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

1,428 lines62,914 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, useNavigation } 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";
79import { REFRESH_MS, useRefreshWhile } from "../../lib/refresh";
80
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 working = pull.status === "draft";
565 const checking = statuses.some((status) => status.state === "pending");
566 const reviews = verdicts(comments);
567 // What stands between this pull request and a merge, if anything: a
568 // required check that has not passed, or the merge queue taking it out.
569 const unchecked = requiredChecks.some((check) => check.state !== "success") || checks?.status === "failed";
570 const requiredFailed = requiredChecks.some((check) => check.state === "failure") || checks?.status === "failed";
571 // Pull requests for other issues changing the same files will conflict;
572 // ones for the same issue are alternatives, and expected to.
573 const collisions = overlaps.filter((other) => other.issue == null || other.issue !== pull.issue);
574 const review = {
575 changesUrl: here + "?tab=changes",
576 // Nobody reviews their own pull request, nor one g1t made for them.
577 canJudge: active && viewer != null && viewer.id !== workOwner(pull).id,
578 };
579 // Everyone whose review was asked for, then anyone who reviewed unasked.
580 const reviewerNames = [
581 ...new Set([...pull.reviewers, ...reviews.map(({ reviewer }) => reviewer)]),
582 ];
583 // A catch-up: the click shows at once; the answer says whether it is
584 // done already or a sandbox is on it.
585 const navigation = useNavigation();
586 const catchUpPending = navigation.state !== "idle" && navigation.formData?.get("action") === "update";
587 const catchUp = actionData?.action === "update" ? actionData : null;
588 const caughtUp = catchUp && "updated" in catchUp ? catchUp.updated : null;
589 const agentCatchUp = catchUp && "agent" in catchUp ? catchUp.agent : null;
590 // Pushed already: followed until the pull request's head is the merge,
591 // so its checks show starting again, for a minute at most.
592 const settling =
593 caughtUp != null && !caughtUp.already && pull.headCommit !== caughtUp.commit && Date.now() - caughtUp.at < 60_000;
594 // Followed until it is pushed, or for so long, never longer.
595 const catchingUp =
596 agentCatchUp != null && behind && Date.now() - agentCatchUp.startedAt < CATCH_UP_TIMEOUT_MS + REFRESH_MS;
597 // g1t is taking a step of its own accord, so the page will change.
598 const moving =
599 lifecycle != null && lifecycle.stage !== "ready" && lifecycle.stage !== "needs_you";
600 // Whether it merges cleanly is being worked out, so the box will change.
601 const probing = active && mergeable === "checking";
602 const conflicting = active && mergeable === "conflicting";
603 useRefreshWhile(working || checking || reviewPending || catchingUp || settling || moving || landing || probing);
604 // Why the merge button cannot be pressed, if it cannot.
605 const mergeBlocked = conflicting
606 ? "Resolve the conflicts first."
607 : probing
608 ? "Waiting to find out whether it merges cleanly."
609 : behind && requireUpToDate
610 ? `This repository requires it to be up to date with ${defaultBranch} first.`
611 : unchecked && !canIgnoreChecks
612 ? `The checks ${defaultBranch} requires have to pass first.`
613 : null;
614
615 return (
616 // The changes get the whole width; people and settings are a tab away.
617 <div>
618 {/* The title and the tabs run the full width; what is happening on
619 the pull request sits at the top of Conversation. */}
620 <header>
621 <h2 className="text-2xl font-semibold tracking-tight text-balance">
622 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
623 </h2>
624 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
625 <PullState status={pull.status} />
626 <span className="flex items-center gap-2">
627 <Avatar
628 name={(pull.status === "merged" && pull.mergedBy) || opener.name}
629 size={18}
630 />
631 <span>
632 <PersonLink
633 name={(pull.status === "merged" && pull.mergedBy) || opener.name}
634 className="font-medium text-fg hover:underline"
635 />{" "}
636 {pull.status === "merged" ? "merged" : "wants to merge"}
637 {pull.branch && (
638 <>
639 {" "}
640 <span className="font-mono text-fg">{pull.branch}</span>
641 </>
642 )}{" "}
643 into <span className="font-mono text-fg">{defaultBranch}</span>
644 </span>
645 </span>
646 {opener.requestedBy && (
647 <span className="text-xs">
648 requested by{" "}
649 <PersonLink name={opener.requestedBy} className="font-medium text-fg-soft hover:underline" />
650 </span>
651 )}
652 {/* A pull request from a branch was made by its author, not an agent;
653 one g1t made already says so. */}
654 {!pull.branch && !opener.requestedBy && (
655 <span className="flex items-center gap-1.5 font-mono text-xs">
656 <Bot size={14} />
657 {pull.agent}
658 {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
659 </span>
660 )}
661 </div>
662
663 {reviews.length > 0 && (
664 <p className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
665 {reviews.map(({ reviewer, verdict }) => (
666 <span
667 key={reviewer}
668 className={`flex items-center gap-1.5 ${
669 verdict === "approve" ? "text-accent" : "text-danger"
670 }`}
671 >
672 {verdict === "approve" ? <CircleCheck size={15} /> : <CircleSlash size={15} />}
673 {verdict === "approve" ? "Approved by" : "Changes requested by"}{" "}
674 <span className="font-medium">{reviewer}</span>
675 </span>
676 ))}
677 </p>
678 )}
679
680 <nav className="mt-6 flex items-end gap-1 overflow-x-auto border-b border-line">
681 <TabLink to={here} active={tab === "conversation"}>
682 <MessageSquare size={15} />
683 Conversation
684 <TabCount n={comments.length} />
685 </TabLink>
686 <TabLink to={here + "?tab=session"} active={tab === "session"}>
687 <MessagesSquare size={15} />
688 Session
689 </TabLink>
690 <TabLink to={here + "?tab=changes"} active={tab === "changes"}>
691 <FileDiff size={15} />
692 Files changed
693 <TabCount n={pull.files.length} />
694 </TabLink>
695 {pull.files.length > 0 && (
696 <span className="ml-auto hidden pb-2.5 font-mono text-xs sm:inline">
697 <span className="text-accent">+{pull.files.reduce((sum, file) => sum + file.additions, 0).toLocaleString("en-US")}</span>{" "}
698 <span className="text-danger">−{pull.files.reduce((sum, file) => sum + file.deletions, 0).toLocaleString("en-US")}</span>
699 </span>
700 )}
701 </nav>
702 </header>
703 <div className={`mt-6 grid gap-8 ${tab === "changes" ? "" : "lg:grid-cols-[1fr_19rem]"}`}>
704 <div className="min-w-0">
705 {/* What is happening on it now: its stage, its agent, what people told
706 the agent, and what else touches the same files. On the conversation,
707 so the tabs sit right under the title. */}
708 {tab === "conversation" && (
709 <div>
710 {issue && (
711 <Link
712 to={`${base}/issues/${issue.number}`}
713 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"
714 >
715 <IssueIcon issue={issue} />
716 <span className="text-muted">
717 {pull.status === "merged" && issue.resolvedBy === pull.number
718 ? "Resolved"
719 : "For issue"}
720 </span>
721 <span className="truncate font-medium">{issue.title}</span>
722 <span className="text-faint">#{issue.number}</span>
723 {issue.pullCount > 1 && (
724 <span className="ml-auto shrink-0 text-xs text-faint">
725 1 of {issue.pullCount} pull requests
726 </span>
727 )}
728 </Link>
729 )}
730 {pull.status === "merged" && (
731 <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">
732 <GitMerge size={16} className="shrink-0 text-merged" />
733 <span>
734 Merged into <span className="font-mono">{defaultBranch}</span> by{" "}
735 <span className="font-medium">{pull.mergedBy}</span>{" "}
736 {/* Not an account: the repository's settings said to merge it. */}
737 {pull.mergedBy === "g1t" && "automatically, once it was ready, "}
738 {pull.mergedAt && <TimeAgo at={pull.mergedAt} />}
739 {pull.headCommit && (
740 <span className="font-mono text-muted"> · {pull.headCommit.slice(0, 7)}</span>
741 )}
742 </span>
743 </p>
744 )}
745 {pull.supersededBy != null && (
746 <p className="mt-4 rounded-xl border border-line bg-surface px-4 py-2.5 text-sm text-muted">
747 Closed because{" "}
748 <Link
749 to={`${base}/pull/${pull.supersededBy}`}
750 className="font-medium text-fg hover:underline"
751 >
752 #{pull.supersededBy}
753 </Link>{" "}
754 was merged for this issue instead.
755 </p>
756 )}
757 {lifecycle && <LifecyclePanel lifecycle={lifecycle} />}
758 {/* The agent on it: who, doing what this minute, for how long, at what cost. */}
759 <AgentPanel
760 owner={params.owner}
761 repo={params.repo}
762 number={pull.number}
763 stage={lifecycle?.stage ?? null}
764 confidence={pull.confidence ?? null}
765 />
766
767 {/* Steering: while its agent works, people can tell it things. */}
768 {canRun &&
769 pull.runtime === "hosted" &&
770 (working || ["working", "revising", "catching_up", "answering"].includes(lifecycle?.stage ?? "")) && (
771 <Form method="post" className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-merged/30">
772 <p className="flex items-center gap-2 text-sm font-medium">
773 <Sparkles size={15} className="text-merged" />
774 Message the agent
775 </p>
776 <p className="mt-1 text-xs text-muted">
777 A correction, a hint, a change of plan. It reads it at its next step, without
778 starting over.
779 </p>
780 <div className="mt-3 flex gap-2">
781 <input type="hidden" name="action" value="message" />
782 <input
783 name="body"
784 required
785 autoComplete="off"
786 data-1p-ignore
787 placeholder="Keep the old flag working too…"
788 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"
789 />
790 <Button type="submit">Send</Button>
791 </div>
792 </Form>
793 )}
794 {messages.length > 0 && (
795 <ul className="mt-3 space-y-1.5">
796 {messages.map((message) => (
797 <li key={message.id} className="flex items-start gap-2 text-sm">
798 <Avatar name={message.author} size={18} />
799 <span className="min-w-0 grow">
800 <span className="font-medium">
801 {message.fromNumber != null ? `The agent on #${message.fromNumber}` : message.author}
802 </span>{" "}
803 <span className="text-muted">
804 {message.kind === "question"
805 ? "asked:"
806 : message.kind === "handoff"
807 ? "handed over:"
808 : message.kind === "answer"
809 ? "answered:"
810 : "to the agent:"}
811 </span>{" "}
812 {message.body}
813 {message.answer && (
814 <span className="mt-1 block border-l-2 border-merged/40 pl-2 text-muted">
815 {message.declined ? "Declined: " : "Answer: "}
816 {message.answer}
817 </span>
818 )}
819 </span>
820 <span className={`shrink-0 text-xs ${message.deliveredAt ? "text-accent" : "text-faint"}`}>
821 {message.deliveredAt ? "read by the agent" : "waiting for its next step"}
822 </span>
823 </li>
824 ))}
825 </ul>
826 )}
827 {collisions.length > 0 && active && (
828 <div className="mt-4 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
829 <p className="flex items-center gap-2.5 font-medium">
830 <Radar size={16} className="shrink-0 text-info" />
831 Other work is changing the same files
832 </p>
833 <ul className="mt-2 space-y-1.5">
834 {collisions.map((other) => (
835 <li key={other.number} className="flex flex-wrap items-baseline gap-x-2 text-muted">
836 <Link
837 to={`${base}/pull/${other.number}`}
838 className="font-medium text-fg hover:underline"
839 >
840 {other.title} <span className="font-normal text-faint">#{other.number}</span>
841 </Link>
842 <span className="font-mono text-xs">{other.paths.join(", ")}</span>
843 </li>
844 ))}
845 </ul>
846 <p className="mt-2 text-xs text-faint">
847 Whichever merges second will have to catch up, and may conflict.
848 </p>
849 </div>
850 )}
851 </div>
852 )}
853 <div className="mt-5">
854 {comparison ? (
855 <DiffView
856 comparison={comparison}
857 review={{
858 comments: comments.filter((comment) => comment.path),
859 canComment: Boolean(viewer),
860 }}
861 />
862 ) : tab === "session" ? (
863 session.length === 0 ? (
864 <EmptyState title="Nothing recorded yet">
865 The agent's prompts, reasoning and tool calls appear here as it
866 works.
867 </EmptyState>
868 ) : (
869 <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line">
870 {session.map((entry) => (
871 <Entry key={entry.seq} entry={entry} agent={pull.agent} />
872 ))}
873 </ol>
874 )
875 ) : (
876 <div className="space-y-4">
877 <TimelineItem
878 author={opener.name}
879 at={pull.createdAt}
880 action={
881 <span>
882 opened this pull request
883 {opener.requestedBy ? (
884 <>
885 {" "}
886 for{" "}
887 <PersonLink name={opener.requestedBy} className="font-medium text-fg-soft hover:underline" />
888 </>
889 ) : !pull.branch && (
890 <>
891 {" "}
892 with <span className="font-mono text-xs">{pull.agent}</span>
893 </>
894 )}
895 </span>
896 }
897 >
898 {pull.body ? (
899 <Markdown source={pull.body} repo={{ namespace: params.owner, name: params.repo }} />
900 ) : (
901 <p className="text-sm text-muted">
902 {working
903 ? "No description yet. It is written when the pull request is marked ready for review."
904 : "No description."}
905 </p>
906 )}
907 {canManage && pull.status === "open" && (
908 <details className="mt-3 border-t border-line pt-3 text-sm">
909 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
910 Edit description
911 </summary>
912 <Form method="post" className="mt-3 space-y-2">
913 <Textarea
914 name="summary"
915 rows={6}
916 placeholder="What changed and why"
917 defaultValue={pull.body ?? ""}
918 />
919 <Button type="submit">Save</Button>
920 </Form>
921 </details>
922 )}
923 </TimelineItem>
924
925 <CommentList comments={comments} review={review} base={base} />
926
927 {(preview || build) && <DeploymentCard preview={preview} build={build} stacked={stacked} base={base} />}
928
929 {pull.status === "draft" && (
930 <StatusBox>
931 <StatusRow icon={<Loader size={16} className="text-faint" />} title="This is a draft">
932 It is still being worked on. It can be reviewed and merged once it is
933 marked ready.
934 </StatusRow>
935 {canManage && (
936 <Form method="post" className="space-y-2 px-4 py-3">
937 <Textarea name="summary" rows={3} placeholder="What changed and why" />
938 <Button type="submit">Mark ready for review</Button>
939 </Form>
940 )}
941 </StatusBox>
942 )}
943
944 {pull.status === "open" && (
945 <StatusBox>
946 {/* Checks by status at once; job by job when the runs are read. */}
947 <Suspense fallback={checksSection({})}>
948 <Await resolve={workflowJobs}>{checksSection}</Await>
949 </Suspense>
950 {noChecks && <AddCiPrompt owner={params.owner} repo={params.repo} canAdd={canAddCi} compact />}
951 <StatusRow
952 icon={
953 reviews.some(({ verdict }) => verdict === "request_changes") ? (
954 <CircleSlash size={16} className="text-danger" />
955 ) : reviews.length > 0 ? (
956 <CircleCheck size={16} className="text-accent" />
957 ) : (
958 <MessageSquare size={16} className="text-faint" />
959 )
960 }
961 title={
962 reviews.length === 0
963 ? "No reviews yet"
964 : reviews
965 .map(
966 ({ reviewer, verdict }) =>
967 `${verdict === "approve" ? "Approved by" : "Changes requested by"} ${reviewer}`,
968 )
969 .join(" · ")
970 }
971 >
972 {reviewPending && "g1t is reviewing it now. "}
973 {requiredApprovals > 0 &&
974 `This repository requires ${requiredApprovals} approving ${
975 requiredApprovals === 1 ? "review" : "reviews"
976 } before merging.`}
977 </StatusRow>
978 {landing ? (
979 <StatusRow
980 icon={<Loader size={16} className="animate-spin text-accent" />}
981 title="Merging"
982 >
983 {defaultBranch} has moved, so g1t is bringing this up to date first. It
984 lands as soon as that is done. Watch it in the Session tab.
985 </StatusRow>
986 ) : agentCatchUp && behind ? (
987 <CatchUpProgress
988 owner={params.owner}
989 repo={params.repo}
990 number={pull.number}
991 defaultBranch={defaultBranch}
992 behind={behind}
993 update={agentCatchUp.update}
994 startedAt={agentCatchUp.startedAt}
995 retrying={catchUpPending}
996 />
997 ) : conflicting ? (
998 <ConflictsSection
999 conflicts={conflicts}
1000 pull={pull}
1001 owner={params.owner}
1002 repo={params.repo}
1003 defaultBranch={defaultBranch}
1004 changesUrl={here + "?tab=changes"}
1005 canResolve={canUpdate && agentsEnabled}
1006 resolving={catchUpPending}
1007 error={catchUp && "error" in catchUp ? catchUp.error : null}
1008 />
1009 ) : probing ? (
1010 <MergeabilityRow mergeable={mergeable} defaultBranch={defaultBranch} />
1011 ) : behind ? (
1012 <StatusRow
1013 icon={<GitPullRequestArrow size={16} className="text-info" />}
1014 title={`${defaultBranch} has moved since this was made`}
1015 >
1016 {mergeable === "clean" && "It has no conflicts with it. "}
1017 {requireUpToDate
1018 ? "This repository requires pull requests to be up to date, so it has to catch up before it can merge."
1019 : "That does not stop it merging: it is brought up to date as part of the merge."}
1020 {canUpdate && (
1021 <Form method="post" className="mt-2">
1022 <input type="hidden" name="action" value="update" />
1023 <Button variant="quiet" type="submit" disabled={catchUpPending}>
1024 {catchUpPending && <Loader size={14} className="animate-spin" />}
1025 {catchUpPending ? `Merging ${defaultBranch} in…` : `Catch up with ${defaultBranch} now`}
1026 </Button>
1027 </Form>
1028 )}
1029 {catchUp && "error" in catchUp && <ErrorText>{catchUp.error}</ErrorText>}
1030 </StatusRow>
1031 ) : (
1032 pull.headCommit && (
1033 <StatusRow
1034 icon={<CircleCheck size={16} className="text-accent" />}
1035 title={
1036 (caughtUp && !caughtUp.already) || agentCatchUp
1037 ? `Brought up to date with ${defaultBranch}`
1038 : `Up to date with ${defaultBranch}`
1039 }
1040 >
1041 {caughtUp && !caughtUp.already && (
1042 <>
1043 Merged <span className="font-mono">{defaultBranch}</span> in as{" "}
1044 <span className="font-mono text-fg">{caughtUp.commit.slice(0, 7)}</span>. Its checks run
1045 again on the new commit.
1046 </>
1047 )}
1048 {agentCatchUp && "g1t merged it in and pushed the result. Its checks run again on the new commit."}
1049 </StatusRow>
1050 )
1051 )}
1052 {stalled && !lifecycle && (
1053 <StatusRow icon={<Hand size={16} className="text-warn" />} title="Needs you">
1054 {stalled}
1055 </StatusRow>
1056 )}
1057 {canMerge && lifecycle?.stage === "queued" && (
1058 <Form method="post" className="flex flex-wrap items-center gap-3 px-4 py-3">
1059 <ButtonLink to={`${base}/queue`}>
1060 <Layers size={15} />
1061 See the queue
1062 </ButtonLink>
1063 <Button variant="quiet" type="submit" name="action" value="unqueue">
1064 Remove from the queue
1065 </Button>
1066 </Form>
1067 )}
1068 {canMerge && !landing && lifecycle?.stage !== "queued" && (
1069 <Form method="post" className="space-y-3 px-4 py-3">
1070 {issue?.state === "open" && (
1071 <CheckboxOption
1072 name="keepIssueOpen"
1073 label={`Keep #${issue.number} open. This is only part of the work.`}
1074 labelClassName="text-xs text-muted"
1075 />
1076 )}
1077 {unchecked && canIgnoreChecks && (
1078 <CheckboxOption
1079 name="ignoreChecks"
1080 label={`Bypass the required checks: merge although ${requiredFailed ? "one failed" : "they have not all passed"}.`}
1081 labelClassName="text-xs text-muted"
1082 />
1083 )}
1084 <div className="flex flex-wrap items-center gap-3">
1085 <Button
1086 variant="accent"
1087 type="submit"
1088 name="action"
1089 value="merge"
1090 disabled={mergeBlocked != null}
1091 title={mergeBlocked ?? undefined}
1092 >
1093 {mergeQueue ? <Layers size={15} /> : <GitMerge size={15} />}
1094 {mergeQueue ? "Add to the merge queue" : `Merge into ${defaultBranch}`}
1095 </Button>
1096 <span className={`text-xs ${mergeBlocked ? "text-danger" : "text-muted"}`}>
1097 {mergeBlocked ?? (mergeQueue
1098 ? `Tested together with everything ahead of it, then lands on ${defaultBranch}.`
1099 : issue?.state === "open"
1100 ? `Closes issue #${issue.number}, and any other pull requests still open for it.`
1101 : `Lands its commits on ${defaultBranch}.`)}
1102 </span>
1103 </div>
1104 {actionData?.action === "merge" && (
1105 <ErrorText>{actionData.error}</ErrorText>
1106 )}
1107 </Form>
1108 )}
1109 </StatusBox>
1110 )}
1111
1112 <CommentForm
1113 author={viewer?.username ?? null}
1114 resetKey={comments.length}
1115 review={review}
1116 />
1117 {canManage && active && (
1118 <Form method="post" className="flex justify-end">
1119 <Button variant="quiet" type="submit" name="action" value="close">
1120 Close pull request
1121 </Button>
1122 </Form>
1123 )}
1124 {actionData &&
1125 !["merge", "comment", "rerun-failed", "rerun-workflow", "update", "agent-review", "reviewers", "assign"].includes(
1126 String(actionData.action),
1127 ) && <ErrorText>{actionData.error}</ErrorText>}
1128 </div>
1129 )}
1130 {actionData?.action === "comment" && (
1131 <div className="mt-2">
1132 <ErrorText>{actionData.error}</ErrorText>
1133 </div>
1134 )}
1135 </div>
1136 </div>
1137
1138 <aside className={tab === "changes" ? "hidden" : "space-y-6"}>
1139 {/* An open pull request shows its checks in full in the merge box. */}
1140 {pull.status !== "open" && <WorkflowStatuses statuses={statuses} />}
1141 {affects.length > 0 && (
1142 <section>
1143 <h3 className="flex items-center gap-1.5 text-sm font-medium">
1144 <Network size={14} className="text-faint" />
1145 Affects
1146 </h3>
1147 <p className="mt-1 text-xs text-muted">Projects that use this one, and so may feel this change:</p>
1148 <ul className="mt-2 space-y-1 text-sm">
1149 {affects.map((project) => (
1150 <li key={project.slug} className="flex items-center justify-between gap-2">
1151 <Link to={`/${params.owner}/${project.slug}`} className="hover:underline">
1152 {project.name}
1153 </Link>
1154 {project.as && <code className="font-mono text-xs text-faint">{project.as}</code>}
1155 </li>
1156 ))}
1157 </ul>
1158 {preview?.branch && canMerge && (
1159 <Form method="post" className="mt-3">
1160 <input type="hidden" name="action" value="stack" />
1161 <input type="hidden" name="branch" value={preview.branch} />
1162 <button
1163 type="submit"
1164 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"
1165 >
1166 Preview them against this change
1167 </button>
1168 </Form>
1169 )}
1170 {actionData?.action === "stack" &&
1171 ("notice" in actionData ? (
1172 <p className="mt-2 text-xs text-accent">{String(actionData.notice)}</p>
1173 ) : (
1174 <ErrorText>{actionData.error}</ErrorText>
1175 ))}
1176 </section>
1177 )}
1178
1179 <section>
1180 <h3 className="text-sm font-medium">Reviewers</h3>
1181 <ul className="mt-2 space-y-1.5 text-sm">
1182 {reviewerNames.map((name) => {
1183 const verdict = reviews.find(({ reviewer }) => reviewer === name)?.verdict;
1184 const pending = name === "g1t" && reviewPending;
1185 return (
1186 <li key={name} className="flex items-center gap-2 px-1">
1187 {name === "g1t" ? (
1188 <Sparkles size={16} className="shrink-0 text-accent" />
1189 ) : (
1190 <Avatar name={name} size={20} />
1191 )}
1192 <span className="grow truncate font-mono text-xs">{name}</span>
1193 {pending ? (
1194 <span className="flex items-center gap-1.5 text-xs text-muted">
1195 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
1196 Reviewing
1197 </span>
1198 ) : verdict === "approve" ? (
1199 <span className="flex items-center gap-1 text-xs text-accent">
1200 <CircleCheck size={13} /> Approved
1201 </span>
1202 ) : verdict === "request_changes" ? (
1203 <span className="flex items-center gap-1 text-xs text-danger">
1204 <CircleSlash size={13} /> Changes requested
1205 </span>
1206 ) : (
1207 <span className="text-xs text-faint">Review requested</span>
1208 )}
1209 </li>
1210 );
1211 })}
1212 {reviewerNames.length === 0 && (
1213 <li className="px-1 text-xs text-faint">No reviews requested yet.</li>
1214 )}
1215 </ul>
1216 {pull.status === "open" && canManage && (
1217 <div className="mt-3 space-y-2">
1218 {agentsEnabled && !reviewPending && (
1219 <Form method="post">
1220 <input type="hidden" name="action" value="agent-review" />
1221 {pull.reviewers.map((name) => (
1222 <input key={name} type="hidden" name="reviewer" value={name} />
1223 ))}
1224 <div className="*:w-full">
1225 <Button variant="quiet" type="submit">
1226 <Sparkles size={14} className="text-accent" />
1227 Request review from g1t
1228 </Button>
1229 </div>
1230 <ComputeNote note={loaderData.computeNote} />
1231 </Form>
1232 )}
1233 <details>
1234 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
1235 Request review from people
1236 </summary>
1237 <Form method="post" className="mt-2 space-y-2" key={pull.reviewers.join()}>
1238 <input type="hidden" name="action" value="reviewers" />
1239 {pull.reviewers.includes("g1t") && (
1240 <input type="hidden" name="reviewer" value="g1t" />
1241 )}
1242 <PeoplePicker
1243 name="reviewer"
1244 members={members.filter((name) => name !== workOwner(pull).username)}
1245 chosen={pull.reviewers.filter((name) => name !== "g1t")}
1246 />
1247 <Button variant="quiet" type="submit">
1248 Save reviewers
1249 </Button>
1250 </Form>
1251 </details>
1252 </div>
1253 )}
1254 {["agent-review", "reviewers"].includes(String(actionData?.action)) && (
1255 <ErrorText>{actionData?.error}</ErrorText>
1256 )}
1257 </section>
1258
1259 <section>
1260 <h3 className="text-sm font-medium">Assignees</h3>
1261 <ul className="mt-2 space-y-1.5 text-sm">
1262 {pull.assignees.map((name) => (
1263 <li key={name} className="flex items-center gap-2 px-1">
1264 <Avatar name={name} size={20} />
1265 <span className="grow truncate font-mono text-xs">{name}</span>
1266 </li>
1267 ))}
1268 {pull.assignees.length === 0 && (
1269 <li className="px-1 text-xs text-faint">No one yet.</li>
1270 )}
1271 </ul>
1272 {viewer && canManage && active && (
1273 <div className="mt-3 space-y-2">
1274 {!pull.assignees.includes(viewer.username) && (
1275 <Form method="post">
1276 <input type="hidden" name="action" value="assign" />
1277 {pull.assignees.map((name) => (
1278 <input key={name} type="hidden" name="assignee" value={name} />
1279 ))}
1280 <input type="hidden" name="assignee" value={viewer.username} />
1281 <div className="*:w-full">
1282 <Button variant="quiet" type="submit">
1283 Assign yourself
1284 </Button>
1285 </div>
1286 </Form>
1287 )}
1288 <details>
1289 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
1290 Assign people
1291 </summary>
1292 <Form method="post" className="mt-2 space-y-2" key={pull.assignees.join()}>
1293 <input type="hidden" name="action" value="assign" />
1294 <PeoplePicker name="assignee" members={members} chosen={pull.assignees} />
1295 <Button variant="quiet" type="submit">
1296 Save assignees
1297 </Button>
1298 </Form>
1299 </details>
1300 </div>
1301 )}
1302 {actionData?.action === "assign" && <ErrorText>{actionData.error}</ErrorText>}
1303 </section>
1304
1305 <section>
1306 <h3 className="text-sm font-medium">Working copy</h3>
1307 {pull.branch ? (
1308 <>
1309 <p className="mt-1 text-xs text-muted">
1310 <GitBranch size={13} className="mr-1 inline align-[-2px]" />
1311 Branch <span className="font-mono text-fg">{pull.branch}</span> of this
1312 repository. Pushes to it show up here.
1313 </p>
1314 <div className="mt-2">
1315 <CopyLine text={`git clone -b ${pull.branch} ${remote}`} />
1316 </div>
1317 </>
1318 ) : (
1319 <>
1320 <p className="mt-1 text-xs text-muted">
1321 This fork belongs to the pull request. Pushes to it show up here.
1322 </p>
1323 <div className="mt-2">
1324 <CopyLine text={`git clone ${remote}`} />
1325 </div>
1326 </>
1327 )}
1328 <p className="mt-3 flex items-center gap-1.5 font-mono text-xs text-faint">
1329 <GitCommitHorizontal size={13} />
1330 {pull.headCommit?.slice(0, 12) ?? "no commits pushed yet"}
1331 </p>
1332 </section>
1333 </aside>
1334 </div>
1335 </div>
1336 );
1337}
1338
1339/** Where the pull request's change is live, as GitHub shows a deployment. */
1340function DeploymentCard({
1341 preview,
1342 build,
1343 stacked,
1344 base,
1345}: {
1346 preview: LiveApp | null;
1347 build: Deployment | null;
1348 stacked: Promise<Stacked[]>;
1349 base: string;
1350}) {
1351 const building = build?.status === "queued" || build?.status === "building";
1352 const failed = build?.status === "failed";
1353 return (
1354 <section className="ml-12 overflow-hidden rounded-xl border border-line bg-surface">
1355 <div className="flex flex-wrap items-center gap-3 px-4 py-3">
1356 <span
1357 className={`flex size-8 shrink-0 items-center justify-center rounded-full ring-1 ${
1358 failed ? "text-danger ring-danger/40" : building ? "text-warn ring-warn/40" : "text-accent ring-accent/40"
1359 }`}
1360 >
1361 {building ? <Loader size={15} className="animate-spin" /> : <Rocket size={15} />}
1362 </span>
1363 <div className="min-w-0 grow">
1364 <p className="text-sm font-medium">
1365 {building
1366 ? preview
1367 ? "Deploying the latest push. The last preview is still live."
1368 : "Deploying a preview"
1369 : failed
1370 ? "The preview failed to deploy"
1371 : "This branch is live"}
1372 </p>
1373 {preview ? (
1374 <a href={preview.url} className="mt-0.5 block truncate font-mono text-xs text-muted hover:text-accent">
1375 {preview.url.replace(/^https?:[/][/]/, "")}
1376 </a>
1377 ) : (
1378 build?.error && <p className="mt-0.5 truncate text-xs text-muted">{build.error}</p>
1379 )}
1380 </div>
1381 <div className="flex shrink-0 items-center gap-2">
1382 {build && (
1383 <Link to={`${base}/deployments/${build.id}`} className="text-xs text-muted hover:text-fg">
1384 {failed ? "See why" : "Build log"}
1385 </Link>
1386 )}
1387 {preview && (
1388 <a
1389 href={preview.url}
1390 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"
1391 >
1392 Visit preview
1393 <ArrowUpRight size={13} />
1394 </a>
1395 )}
1396 </div>
1397 </div>
1398 {preview && (
1399 <p className="border-t border-line px-4 py-2 text-xs text-faint">
1400 <span className="font-mono">{preview.commit.slice(0, 7)}</span> · deployed <TimeAgo at={preview.deployedAt} />
1401 {preview.branch && ` · from ${preview.branch}`}
1402 </p>
1403 )}
1404 {/* Read after the page: the card's own part shows at once. */}
1405 <Suspense fallback={null}>
1406 <Await resolve={stacked}>
1407 {(stacked) =>
1408 stacked.length > 0 && (
1409 <div className="border-t border-line px-4 py-2.5">
1410 <p className="text-xs text-muted">Built against this change:</p>
1411 <ul className="mt-1.5 space-y-1">
1412 {stacked.map((entry) => (
1413 <li key={entry.slug} className="flex items-center gap-2 text-xs">
1414 <span className="font-medium">{entry.name}</span>
1415 <a href={entry.url} className="truncate font-mono text-muted hover:text-accent">
1416 {entry.url.replace(/^https?:[/][/]/, "")}
1417 </a>
1418 </li>
1419 ))}
1420 </ul>
1421 </div>
1422 )
1423 }
1424 </Await>
1425 </Suspense>
1426 </section>
1427 );
1428}