Skip to content

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

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