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