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