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