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