Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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