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