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