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