Skip to content

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

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