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.

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