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