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