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