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