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