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