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