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