flagon-io/g1t

public

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/apps/web/app/routes/repo/pull.tsx

1,204 lines49,491 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import {
3 Bot,
4 ChevronRight,
5 FileDiff,
6 GitBranch,
7 GitCommitHorizontal,
8 CircleCheck,
9 CircleSlash,
10 GitMerge,
11 Layers,
12 GitPullRequestArrow,
13 Hand,
14 Loader,
15 Radar,
16 Sparkles,
17 MessageSquare,
18 ArrowUpRight,
19 MessagesSquare,
20 Rocket,
21 Network,
22 StickyNote,
23 User,
24 Wrench,
25} from "lucide-react";
26import { useEffect, useState } from "react";
27import { Form, Link, redirect, useRevalidator } from "react-router";
28
29import {
30 type Comparison,
31 type Deployment,
32 type LiveApp,
33 type SessionEntry,
34 type Viewer,
35 pullComparison,
36} from "@g1t/contracts";
37
38import type { Route } from "./+types/pull";
39import { DiffView } from "../../components/diff-view";
40import { LifecyclePanel } from "../../components/lifecycle";
41import { Markdown } from "../../components/markdown";
42import {
43 Avatar,
44 Button,
45 ButtonLink,
46 CopyLine,
47 EmptyState,
48 ErrorText,
49 Textarea,
50 TimeAgo,
51} from "../../components/ui";
52import { WorkflowStatuses } from "../../components/actions";
53import { ChecksPanel } from "../../components/checks";
54import {
55 CommentForm,
56 CommentList,
57 IssueIcon,
58 PeoplePicker,
59 PullState,
60 TimelineItem,
61 verdicts,
62} from "../../components/work";
63import { deployments, identity, projects, repos, work } from "../../lib/services.server";
64import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
65
66const REFRESH_MS = 4000;
67const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
68const TABS = ["conversation", "session", "changes"] as const;
69type Tab = (typeof TABS)[number];
70
71export function meta({ loaderData, params }: Route.MetaArgs) {
72 const title = loaderData
73 ? `${loaderData.pull.title} · Pull request #${loaderData.pull.number} · `
74 : "";
75 return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
76}
77
78export async function loader({ params, context, request }: Route.LoaderArgs) {
79 const viewer = getViewer(context);
80 const path = { namespace: params.owner, name: params.repo };
81 const number = Number(params.number);
82 const asked = new URL(request.url).searchParams.get("tab");
83 const tab: Tab = TABS.find((name) => name === asked) ?? "conversation";
84
85 const member = (viewer?.workspaces ?? []).some(
86 (membership) => membership.slug === params.owner,
87 );
88 // At once: none of these depends on another.
89 const ref = { workspace: params.owner, slug: params.repo };
90 const [found, repo, settings, agentsEnabled, members, deps, deployed] = await Promise.all([
91 work.getPull(path, number, viewer),
92 repos.get(path, viewer),
93 work.getSettings(path, viewer),
94 env.RUNNER.enabled(viewer, path),
95 // A member picks reviewers and assignees from the workspace's people.
96 member ? identity.listMembers(params.owner, viewer) : null,
97 // The projects that use this one: what a change here can affect.
98 projects.dependencies(params.owner, params.repo, viewer),
99 member ? deployments.list(ref, viewer) : null,
100 ]);
101 if (!found.ok) {
102 // Issues and pull requests share numbers; this one may be an issue.
103 const issue = await work.getIssue(path, number, viewer);
104 if (issue.ok) throw redirect(`/${params.owner}/${params.repo}/issues/${number}`);
105 throw new Response("Pull request not found.", { status: 404 });
106 }
107 const { pull } = found.value;
108 const range = pullComparison(pull);
109 const [session, comparison] = await Promise.all([
110 tab === "session" ? work.readSession(path, number, viewer) : null,
111 tab === "changes"
112 ? repos.compare(range.repoId, viewer, range.base, range.head)
113 : null,
114 ]);
115 return {
116 ...found.value,
117 tab,
118 session: session?.ok ? session.value : [],
119 // An empty comparison if it could not be made.
120 comparison: comparison && (comparison.ok ? comparison.value : EMPTY_COMPARISON),
121 viewer,
122 // Members of the repository's workspace can merge.
123 canMerge: member,
124 canManage: member || viewer?.id === pull.author.id,
125 // A catch-up is pushed as the viewer: a fork takes pushes only from
126 // whoever opened it, a branch from any member.
127 canUpdate: pull.fork ? viewer?.id === pull.author.id : member,
128 agentsEnabled,
129 members: members?.ok ? members.value.map((person) => person.username) : [],
130 requireUpToDate: settings.ok && settings.value.requireUpToDate,
131 mergeQueue: settings.ok && settings.value.mergeQueue,
132 requiredApprovals: settings.ok ? settings.value.requiredApprovals : 0,
133 canIgnoreChecks: !settings.ok || settings.value.allowIgnoringChecks,
134 defaultBranch: repo.ok ? repo.value.defaultBranch : "main",
135 affects: deps.ok ? deps.value.usedBy : [],
136 ...(await deploymentOf(deployed?.ok ? deployed.value : null, number, params.owner, deps.ok ? deps.value.usedBy : [], viewer)),
137 };
138}
139
140/**
141 * Where this pull request is live: its preview (or its latest build, while
142 * one is going or after one failed), and the previews of the projects
143 * that use this one, built against it.
144 */
145async function deploymentOf(
146 list: { deployments: Deployment[]; live: LiveApp[] } | null,
147 number: number,
148 owner: string,
149 affects: { slug: string; name: string }[],
150 viewer: Viewer,
151) {
152 if (!list) return { preview: null, build: null, stacked: [] };
153 const preview = list.live.find((app) => app.kind === "preview" && app.number === number) ?? null;
154 const build = list.deployments.find((d) => d.kind === "preview" && d.number === number) ?? null;
155 const branch = preview?.branch ?? build?.branch ?? null;
156 const stacked = branch
157 ? (
158 await Promise.all(
159 affects.slice(0, 5).map(async (project) => {
160 const theirs = await deployments.list({ workspace: owner, slug: project.slug }, viewer);
161 const app = theirs.ok ? theirs.value.live.find((a) => a.kind === "preview" && a.branch === branch) : undefined;
162 return app ? { name: project.name, slug: project.slug, url: app.url } : null;
163 }),
164 )
165 ).filter((entry) => entry != null)
166 : [];
167 return { preview, build, stacked };
168}
169
170export async function action({ request, params, context }: Route.ActionArgs) {
171 assertSameOrigin(request);
172 const user = requireUser(context, request);
173 const form = await request.formData();
174 const path = { namespace: params.owner, name: params.repo };
175 const number = Number(params.number);
176 const action = form.get("action");
177 const verdict = form.get("verdict");
178 const line = Number(form.get("line"));
179 /** Names ticked in a people picker, plus those typed beside it. */
180 const picked = (field: string) => [
181 ...form.getAll(field).map(String),
182 ...String(form.get("others") ?? "").split(/[\s,]+/),
183 ];
184 // The projects that use this one, built against this pull request's preview.
185 if (action === "stack") {
186 const built = await deployments.stack(user, { workspace: params.owner, slug: params.repo }, String(form.get("branch") ?? ""));
187 return built.ok
188 ? { action, notice: `Building ${built.value.join(", ")} against this preview. They appear on their Deployments pages.` }
189 : { action, error: built.error.message };
190 }
191 // Asking a g1t agent for its review records the request, then starts it.
192 if (action === "agent-review") {
193 const asked = await work.updatePull(user, path, number, {
194 reviewers: [...form.getAll("reviewer").map(String), "g1t-agent"],
195 });
196 if (!asked.ok) return { error: asked.error.message, action };
197 }
198 const result =
199 action === "merge"
200 ? await work.mergePull(user, path, number, {
201 keepIssueOpen: form.get("keepIssueOpen") === "on",
202 ignoreChecks: form.get("ignoreChecks") === "on",
203 })
204 : action === "unqueue"
205 ? await work.removeFromQueue(user, path, number)
206 : action === "message"
207 ? await work.messageAgent(user, path, number, String(form.get("body") ?? ""))
208 : action === "close"
209 ? await work.closePull(user, path, number)
210 : action === "recheck"
211 ? await env.RUNNER.recheck(user, path, number)
212 : action === "update"
213 ? await env.RUNNER.update(user, path, number)
214 : action === "agent-review"
215 ? await env.RUNNER.review(user, path, number)
216 : action === "reviewers"
217 ? await work.updatePull(user, path, number, { reviewers: picked("reviewer") })
218 : action === "assign"
219 ? await work.updatePull(user, path, number, { assignees: picked("assignee") })
220 : action === "comment"
221 ? await work.addComment(user, path, number, {
222 body: String(form.get("body") ?? ""),
223 path: String(form.get("path") ?? "") || undefined,
224 line: line > 0 ? line : undefined,
225 verdict:
226 verdict === "approve" || verdict === "request_changes" ? verdict : undefined,
227 })
228 : await work.readyPull(user, path, number, String(form.get("summary") ?? ""));
229 return result.ok ? null : { error: result.error.message, action };
230}
231
232function TabLink({
233 to,
234 active,
235 children,
236}: {
237 to: string;
238 active: boolean;
239 children: React.ReactNode;
240}) {
241 return (
242 <Link
243 to={to}
244 preventScrollReset
245 className={
246 "-mb-px flex items-center gap-2 border-b-2 px-1 pb-2.5 text-sm transition-colors " +
247 (active
248 ? "border-accent font-medium text-fg"
249 : "border-transparent text-muted hover:text-fg")
250 }
251 >
252 {children}
253 </Link>
254 );
255}
256
257/** One step of the session, on the timeline's rail. */
258function Entry({ entry, agent }: { entry: SessionEntry; agent: string }) {
259 const isTool = entry.kind === "tool_call" || entry.kind === "tool_result";
260 const Icon =
261 entry.kind === "prompt"
262 ? User
263 : entry.kind === "note"
264 ? StickyNote
265 : isTool
266 ? Wrench
267 : Bot;
268 return (
269 <li className="relative pl-10">
270 <span
271 className={`absolute top-0.5 left-0 flex size-7 items-center justify-center rounded-full border bg-bg ${
272 entry.kind === "prompt"
273 ? "border-accent/50 text-accent"
274 : "border-line text-faint"
275 }`}
276 >
277 <Icon size={14} />
278 </span>
279 {isTool ? (
280 <details className="group rounded-lg border border-line bg-surface">
281 <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-1.5 text-sm">
282 <ChevronRight
283 size={14}
284 className="text-faint transition-transform group-open:rotate-90"
285 />
286 <span className="font-mono text-xs text-accent">
287 {entry.tool ?? "tool"}
288 </span>
289 <span className="truncate font-mono text-xs text-muted">
290 {entry.kind === "tool_result" ? "→ " : ""}
291 {entry.text.split("\n")[0]}
292 </span>
293 </summary>
294 <pre className="overflow-x-auto border-t border-line p-3 font-mono text-xs whitespace-pre-wrap text-muted">
295 <code>{entry.text}</code>
296 </pre>
297 </details>
298 ) : (
299 <div>
300 <p className="text-xs font-medium text-faint">
301 {entry.kind === "prompt"
302 ? "Prompt"
303 : entry.kind === "note"
304 ? "Note"
305 : agent}
306 </p>
307 {entry.kind === "message" ? (
308 <div className="mt-1">
309 <Markdown source={entry.text} />
310 </div>
311 ) : entry.kind === "prompt" ? (
312 <div className="mt-1.5 max-h-[32rem] overflow-y-auto rounded-xl border border-line bg-surface p-4">
313 <Markdown source={entry.text} />
314 </div>
315 ) : (
316 <p className="mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap">
317 {entry.text}
318 </p>
319 )}
320 </div>
321 )}
322 {entry.commit && (
323 <p className="mt-1.5 flex items-center gap-1 font-mono text-xs text-faint">
324 <GitCommitHorizontal size={12} />
325 {entry.commit.slice(0, 7)}
326 </p>
327 )}
328 </li>
329 );
330}
331
332/** The box at the foot of the conversation saying what stands before a merge. */
333function StatusBox({ children }: { children: React.ReactNode }) {
334 return (
335 <div className="flex gap-3">
336 <span className="hidden w-8 shrink-0 sm:block" />
337 <section className="min-w-0 grow divide-y divide-line overflow-hidden rounded-xl border border-line-strong bg-surface">
338 {children}
339 </section>
340 </div>
341 );
342}
343
344function StatusRow({
345 icon,
346 title,
347 children,
348}: {
349 icon: React.ReactNode;
350 title: string;
351 children?: React.ReactNode;
352}) {
353 return (
354 <div className="flex gap-3 px-4 py-3 text-sm">
355 <span className="mt-0.5 shrink-0">{icon}</span>
356 <div className="min-w-0">
357 <p className="font-medium">{title}</p>
358 {children && <div className="mt-0.5 text-muted">{children}</div>}
359 </div>
360 </div>
361 );
362}
363
364export default function PullPage({ loaderData, actionData, params }: Route.ComponentProps) {
365 const {
366 pull,
367 issue,
368 comments,
369 checks,
370 overlaps,
371 behind,
372 reviewPending,
373 lifecycle,
374 messages,
375 statuses = [],
376 affects,
377 preview,
378 build,
379 stacked,
380 landing,
381 stalled,
382 requireUpToDate,
383 mergeQueue,
384 requiredApprovals,
385 canIgnoreChecks,
386 canUpdate,
387 agentsEnabled,
388 members,
389 tab,
390 session,
391 comparison,
392 viewer,
393 canMerge,
394 canManage,
395 defaultBranch,
396 } = loaderData;
397 const base = `/${params.owner}/${params.repo}`;
398 const here = `${base}/pull/${pull.number}`;
399 const remote = pull.fork
400 ? `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`
401 : `https://g1t.sh/${params.owner}/${params.repo}.git`;
402 const active = pull.status === "draft" || pull.status === "open";
403
404 // Follow an agent at work, or checks in progress, without a manual reload.
405 const revalidator = useRevalidator();
406 const working = pull.status === "draft";
407 const checking =
408 checks?.status === "queued" || checks?.status === "running" || statuses.some((status) => status.state === "pending");
409 const reviews = verdicts(comments);
410 // What stands between this pull request and a merge, if anything.
411 const unchecked = checks && checks.status !== "passed";
412 // Pull requests for other issues changing the same files will conflict;
413 // ones for the same issue are alternatives, and expected to.
414 const collisions = overlaps.filter((other) => other.issue == null || other.issue !== pull.issue);
415 const review = {
416 changesUrl: here + "?tab=changes",
417 // Nobody reviews their own pull request.
418 canJudge: active && viewer != null && viewer.id !== pull.author.id,
419 };
420 // Everyone whose review was asked for, then anyone who reviewed unasked.
421 const reviewerNames = [
422 ...new Set([...pull.reviewers, ...reviews.map(({ reviewer }) => reviewer)]),
423 ];
424 const [submitted, setSubmitted] = useState<string | null>(null);
425 const catchingUp = submitted === "update" && behind;
426 // g1t is taking a step of its own accord, so the page will change.
427 const moving =
428 lifecycle != null && lifecycle.stage !== "ready" && lifecycle.stage !== "needs_you";
429 useEffect(() => {
430 if (!working && !checking && !reviewPending && !catchingUp && !moving && !landing) return;
431 const timer = setInterval(() => {
432 if (document.visibilityState === "visible") revalidator.revalidate();
433 }, REFRESH_MS);
434 return () => clearInterval(timer);
435 }, [working, checking, reviewPending, catchingUp, moving, landing, revalidator]);
436
437 return (
438 // The changes get the whole width; people and settings are a tab away.
439 <div className={`grid gap-8 ${tab === "changes" ? "" : "lg:grid-cols-[1fr_19rem]"}`}>
440 <div className="min-w-0">
441 <h2 className="text-2xl font-semibold tracking-tight text-balance">
442 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
443 </h2>
444 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
445 <PullState status={pull.status} />
446 <span className="flex items-center gap-2">
447 <Avatar
448 name={(pull.status === "merged" && pull.mergedBy) || pull.author.username}
449 size={18}
450 />
451 <span>
452 <span className="font-medium text-fg">
453 {(pull.status === "merged" && pull.mergedBy) || pull.author.username}
454 </span>{" "}
455 {pull.status === "merged" ? "merged" : "wants to merge"}
456 {pull.branch && (
457 <>
458 {" "}
459 <span className="font-mono text-fg">{pull.branch}</span>
460 </>
461 )}{" "}
462 into <span className="font-mono text-fg">{defaultBranch}</span>
463 </span>
464 </span>
465 {/* A pull request from a branch was made by its author, not an agent. */}
466 {!pull.branch && (
467 <span className="flex items-center gap-1.5 font-mono text-xs">
468 <Bot size={14} />
469 {pull.agent}
470 {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
471 </span>
472 )}
473 </div>
474
475 {reviews.length > 0 && (
476 <p className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
477 {reviews.map(({ reviewer, verdict }) => (
478 <span
479 key={reviewer}
480 className={`flex items-center gap-1.5 ${
481 verdict === "approve" ? "text-accent" : "text-danger"
482 }`}
483 >
484 {verdict === "approve" ? <CircleCheck size={15} /> : <CircleSlash size={15} />}
485 {verdict === "approve" ? "Approved by" : "Changes requested by"}{" "}
486 <span className="font-medium">{reviewer}</span>
487 </span>
488 ))}
489 </p>
490 )}
491
492 {lifecycle && <LifecyclePanel lifecycle={lifecycle} />}
493
494 {/* Steering: while its agent works, people can tell it things. */}
495 {canManage &&
496 pull.runtime === "hosted" &&
497 (working || ["working", "revising", "catching_up", "answering"].includes(lifecycle?.stage ?? "")) && (
498 <Form method="post" className="mt-4 rounded-2xl bg-surface p-4 ring-1 ring-merged/30">
499 <p className="flex items-center gap-2 text-sm font-medium">
500 <Sparkles size={15} className="text-merged" />
501 Message the agent
502 </p>
503 <p className="mt-1 text-xs text-muted">
504 A correction, a hint, a change of plan. It reads it at its next step, without
505 starting over.
506 </p>
507 <div className="mt-3 flex gap-2">
508 <input type="hidden" name="action" value="message" />
509 <input
510 name="body"
511 required
512 autoComplete="off"
513 data-1p-ignore
514 placeholder="Keep the old flag working too…"
515 className="h-9 min-w-0 grow rounded-md bg-bg px-3 text-sm ring-1 ring-line outline-none placeholder:text-faint focus:ring-merged/60"
516 />
517 <Button type="submit">Send</Button>
518 </div>
519 </Form>
520 )}
521 {messages.length > 0 && (
522 <ul className="mt-3 space-y-1.5">
523 {messages.map((message) => (
524 <li key={message.id} className="flex items-start gap-2 text-sm">
525 <Avatar name={message.author} size={18} />
526 <span className="min-w-0 grow">
527 <span className="font-medium">
528 {message.fromNumber != null ? `The agent on #${message.fromNumber}` : message.author}
529 </span>{" "}
530 <span className="text-muted">
531 {message.kind === "question"
532 ? "asked:"
533 : message.kind === "handoff"
534 ? "handed over:"
535 : message.kind === "answer"
536 ? "answered:"
537 : "to the agent:"}
538 </span>{" "}
539 {message.body}
540 {message.answer && (
541 <span className="mt-1 block border-l-2 border-merged/40 pl-2 text-muted">
542 {message.declined ? "Declined: " : "Answer: "}
543 {message.answer}
544 </span>
545 )}
546 </span>
547 <span className={`shrink-0 text-xs ${message.deliveredAt ? "text-accent" : "text-faint"}`}>
548 {message.deliveredAt ? "read by the agent" : "waiting for its next step"}
549 </span>
550 </li>
551 ))}
552 </ul>
553 )}
554
555 {issue && (
556 <Link
557 to={`${base}/issues/${issue.number}`}
558 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"
559 >
560 <IssueIcon issue={issue} />
561 <span className="text-muted">
562 {pull.status === "merged" && issue.resolvedBy === pull.number
563 ? "Resolved"
564 : "For issue"}
565 </span>
566 <span className="truncate font-medium">{issue.title}</span>
567 <span className="text-faint">#{issue.number}</span>
568 {issue.pullCount > 1 && (
569 <span className="ml-auto shrink-0 text-xs text-faint">
570 1 of {issue.pullCount} pull requests
571 </span>
572 )}
573 </Link>
574 )}
575
576 {pull.status === "merged" && (
577 <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">
578 <GitMerge size={16} className="shrink-0 text-merged" />
579 <span>
580 Merged into <span className="font-mono">{defaultBranch}</span> by{" "}
581 <span className="font-medium">{pull.mergedBy}</span>{" "}
582 {/* Not an account: the repository's settings said to merge it. */}
583 {pull.mergedBy === "g1t" && "automatically, once it was ready, "}
584 {pull.mergedAt && <TimeAgo at={pull.mergedAt} />}
585 {pull.headCommit && (
586 <span className="font-mono text-muted"> · {pull.headCommit.slice(0, 7)}</span>
587 )}
588 </span>
589 </p>
590 )}
591 {pull.supersededBy != null && (
592 <p className="mt-4 rounded-xl border border-line bg-surface px-4 py-2.5 text-sm text-muted">
593 Closed because{" "}
594 <Link
595 to={`${base}/pull/${pull.supersededBy}`}
596 className="font-medium text-fg hover:underline"
597 >
598 #{pull.supersededBy}
599 </Link>{" "}
600 was merged for this issue instead.
601 </p>
602 )}
603
604 {collisions.length > 0 && active && (
605 <div className="mt-4 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
606 <p className="flex items-center gap-2.5 font-medium">
607 <Radar size={16} className="shrink-0 text-info" />
608 Other work is changing the same files
609 </p>
610 <ul className="mt-2 space-y-1.5">
611 {collisions.map((other) => (
612 <li key={other.number} className="flex flex-wrap items-baseline gap-x-2 text-muted">
613 <Link
614 to={`${base}/pull/${other.number}`}
615 className="font-medium text-fg hover:underline"
616 >
617 {other.title} <span className="font-normal text-faint">#{other.number}</span>
618 </Link>
619 <span className="font-mono text-xs">{other.paths.join(", ")}</span>
620 </li>
621 ))}
622 </ul>
623 <p className="mt-2 text-xs text-faint">
624 Whichever merges second will have to catch up, and may conflict.
625 </p>
626 </div>
627 )}
628
629 <nav className="mt-8 flex gap-6 border-b border-line">
630 <TabLink to={here} active={tab === "conversation"}>
631 <MessageSquare size={15} />
632 Conversation
633 </TabLink>
634 <TabLink to={here + "?tab=session"} active={tab === "session"}>
635 <MessagesSquare size={15} />
636 Session
637 </TabLink>
638 <TabLink to={here + "?tab=changes"} active={tab === "changes"}>
639 <FileDiff size={15} />
640 Changes
641 </TabLink>
642 </nav>
643 <div className="mt-5">
644 {comparison ? (
645 <DiffView
646 comparison={comparison}
647 review={{
648 comments: comments.filter((comment) => comment.path),
649 canComment: Boolean(viewer),
650 }}
651 />
652 ) : tab === "session" ? (
653 session.length === 0 ? (
654 <EmptyState title="Nothing recorded yet">
655 The agent's prompts, reasoning and tool calls appear here as it
656 works.
657 </EmptyState>
658 ) : (
659 <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line">
660 {session.map((entry) => (
661 <Entry key={entry.seq} entry={entry} agent={pull.agent} />
662 ))}
663 </ol>
664 )
665 ) : (
666 <div className="space-y-4">
667 <TimelineItem
668 author={pull.author.username}
669 at={pull.createdAt}
670 action={
671 <span>
672 opened this pull request
673 {!pull.branch && (
674 <>
675 {" "}
676 with <span className="font-mono text-xs">{pull.agent}</span>
677 </>
678 )}
679 </span>
680 }
681 >
682 {pull.body ? (
683 <Markdown source={pull.body} repo={{ namespace: params.owner, name: params.repo }} />
684 ) : (
685 <p className="text-sm text-muted">
686 {working
687 ? "No description yet. It is written when the pull request is marked ready for review."
688 : "No description."}
689 </p>
690 )}
691 {canManage && pull.status === "open" && (
692 <details className="mt-3 border-t border-line pt-3 text-sm">
693 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
694 Edit description
695 </summary>
696 <Form method="post" className="mt-3 space-y-2">
697 <Textarea
698 name="summary"
699 rows={6}
700 placeholder="What changed and why"
701 defaultValue={pull.body ?? ""}
702 />
703 <Button type="submit">Save</Button>
704 </Form>
705 </details>
706 )}
707 </TimelineItem>
708
709 <CommentList comments={comments} review={review} base={base} />
710
711 {(preview || build) && (
712 <DeploymentCard preview={preview} build={build} stacked={stacked} base={base} />
713 )}
714
715 {pull.status === "draft" && (
716 <StatusBox>
717 <StatusRow icon={<Loader size={16} className="text-faint" />} title="This is a draft">
718 It is still being worked on. It can be reviewed and merged once it is
719 marked ready.
720 </StatusRow>
721 {canManage && (
722 <Form method="post" className="space-y-2 px-4 py-3">
723 <Textarea name="summary" rows={3} placeholder="What changed and why" />
724 <Button type="submit">Mark ready for review</Button>
725 </Form>
726 )}
727 </StatusBox>
728 )}
729
730 {pull.status === "open" && (
731 <StatusBox>
732 {checks && (
733 <StatusRow
734 icon={
735 checks.status === "passed" ? (
736 <CircleCheck size={16} className="text-accent" />
737 ) : checking ? (
738 <Loader size={16} className="animate-spin text-faint" />
739 ) : (
740 <CircleSlash size={16} className="text-danger" />
741 )
742 }
743 title={
744 checks.status === "passed"
745 ? "Acceptance checks passed"
746 : checking
747 ? "Acceptance checks are running"
748 : checks.status === "failed"
749 ? "Acceptance checks failed"
750 : "Acceptance checks could not be run"
751 }
752 >
753 {checks.results.length > 0 &&
754 `${checks.results.filter((result) => result.passed).length} of ${checks.results.length} passed, in a clean sandbox.`}
755 </StatusRow>
756 )}
757 <StatusRow
758 icon={
759 reviews.some(({ verdict }) => verdict === "request_changes") ? (
760 <CircleSlash size={16} className="text-danger" />
761 ) : reviews.length > 0 ? (
762 <CircleCheck size={16} className="text-accent" />
763 ) : (
764 <MessageSquare size={16} className="text-faint" />
765 )
766 }
767 title={
768 reviews.length === 0
769 ? "No reviews yet"
770 : reviews
771 .map(
772 ({ reviewer, verdict }) =>
773 `${verdict === "approve" ? "Approved by" : "Changes requested by"} ${reviewer}`,
774 )
775 .join(" · ")
776 }
777 >
778 {reviewPending && "A g1t agent is reviewing it now. "}
779 {requiredApprovals > 0 &&
780 `This repository requires ${requiredApprovals} approving ${
781 requiredApprovals === 1 ? "review" : "reviews"
782 } before merging.`}
783 </StatusRow>
784 {landing ? (
785 <StatusRow
786 icon={<Loader size={16} className="animate-spin text-accent" />}
787 title="Merging"
788 >
789 {defaultBranch} has moved, so g1t is bringing this up to date first. It
790 lands as soon as that is done. Watch it in the Session tab.
791 </StatusRow>
792 ) : behind ? (
793 <StatusRow
794 icon={<GitPullRequestArrow size={16} className="text-info" />}
795 title={`${defaultBranch} has moved since this was made`}
796 >
797 {requireUpToDate
798 ? "This repository requires pull requests to be up to date, so it has to catch up before it can merge."
799 : "That does not stop it merging: it is brought up to date as part of the merge."}
800 {canUpdate && agentsEnabled && (
801 <Form
802 method="post"
803 className="mt-2"
804 onSubmit={() => setSubmitted("update")}
805 >
806 <input type="hidden" name="action" value="update" />
807 <Button variant="quiet" type="submit" disabled={catchingUp}>
808 {catchingUp ? "Catching up…" : `Catch up with ${defaultBranch} now`}
809 </Button>
810 </Form>
811 )}
812 {actionData?.action === "update" && (
813 <ErrorText>{actionData.error}</ErrorText>
814 )}
815 </StatusRow>
816 ) : (
817 pull.headCommit && (
818 <StatusRow
819 icon={<CircleCheck size={16} className="text-accent" />}
820 title={`Up to date with ${defaultBranch}`}
821 />
822 )
823 )}
824 {stalled && !lifecycle && (
825 <StatusRow icon={<Hand size={16} className="text-warn" />} title="Needs you">
826 {stalled}
827 </StatusRow>
828 )}
829 {canMerge && lifecycle?.stage === "queued" && (
830 <Form method="post" className="flex flex-wrap items-center gap-3 px-4 py-3">
831 <ButtonLink to={`${base}/queue`}>
832 <Layers size={15} />
833 See the queue
834 </ButtonLink>
835 <Button variant="quiet" type="submit" name="action" value="unqueue">
836 Remove from the queue
837 </Button>
838 </Form>
839 )}
840 {canMerge && !landing && lifecycle?.stage !== "queued" && (
841 <Form method="post" className="space-y-3 px-4 py-3">
842 {issue?.state === "open" && (
843 <label className="flex items-start gap-2 text-xs text-muted">
844 <input
845 type="checkbox"
846 name="keepIssueOpen"
847 className="mt-0.5 accent-accent"
848 />
849 <span>Keep #{issue.number} open. This is only part of the work.</span>
850 </label>
851 )}
852 {unchecked && canIgnoreChecks && (
853 <label className="flex items-start gap-2 text-xs text-muted">
854 <input
855 type="checkbox"
856 name="ignoreChecks"
857 className="mt-0.5 accent-accent"
858 />
859 <span>
860 Merge although the checks{" "}
861 {checking ? "have not finished" : "did not pass"}.
862 </span>
863 </label>
864 )}
865 <div className="flex flex-wrap items-center gap-3">
866 <Button
867 variant="accent"
868 type="submit"
869 name="action"
870 value="merge"
871 disabled={behind && requireUpToDate}
872 >
873 {mergeQueue ? <Layers size={15} /> : <GitMerge size={15} />}
874 {mergeQueue ? "Add to the merge queue" : `Merge into ${defaultBranch}`}
875 </Button>
876 <span className="text-xs text-muted">
877 {mergeQueue
878 ? `Tested together with everything ahead of it, then lands on ${defaultBranch}.`
879 : issue?.state === "open"
880 ? `Closes issue #${issue.number}, and any other pull requests still open for it.`
881 : `Lands its commits on ${defaultBranch}.`}
882 </span>
883 </div>
884 {actionData?.action === "merge" && (
885 <ErrorText>{actionData.error}</ErrorText>
886 )}
887 </Form>
888 )}
889 </StatusBox>
890 )}
891
892 <CommentForm
893 author={viewer?.username ?? null}
894 resetKey={comments.length}
895 review={review}
896 />
897 {canManage && active && (
898 <Form method="post" className="flex justify-end">
899 <Button variant="quiet" type="submit" name="action" value="close">
900 Close pull request
901 </Button>
902 </Form>
903 )}
904 {actionData &&
905 !["merge", "comment", "recheck", "update", "agent-review", "reviewers", "assign"].includes(
906 String(actionData.action),
907 ) && <ErrorText>{actionData.error}</ErrorText>}
908 </div>
909 )}
910 {actionData?.action === "comment" && (
911 <div className="mt-2">
912 <ErrorText>{actionData.error}</ErrorText>
913 </div>
914 )}
915 </div>
916 </div>
917
918 <aside className={tab === "changes" ? "hidden" : "space-y-6"}>
919 <ChecksPanel
920 run={checks}
921 commands={issue?.checks ?? []}
922 canRerun={canManage && pull.status === "open"}
923 />
924 {actionData?.action === "recheck" && <ErrorText>{actionData.error}</ErrorText>}
925 <WorkflowStatuses statuses={statuses} />
926 {affects.length > 0 && (
927 <section>
928 <h3 className="flex items-center gap-1.5 text-sm font-medium">
929 <Network size={14} className="text-faint" />
930 Affects
931 </h3>
932 <p className="mt-1 text-xs text-muted">Projects that use this one, and so may feel this change:</p>
933 <ul className="mt-2 space-y-1 text-sm">
934 {affects.map((project) => (
935 <li key={project.slug} className="flex items-center justify-between gap-2">
936 <Link to={`/${params.owner}/${project.slug}`} className="hover:underline">
937 {project.name}
938 </Link>
939 {project.as && <code className="font-mono text-xs text-faint">{project.as}</code>}
940 </li>
941 ))}
942 </ul>
943 {preview?.branch && canMerge && (
944 <Form method="post" className="mt-3">
945 <input type="hidden" name="action" value="stack" />
946 <input type="hidden" name="branch" value={preview.branch} />
947 <button
948 type="submit"
949 className="w-full rounded-md border border-line px-3 py-1.5 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
950 >
951 Preview them against this change
952 </button>
953 </Form>
954 )}
955 {actionData?.action === "stack" &&
956 ("notice" in actionData ? (
957 <p className="mt-2 text-xs text-accent">{String(actionData.notice)}</p>
958 ) : (
959 <ErrorText>{actionData.error}</ErrorText>
960 ))}
961 </section>
962 )}
963
964 <section>
965 <h3 className="text-sm font-medium">Reviewers</h3>
966 <ul className="mt-2 space-y-1.5 text-sm">
967 {reviewerNames.map((name) => {
968 const verdict = reviews.find(({ reviewer }) => reviewer === name)?.verdict;
969 const pending = name === "g1t-agent" && reviewPending;
970 return (
971 <li key={name} className="flex items-center gap-2 px-1">
972 {name === "g1t-agent" ? (
973 <Sparkles size={16} className="shrink-0 text-accent" />
974 ) : (
975 <Avatar name={name} size={20} />
976 )}
977 <span className="grow truncate font-mono text-xs">{name}</span>
978 {pending ? (
979 <span className="flex items-center gap-1.5 text-xs text-muted">
980 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
981 Reviewing
982 </span>
983 ) : verdict === "approve" ? (
984 <span className="flex items-center gap-1 text-xs text-accent">
985 <CircleCheck size={13} /> Approved
986 </span>
987 ) : verdict === "request_changes" ? (
988 <span className="flex items-center gap-1 text-xs text-danger">
989 <CircleSlash size={13} /> Changes requested
990 </span>
991 ) : (
992 <span className="text-xs text-faint">Review requested</span>
993 )}
994 </li>
995 );
996 })}
997 {reviewerNames.length === 0 && (
998 <li className="px-1 text-xs text-faint">No reviews requested yet.</li>
999 )}
1000 </ul>
1001 {pull.status === "open" && canManage && (
1002 <div className="mt-3 space-y-2">
1003 {agentsEnabled && !reviewPending && (
1004 <Form method="post">
1005 <input type="hidden" name="action" value="agent-review" />
1006 {pull.reviewers.map((name) => (
1007 <input key={name} type="hidden" name="reviewer" value={name} />
1008 ))}
1009 <div className="*:w-full">
1010 <Button variant="quiet" type="submit">
1011 <Sparkles size={14} className="text-accent" />
1012 Request review from g1t agent
1013 </Button>
1014 </div>
1015 </Form>
1016 )}
1017 <details>
1018 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
1019 Request review from people
1020 </summary>
1021 <Form method="post" className="mt-2 space-y-2" key={pull.reviewers.join()}>
1022 <input type="hidden" name="action" value="reviewers" />
1023 {pull.reviewers.includes("g1t-agent") && (
1024 <input type="hidden" name="reviewer" value="g1t-agent" />
1025 )}
1026 <PeoplePicker
1027 name="reviewer"
1028 members={members.filter((name) => name !== pull.author.username)}
1029 chosen={pull.reviewers.filter((name) => name !== "g1t-agent")}
1030 />
1031 <Button variant="quiet" type="submit">
1032 Save reviewers
1033 </Button>
1034 </Form>
1035 </details>
1036 </div>
1037 )}
1038 {["agent-review", "reviewers"].includes(String(actionData?.action)) && (
1039 <ErrorText>{actionData?.error}</ErrorText>
1040 )}
1041 </section>
1042
1043 <section>
1044 <h3 className="text-sm font-medium">Assignees</h3>
1045 <ul className="mt-2 space-y-1.5 text-sm">
1046 {pull.assignees.map((name) => (
1047 <li key={name} className="flex items-center gap-2 px-1">
1048 <Avatar name={name} size={20} />
1049 <span className="grow truncate font-mono text-xs">{name}</span>
1050 </li>
1051 ))}
1052 {pull.assignees.length === 0 && (
1053 <li className="px-1 text-xs text-faint">No one yet.</li>
1054 )}
1055 </ul>
1056 {viewer && canManage && active && (
1057 <div className="mt-3 space-y-2">
1058 {!pull.assignees.includes(viewer.username) && (
1059 <Form method="post">
1060 <input type="hidden" name="action" value="assign" />
1061 {pull.assignees.map((name) => (
1062 <input key={name} type="hidden" name="assignee" value={name} />
1063 ))}
1064 <input type="hidden" name="assignee" value={viewer.username} />
1065 <div className="*:w-full">
1066 <Button variant="quiet" type="submit">
1067 Assign yourself
1068 </Button>
1069 </div>
1070 </Form>
1071 )}
1072 <details>
1073 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
1074 Assign people
1075 </summary>
1076 <Form method="post" className="mt-2 space-y-2" key={pull.assignees.join()}>
1077 <input type="hidden" name="action" value="assign" />
1078 <PeoplePicker name="assignee" members={members} chosen={pull.assignees} />
1079 <Button variant="quiet" type="submit">
1080 Save assignees
1081 </Button>
1082 </Form>
1083 </details>
1084 </div>
1085 )}
1086 {actionData?.action === "assign" && <ErrorText>{actionData.error}</ErrorText>}
1087 </section>
1088
1089 <section>
1090 <h3 className="text-sm font-medium">Working copy</h3>
1091 {pull.branch ? (
1092 <>
1093 <p className="mt-1 text-xs text-muted">
1094 <GitBranch size={13} className="mr-1 inline align-[-2px]" />
1095 Branch <span className="font-mono text-fg">{pull.branch}</span> of this
1096 repository. Pushes to it show up here.
1097 </p>
1098 <div className="mt-2">
1099 <CopyLine text={`git clone -b ${pull.branch} ${remote}`} />
1100 </div>
1101 </>
1102 ) : (
1103 <>
1104 <p className="mt-1 text-xs text-muted">
1105 This fork belongs to the pull request. Pushes to it show up here.
1106 </p>
1107 <div className="mt-2">
1108 <CopyLine text={`git clone ${remote}`} />
1109 </div>
1110 </>
1111 )}
1112 <p className="mt-3 flex items-center gap-1.5 font-mono text-xs text-faint">
1113 <GitCommitHorizontal size={13} />
1114 {pull.headCommit?.slice(0, 12) ?? "no commits pushed yet"}
1115 </p>
1116 </section>
1117 </aside>
1118 </div>
1119 );
1120}
1121
1122/** Where the pull request's change is live, as GitHub shows a deployment. */
1123function DeploymentCard({
1124 preview,
1125 build,
1126 stacked,
1127 base,
1128}: {
1129 preview: LiveApp | null;
1130 build: Deployment | null;
1131 stacked: { name: string; slug: string; url: string }[];
1132 base: string;
1133}) {
1134 const building = build?.status === "queued" || build?.status === "building";
1135 const failed = build?.status === "failed";
1136 return (
1137 <section className="ml-12 overflow-hidden rounded-xl border border-line bg-surface">
1138 <div className="flex flex-wrap items-center gap-3 px-4 py-3">
1139 <span
1140 className={`flex size-8 shrink-0 items-center justify-center rounded-full ring-1 ${
1141 failed ? "text-danger ring-danger/40" : building ? "text-warn ring-warn/40" : "text-accent ring-accent/40"
1142 }`}
1143 >
1144 {building ? <Loader size={15} className="animate-spin" /> : <Rocket size={15} />}
1145 </span>
1146 <div className="min-w-0 grow">
1147 <p className="text-sm font-medium">
1148 {building
1149 ? preview
1150 ? "Deploying the latest push. The last preview is still live."
1151 : "Deploying a preview"
1152 : failed
1153 ? "The preview failed to deploy"
1154 : "This branch is live"}
1155 </p>
1156 {preview ? (
1157 <a href={preview.url} className="mt-0.5 block truncate font-mono text-xs text-muted hover:text-accent">
1158 {preview.url.replace(/^https?:[/][/]/, "")}
1159 </a>
1160 ) : (
1161 build?.error && <p className="mt-0.5 truncate text-xs text-muted">{build.error}</p>
1162 )}
1163 </div>
1164 <div className="flex shrink-0 items-center gap-2">
1165 {build && (
1166 <Link to={`${base}/deployments/${build.id}`} className="text-xs text-muted hover:text-fg">
1167 {failed ? "See why" : "Build log"}
1168 </Link>
1169 )}
1170 {preview && (
1171 <a
1172 href={preview.url}
1173 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"
1174 >
1175 Visit preview
1176 <ArrowUpRight size={13} />
1177 </a>
1178 )}
1179 </div>
1180 </div>
1181 {preview && (
1182 <p className="border-t border-line px-4 py-2 text-xs text-faint">
1183 <span className="font-mono">{preview.commit.slice(0, 7)}</span> · deployed <TimeAgo at={preview.deployedAt} />
1184 {preview.branch && ` · from ${preview.branch}`}
1185 </p>
1186 )}
1187 {stacked.length > 0 && (
1188 <div className="border-t border-line px-4 py-2.5">
1189 <p className="text-xs text-muted">Built against this change:</p>
1190 <ul className="mt-1.5 space-y-1">
1191 {stacked.map((entry) => (
1192 <li key={entry.slug} className="flex items-center gap-2 text-xs">
1193 <span className="font-medium">{entry.name}</span>
1194 <a href={entry.url} className="truncate font-mono text-muted hover:text-accent">
1195 {entry.url.replace(/^https?:[/][/]/, "")}
1196 </a>
1197 </li>
1198 ))}
1199 </ul>
1200 </div>
1201 )}
1202 </section>
1203 );
1204}