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