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.

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