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