Skip to content

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

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