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