Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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