Skip to content

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

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