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