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