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.

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