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