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