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