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