Skip to content

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

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