g1t/apps/web/app/routes/repo/pull.tsx

954 lines38,858 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Acceptance checks in sandboxes, line comments and review verdicts1import { env } from "cloudflare:workers";
Issues and pull requests replace intents and attempts2import {
3 Bot,
4 ChevronRight,
5 FileDiff,
Pull requests from branches6 GitBranch,
Issues and pull requests replace intents and attempts7 GitCommitHorizontal,
Acceptance checks in sandboxes, line comments and review verdicts8 CircleCheck,
9 CircleSlash,
Issues and pull requests replace intents and attempts10 GitMerge,
Agents as a team: lifecycle, merge queue, billing and a new shell11 Layers,
12 GitPullRequestArrow,
13 Hand,
14 Loader,
15 Radar,
16 Sparkles,
Issues and pull requests replace intents and attempts17 MessageSquare,
18 MessagesSquare,
19 StickyNote,
20 User,
21 Wrench,
22} from "lucide-react";
Agents as a team: lifecycle, merge queue, billing and a new shell23import { useEffect, useState } from "react";
Issues and pull requests replace intents and attempts24import { Form, Link, redirect, useRevalidator } from "react-router";
25
Pull requests from branches26import { type Comparison, type SessionEntry, pullComparison } from "@g1t/contracts";
Issues and pull requests replace intents and attempts27
28import type { Route } from "./+types/pull";
29import { DiffView } from "../../components/diff-view";
Agents as a team: lifecycle, merge queue, billing and a new shell30import { LifecyclePanel } from "../../components/lifecycle";
Issues and pull requests replace intents and attempts31import { Markdown } from "../../components/markdown";
32import {
33 Avatar,
34 Button,
Agents as a team: lifecycle, merge queue, billing and a new shell35 ButtonLink,
Issues and pull requests replace intents and attempts36 CopyLine,
37 EmptyState,
38 ErrorText,
39 Textarea,
40 TimeAgo,
41} from "../../components/ui";
Acceptance checks in sandboxes, line comments and review verdicts42import { ChecksPanel } from "../../components/checks";
Agents as a team: lifecycle, merge queue, billing and a new shell43import {
44 CommentForm,
45 CommentList,
46 IssueIcon,
47 PeoplePicker,
48 PullState,
49 TimelineItem,
50 verdicts,
51} from "../../components/work";
52import { identity, repos, work } from "../../lib/services.server";
Issues and pull requests replace intents and attempts53import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
54
55const REFRESH_MS = 4000;
56const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
57const TABS = ["conversation", "session", "changes"] as const;
58type Tab = (typeof TABS)[number];
59
60export function meta({ loaderData, params }: Route.MetaArgs) {
61 const title = loaderData
62 ? `${loaderData.pull.title} · Pull request #${loaderData.pull.number} · `
63 : "";
64 return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
65}
66
67export async function loader({ params, context, request }: Route.LoaderArgs) {
68 const viewer = getViewer(context);
69 const path = { namespace: params.owner, name: params.repo };
70 const number = Number(params.number);
71 const asked = new URL(request.url).searchParams.get("tab");
72 const tab: Tab = TABS.find((name) => name === asked) ?? "conversation";
73
Agents as a team: lifecycle, merge queue, billing and a new shell74 const member = (viewer?.workspaces ?? []).some(
75 (membership) => membership.slug === params.owner,
76 );
77 // At once: none of these depends on another.
78 const [found, repo, settings, agentsEnabled, members] = await Promise.all([
Issues and pull requests replace intents and attempts79 work.getPull(path, number, viewer),
80 repos.get(path, viewer),
Agents as a team: lifecycle, merge queue, billing and a new shell81 work.getSettings(path, viewer),
82 env.RUNNER.enabled(viewer),
83 // A member picks reviewers and assignees from the workspace's people.
84 member ? identity.listMembers(params.owner, viewer) : null,
Issues and pull requests replace intents and attempts85 ]);
86 if (!found.ok) {
87 // Issues and pull requests share numbers; this one may be an issue.
88 const issue = await work.getIssue(path, number, viewer);
89 if (issue.ok) throw redirect(`/${params.owner}/${params.repo}/issues/${number}`);
90 throw new Response("Pull request not found.", { status: 404 });
91 }
92 const { pull } = found.value;
Pull requests from branches93 const range = pullComparison(pull);
Issues and pull requests replace intents and attempts94 const [session, comparison] = await Promise.all([
95 tab === "session" ? work.readSession(path, number, viewer) : null,
Pull requests from branches96 tab === "changes"
97 ? repos.compare(range.repoId, viewer, range.base, range.head)
98 : null,
Issues and pull requests replace intents and attempts99 ]);
100 return {
101 ...found.value,
102 tab,
103 session: session?.ok ? session.value : [],
104 // An empty comparison if it could not be made.
105 comparison: comparison && (comparison.ok ? comparison.value : EMPTY_COMPARISON),
106 viewer,
107 // Members of the repository's workspace can merge.
108 canMerge: member,
109 canManage: member || viewer?.id === pull.author.id,
Agents as a team: lifecycle, merge queue, billing and a new shell110 // A catch-up is pushed as the viewer: a fork takes pushes only from
111 // whoever opened it, a branch from any member.
112 canUpdate: pull.fork ? viewer?.id === pull.author.id : member,
113 agentsEnabled,
114 members: members?.ok ? members.value.map((person) => person.username) : [],
115 requireUpToDate: settings.ok && settings.value.requireUpToDate,
116 mergeQueue: settings.ok && settings.value.mergeQueue,
117 requiredApprovals: settings.ok ? settings.value.requiredApprovals : 0,
118 canIgnoreChecks: !settings.ok || settings.value.allowIgnoringChecks,
Issues and pull requests replace intents and attempts119 defaultBranch: repo.ok ? repo.value.defaultBranch : "main",
120 };
121}
122
123export async function action({ request, params, context }: Route.ActionArgs) {
124 assertSameOrigin(request);
125 const user = requireUser(context, request);
126 const form = await request.formData();
127 const path = { namespace: params.owner, name: params.repo };
128 const number = Number(params.number);
129 const action = form.get("action");
Acceptance checks in sandboxes, line comments and review verdicts130 const verdict = form.get("verdict");
131 const line = Number(form.get("line"));
Agents as a team: lifecycle, merge queue, billing and a new shell132 /** Names ticked in a people picker, plus those typed beside it. */
133 const picked = (field: string) => [
134 ...form.getAll(field).map(String),
135 ...String(form.get("others") ?? "").split(/[\s,]+/),
136 ];
137 // Asking a g1t agent for its review records the request, then starts it.
138 if (action === "agent-review") {
139 const asked = await work.updatePull(user, path, number, {
140 reviewers: [...form.getAll("reviewer").map(String), "g1t-agent"],
141 });
142 if (!asked.ok) return { error: asked.error.message, action };
143 }
Issues and pull requests replace intents and attempts144 const result =
145 action === "merge"
Acceptance checks in sandboxes, line comments and review verdicts146 ? await work.mergePull(user, path, number, {
147 keepIssueOpen: form.get("keepIssueOpen") === "on",
148 ignoreChecks: form.get("ignoreChecks") === "on",
149 })
Agents as a team: lifecycle, merge queue, billing and a new shell150 : action === "unqueue"
151 ? await work.removeFromQueue(user, path, number)
Issues and pull requests replace intents and attempts152 : action === "close"
153 ? await work.closePull(user, path, number)
Acceptance checks in sandboxes, line comments and review verdicts154 : action === "recheck"
155 ? await env.RUNNER.recheck(user, path, number)
Agents as a team: lifecycle, merge queue, billing and a new shell156 : action === "update"
157 ? await env.RUNNER.update(user, path, number)
158 : action === "agent-review"
159 ? await env.RUNNER.review(user, path, number)
160 : action === "reviewers"
161 ? await work.updatePull(user, path, number, { reviewers: picked("reviewer") })
162 : action === "assign"
163 ? await work.updatePull(user, path, number, { assignees: picked("assignee") })
Acceptance checks in sandboxes, line comments and review verdicts164 : action === "comment"
165 ? await work.addComment(user, path, number, {
166 body: String(form.get("body") ?? ""),
167 path: String(form.get("path") ?? "") || undefined,
168 line: line > 0 ? line : undefined,
169 verdict:
170 verdict === "approve" || verdict === "request_changes" ? verdict : undefined,
171 })
172 : await work.readyPull(user, path, number, String(form.get("summary") ?? ""));
Issues and pull requests replace intents and attempts173 return result.ok ? null : { error: result.error.message, action };
174}
175
176function TabLink({
177 to,
178 active,
179 children,
180}: {
181 to: string;
182 active: boolean;
183 children: React.ReactNode;
184}) {
185 return (
186 <Link
187 to={to}
188 preventScrollReset
189 className={
190 "-mb-px flex items-center gap-2 border-b-2 px-1 pb-2.5 text-sm transition-colors " +
191 (active
192 ? "border-accent font-medium text-fg"
193 : "border-transparent text-muted hover:text-fg")
194 }
195 >
196 {children}
197 </Link>
198 );
199}
200
201/** One step of the session, on the timeline's rail. */
202function Entry({ entry, agent }: { entry: SessionEntry; agent: string }) {
203 const isTool = entry.kind === "tool_call" || entry.kind === "tool_result";
204 const Icon =
205 entry.kind === "prompt"
206 ? User
207 : entry.kind === "note"
208 ? StickyNote
209 : isTool
210 ? Wrench
211 : Bot;
212 return (
213 <li className="relative pl-10">
214 <span
215 className={`absolute top-0.5 left-0 flex size-7 items-center justify-center rounded-full border bg-bg ${
216 entry.kind === "prompt"
217 ? "border-accent/50 text-accent"
218 : "border-line text-faint"
219 }`}
220 >
221 <Icon size={14} />
222 </span>
223 {isTool ? (
224 <details className="group rounded-lg border border-line bg-surface">
225 <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-1.5 text-sm">
226 <ChevronRight
227 size={14}
228 className="text-faint transition-transform group-open:rotate-90"
229 />
230 <span className="font-mono text-xs text-accent">
231 {entry.tool ?? "tool"}
232 </span>
233 <span className="truncate font-mono text-xs text-muted">
234 {entry.kind === "tool_result" ? "→ " : ""}
235 {entry.text.split("\n")[0]}
236 </span>
237 </summary>
238 <pre className="overflow-x-auto border-t border-line p-3 font-mono text-xs whitespace-pre-wrap text-muted">
239 {entry.text}
240 </pre>
241 </details>
242 ) : (
243 <div>
244 <p className="text-xs font-medium text-faint">
245 {entry.kind === "prompt"
246 ? "Prompt"
247 : entry.kind === "note"
248 ? "Note"
249 : agent}
250 </p>
251 {entry.kind === "message" ? (
252 <div className="mt-1">
253 <Markdown source={entry.text} />
254 </div>
Agents as a team: lifecycle, merge queue, billing and a new shell255 ) : entry.kind === "prompt" ? (
256 <div className="mt-1.5 max-h-[32rem] overflow-y-auto rounded-xl border border-line bg-surface p-4">
257 <Markdown source={entry.text} />
258 </div>
Issues and pull requests replace intents and attempts259 ) : (
Agents as a team: lifecycle, merge queue, billing and a new shell260 <p className="mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap">
Issues and pull requests replace intents and attempts261 {entry.text}
262 </p>
263 )}
264 </div>
265 )}
266 {entry.commit && (
267 <p className="mt-1.5 flex items-center gap-1 font-mono text-xs text-faint">
268 <GitCommitHorizontal size={12} />
269 {entry.commit.slice(0, 7)}
270 </p>
271 )}
272 </li>
273 );
274}
275
Agents as a team: lifecycle, merge queue, billing and a new shell276/** The box at the foot of the conversation saying what stands before a merge. */
277function StatusBox({ children }: { children: React.ReactNode }) {
278 return (
279 <div className="flex gap-3">
280 <span className="hidden w-8 shrink-0 sm:block" />
281 <section className="min-w-0 grow divide-y divide-line overflow-hidden rounded-xl border border-line-strong bg-surface">
282 {children}
283 </section>
284 </div>
285 );
286}
287
288function StatusRow({
289 icon,
290 title,
291 children,
292}: {
293 icon: React.ReactNode;
294 title: string;
295 children?: React.ReactNode;
296}) {
297 return (
298 <div className="flex gap-3 px-4 py-3 text-sm">
299 <span className="mt-0.5 shrink-0">{icon}</span>
300 <div className="min-w-0">
301 <p className="font-medium">{title}</p>
302 {children && <div className="mt-0.5 text-muted">{children}</div>}
303 </div>
304 </div>
305 );
306}
307
Issues and pull requests replace intents and attempts308export default function PullPage({ loaderData, actionData, params }: Route.ComponentProps) {
309 const {
310 pull,
311 issue,
312 comments,
Acceptance checks in sandboxes, line comments and review verdicts313 checks,
Agents as a team: lifecycle, merge queue, billing and a new shell314 overlaps,
315 behind,
316 reviewPending,
317 lifecycle,
318 landing,
319 stalled,
320 requireUpToDate,
321 mergeQueue,
322 requiredApprovals,
323 canIgnoreChecks,
324 canUpdate,
325 agentsEnabled,
326 members,
Issues and pull requests replace intents and attempts327 tab,
328 session,
329 comparison,
330 viewer,
331 canMerge,
332 canManage,
333 defaultBranch,
334 } = loaderData;
335 const base = `/${params.owner}/${params.repo}`;
336 const here = `${base}/pull/${pull.number}`;
Pull requests from branches337 const remote = pull.fork
338 ? `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`
339 : `https://g1t.sh/${params.owner}/${params.repo}.git`;
Issues and pull requests replace intents and attempts340 const active = pull.status === "draft" || pull.status === "open";
341
Acceptance checks in sandboxes, line comments and review verdicts342 // Follow an agent at work, or checks in progress, without a manual reload.
Issues and pull requests replace intents and attempts343 const revalidator = useRevalidator();
344 const working = pull.status === "draft";
Acceptance checks in sandboxes, line comments and review verdicts345 const checking = checks?.status === "queued" || checks?.status === "running";
346 const reviews = verdicts(comments);
347 // What stands between this pull request and a merge, if anything.
348 const unchecked = checks && checks.status !== "passed";
Agents as a team: lifecycle, merge queue, billing and a new shell349 // Pull requests for other issues changing the same files will conflict;
350 // ones for the same issue are alternatives, and expected to.
351 const collisions = overlaps.filter((other) => other.issue == null || other.issue !== pull.issue);
352 const review = {
353 changesUrl: here + "?tab=changes",
354 // Nobody reviews their own pull request.
355 canJudge: active && viewer != null && viewer.id !== pull.author.id,
356 };
357 // Everyone whose review was asked for, then anyone who reviewed unasked.
358 const reviewerNames = [
359 ...new Set([...pull.reviewers, ...reviews.map(({ reviewer }) => reviewer)]),
360 ];
361 const [submitted, setSubmitted] = useState<string | null>(null);
362 const catchingUp = submitted === "update" && behind;
363 // g1t is taking a step of its own accord, so the page will change.
364 const moving =
365 lifecycle != null && lifecycle.stage !== "ready" && lifecycle.stage !== "needs_you";
Issues and pull requests replace intents and attempts366 useEffect(() => {
Agents as a team: lifecycle, merge queue, billing and a new shell367 if (!working && !checking && !reviewPending && !catchingUp && !moving && !landing) return;
Issues and pull requests replace intents and attempts368 const timer = setInterval(() => {
369 if (document.visibilityState === "visible") revalidator.revalidate();
370 }, REFRESH_MS);
371 return () => clearInterval(timer);
Agents as a team: lifecycle, merge queue, billing and a new shell372 }, [working, checking, reviewPending, catchingUp, moving, landing, revalidator]);
Issues and pull requests replace intents and attempts373
374 return (
Agents as a team: lifecycle, merge queue, billing and a new shell375 // The changes get the whole width; people and settings are a tab away.
376 <div className={`grid gap-8 ${tab === "changes" ? "" : "lg:grid-cols-[1fr_19rem]"}`}>
Issues and pull requests replace intents and attempts377 <div className="min-w-0">
378 <h2 className="text-2xl font-semibold tracking-tight text-balance">
379 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
380 </h2>
381 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
382 <PullState status={pull.status} />
383 <span className="flex items-center gap-2">
Agents as a team: lifecycle, merge queue, billing and a new shell384 <Avatar
385 name={(pull.status === "merged" && pull.mergedBy) || pull.author.username}
386 size={18}
387 />
Issues and pull requests replace intents and attempts388 <span>
Agents as a team: lifecycle, merge queue, billing and a new shell389 <span className="font-medium text-fg">
390 {(pull.status === "merged" && pull.mergedBy) || pull.author.username}
391 </span>{" "}
Pull requests from branches392 {pull.status === "merged" ? "merged" : "wants to merge"}
393 {pull.branch && (
394 <>
395 {" "}
396 <span className="font-mono text-fg">{pull.branch}</span>
397 </>
398 )}{" "}
399 into <span className="font-mono text-fg">{defaultBranch}</span>
Issues and pull requests replace intents and attempts400 </span>
401 </span>
Pull requests from branches402 {/* A pull request from a branch was made by its author, not an agent. */}
403 {!pull.branch && (
404 <span className="flex items-center gap-1.5 font-mono text-xs">
405 <Bot size={14} />
406 {pull.agent}
407 {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
408 </span>
409 )}
Issues and pull requests replace intents and attempts410 </div>
411
Acceptance checks in sandboxes, line comments and review verdicts412 {reviews.length > 0 && (
413 <p className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
414 {reviews.map(({ reviewer, verdict }) => (
415 <span
416 key={reviewer}
417 className={`flex items-center gap-1.5 ${
418 verdict === "approve" ? "text-accent" : "text-danger"
419 }`}
420 >
421 {verdict === "approve" ? <CircleCheck size={15} /> : <CircleSlash size={15} />}
422 {verdict === "approve" ? "Approved by" : "Changes requested by"}{" "}
423 <span className="font-medium">{reviewer}</span>
424 </span>
425 ))}
426 </p>
427 )}
428
Agents as a team: lifecycle, merge queue, billing and a new shell429 {lifecycle && <LifecyclePanel lifecycle={lifecycle} />}
430
Issues and pull requests replace intents and attempts431 {issue && (
432 <Link
433 to={`${base}/issues/${issue.number}`}
434 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"
435 >
436 <IssueIcon issue={issue} />
437 <span className="text-muted">
438 {pull.status === "merged" && issue.resolvedBy === pull.number
439 ? "Resolved"
440 : "For issue"}
441 </span>
442 <span className="truncate font-medium">{issue.title}</span>
443 <span className="text-faint">#{issue.number}</span>
444 {issue.pullCount > 1 && (
445 <span className="ml-auto shrink-0 text-xs text-faint">
446 1 of {issue.pullCount} pull requests
447 </span>
448 )}
449 </Link>
450 )}
451
452 {pull.status === "merged" && (
453 <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">
454 <GitMerge size={16} className="shrink-0 text-merged" />
455 <span>
456 Merged into <span className="font-mono">{defaultBranch}</span> by{" "}
457 <span className="font-medium">{pull.mergedBy}</span>{" "}
Agents as a team: lifecycle, merge queue, billing and a new shell458 {/* Not an account: the repository's settings said to merge it. */}
459 {pull.mergedBy === "g1t" && "automatically, once it was ready, "}
Issues and pull requests replace intents and attempts460 {pull.mergedAt && <TimeAgo at={pull.mergedAt} />}
461 {pull.headCommit && (
462 <span className="font-mono text-muted"> · {pull.headCommit.slice(0, 7)}</span>
463 )}
464 </span>
465 </p>
466 )}
467 {pull.supersededBy != null && (
468 <p className="mt-4 rounded-xl border border-line bg-surface px-4 py-2.5 text-sm text-muted">
469 Closed because{" "}
470 <Link
471 to={`${base}/pull/${pull.supersededBy}`}
472 className="font-medium text-fg hover:underline"
473 >
474 #{pull.supersededBy}
475 </Link>{" "}
476 was merged for this issue instead.
477 </p>
478 )}
479
Agents as a team: lifecycle, merge queue, billing and a new shell480 {collisions.length > 0 && active && (
481 <div className="mt-4 rounded-xl border border-line bg-surface px-4 py-3 text-sm">
482 <p className="flex items-center gap-2.5 font-medium">
483 <Radar size={16} className="shrink-0 text-info" />
484 Other work is changing the same files
485 </p>
486 <ul className="mt-2 space-y-1.5">
487 {collisions.map((other) => (
488 <li key={other.number} className="flex flex-wrap items-baseline gap-x-2 text-muted">
489 <Link
490 to={`${base}/pull/${other.number}`}
491 className="font-medium text-fg hover:underline"
492 >
493 {other.title} <span className="font-normal text-faint">#{other.number}</span>
494 </Link>
495 <span className="font-mono text-xs">{other.paths.join(", ")}</span>
496 </li>
497 ))}
498 </ul>
499 <p className="mt-2 text-xs text-faint">
500 Whichever merges second will have to catch up, and may conflict.
501 </p>
502 </div>
503 )}
504
Issues and pull requests replace intents and attempts505 <nav className="mt-8 flex gap-6 border-b border-line">
506 <TabLink to={here} active={tab === "conversation"}>
507 <MessageSquare size={15} />
508 Conversation
509 </TabLink>
510 <TabLink to={here + "?tab=session"} active={tab === "session"}>
511 <MessagesSquare size={15} />
512 Session
513 </TabLink>
514 <TabLink to={here + "?tab=changes"} active={tab === "changes"}>
515 <FileDiff size={15} />
516 Changes
517 </TabLink>
518 </nav>
519 <div className="mt-5">
520 {comparison ? (
Acceptance checks in sandboxes, line comments and review verdicts521 <DiffView
522 comparison={comparison}
523 review={{
524 comments: comments.filter((comment) => comment.path),
525 canComment: Boolean(viewer),
526 }}
527 />
Issues and pull requests replace intents and attempts528 ) : tab === "session" ? (
529 session.length === 0 ? (
530 <EmptyState title="Nothing recorded yet">
531 The agent's prompts, reasoning and tool calls appear here as it
532 works.
533 </EmptyState>
534 ) : (
535 <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line">
536 {session.map((entry) => (
537 <Entry key={entry.seq} entry={entry} agent={pull.agent} />
538 ))}
539 </ol>
540 )
541 ) : (
542 <div className="space-y-4">
Agents as a team: lifecycle, merge queue, billing and a new shell543 <TimelineItem
544 author={pull.author.username}
545 at={pull.createdAt}
546 action={
547 <span>
548 opened this pull request
549 {!pull.branch && (
550 <>
551 {" "}
552 with <span className="font-mono text-xs">{pull.agent}</span>
553 </>
554 )}
555 </span>
556 }
557 >
558 {pull.body ? (
559 <Markdown source={pull.body} repo={{ namespace: params.owner, name: params.repo }} />
560 ) : (
561 <p className="text-sm text-muted">
562 {working
563 ? "No description yet. It is written when the pull request is marked ready for review."
564 : "No description."}
565 </p>
566 )}
567 {canManage && pull.status === "open" && (
568 <details className="mt-3 border-t border-line pt-3 text-sm">
569 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
570 Edit description
571 </summary>
572 <Form method="post" className="mt-3 space-y-2">
573 <Textarea
574 name="summary"
575 rows={6}
576 placeholder="What changed and why"
577 defaultValue={pull.body ?? ""}
578 />
579 <Button type="submit">Save</Button>
580 </Form>
581 </details>
582 )}
583 </TimelineItem>
584
585 <CommentList comments={comments} review={review} base={base} />
586
587 {pull.status === "draft" && (
588 <StatusBox>
589 <StatusRow icon={<Loader size={16} className="text-faint" />} title="This is a draft">
590 It is still being worked on. It can be reviewed and merged once it is
591 marked ready.
592 </StatusRow>
593 {canManage && (
594 <Form method="post" className="space-y-2 px-4 py-3">
595 <Textarea name="summary" rows={3} placeholder="What changed and why" />
596 <Button type="submit">Mark ready for review</Button>
597 </Form>
598 )}
599 </StatusBox>
600 )}
601
602 {pull.status === "open" && (
603 <StatusBox>
604 {checks && (
605 <StatusRow
606 icon={
607 checks.status === "passed" ? (
608 <CircleCheck size={16} className="text-accent" />
609 ) : checking ? (
610 <Loader size={16} className="animate-spin text-faint" />
611 ) : (
612 <CircleSlash size={16} className="text-danger" />
613 )
614 }
615 title={
616 checks.status === "passed"
617 ? "Acceptance checks passed"
618 : checking
619 ? "Acceptance checks are running"
620 : checks.status === "failed"
621 ? "Acceptance checks failed"
622 : "Acceptance checks could not be run"
623 }
624 >
625 {checks.results.length > 0 &&
626 `${checks.results.filter((result) => result.passed).length} of ${checks.results.length} passed, in a clean sandbox.`}
627 </StatusRow>
628 )}
629 <StatusRow
630 icon={
631 reviews.some(({ verdict }) => verdict === "request_changes") ? (
632 <CircleSlash size={16} className="text-danger" />
633 ) : reviews.length > 0 ? (
634 <CircleCheck size={16} className="text-accent" />
635 ) : (
636 <MessageSquare size={16} className="text-faint" />
637 )
638 }
639 title={
640 reviews.length === 0
641 ? "No reviews yet"
642 : reviews
643 .map(
644 ({ reviewer, verdict }) =>
645 `${verdict === "approve" ? "Approved by" : "Changes requested by"} ${reviewer}`,
646 )
647 .join(" · ")
648 }
649 >
650 {reviewPending && "A g1t agent is reviewing it now. "}
651 {requiredApprovals > 0 &&
652 `This repository requires ${requiredApprovals} approving ${
653 requiredApprovals === 1 ? "review" : "reviews"
654 } before merging.`}
655 </StatusRow>
656 {landing ? (
657 <StatusRow
658 icon={<Loader size={16} className="animate-spin text-accent" />}
659 title="Merging"
660 >
661 {defaultBranch} has moved, so g1t is bringing this up to date first. It
662 lands as soon as that is done. Watch it in the Session tab.
663 </StatusRow>
664 ) : behind ? (
665 <StatusRow
666 icon={<GitPullRequestArrow size={16} className="text-info" />}
667 title={`${defaultBranch} has moved since this was made`}
668 >
669 {requireUpToDate
670 ? "This repository requires pull requests to be up to date, so it has to catch up before it can merge."
671 : "That does not stop it merging: it is brought up to date as part of the merge."}
672 {canUpdate && agentsEnabled && (
673 <Form
674 method="post"
675 className="mt-2"
676 onSubmit={() => setSubmitted("update")}
677 >
678 <input type="hidden" name="action" value="update" />
679 <Button variant="quiet" type="submit" disabled={catchingUp}>
680 {catchingUp ? "Catching up…" : `Catch up with ${defaultBranch} now`}
681 </Button>
682 </Form>
683 )}
684 {actionData?.action === "update" && (
685 <ErrorText>{actionData.error}</ErrorText>
686 )}
687 </StatusRow>
688 ) : (
689 pull.headCommit && (
690 <StatusRow
691 icon={<CircleCheck size={16} className="text-accent" />}
692 title={`Up to date with ${defaultBranch}`}
693 />
694 )
695 )}
696 {stalled && !lifecycle && (
697 <StatusRow icon={<Hand size={16} className="text-warn" />} title="Needs you">
698 {stalled}
699 </StatusRow>
700 )}
701 {canMerge && lifecycle?.stage === "queued" && (
702 <Form method="post" className="flex flex-wrap items-center gap-3 px-4 py-3">
703 <ButtonLink to={`${base}/queue`}>
704 <Layers size={15} />
705 See the queue
706 </ButtonLink>
707 <Button variant="quiet" type="submit" name="action" value="unqueue">
708 Remove from the queue
709 </Button>
710 </Form>
711 )}
712 {canMerge && !landing && lifecycle?.stage !== "queued" && (
713 <Form method="post" className="space-y-3 px-4 py-3">
714 {issue?.state === "open" && (
715 <label className="flex items-start gap-2 text-xs text-muted">
716 <input
717 type="checkbox"
718 name="keepIssueOpen"
719 className="mt-0.5 accent-accent"
720 />
721 <span>Keep #{issue.number} open. This is only part of the work.</span>
722 </label>
723 )}
724 {unchecked && canIgnoreChecks && (
725 <label className="flex items-start gap-2 text-xs text-muted">
726 <input
727 type="checkbox"
728 name="ignoreChecks"
729 className="mt-0.5 accent-accent"
730 />
731 <span>
732 Merge although the checks{" "}
733 {checking ? "have not finished" : "did not pass"}.
734 </span>
735 </label>
736 )}
737 <div className="flex flex-wrap items-center gap-3">
738 <Button
739 variant="accent"
740 type="submit"
741 name="action"
742 value="merge"
743 disabled={behind && requireUpToDate}
744 >
745 {mergeQueue ? <Layers size={15} /> : <GitMerge size={15} />}
746 {mergeQueue ? "Add to the merge queue" : `Merge into ${defaultBranch}`}
747 </Button>
748 <span className="text-xs text-muted">
749 {mergeQueue
750 ? `Tested together with everything ahead of it, then lands on ${defaultBranch}.`
751 : issue?.state === "open"
752 ? `Closes issue #${issue.number}, and any other pull requests still open for it.`
753 : `Lands its commits on ${defaultBranch}.`}
754 </span>
755 </div>
756 {actionData?.action === "merge" && (
757 <ErrorText>{actionData.error}</ErrorText>
758 )}
759 </Form>
760 )}
761 </StatusBox>
Issues and pull requests replace intents and attempts762 )}
Agents as a team: lifecycle, merge queue, billing and a new shell763
764 <CommentForm
765 author={viewer?.username ?? null}
766 resetKey={comments.length}
767 review={review}
Acceptance checks in sandboxes, line comments and review verdicts768 />
Agents as a team: lifecycle, merge queue, billing and a new shell769 {canManage && active && (
770 <Form method="post" className="flex justify-end">
771 <Button variant="quiet" type="submit" name="action" value="close">
772 Close pull request
773 </Button>
774 </Form>
775 )}
776 {actionData &&
777 !["merge", "comment", "recheck", "update", "agent-review", "reviewers", "assign"].includes(
778 String(actionData.action),
779 ) && <ErrorText>{actionData.error}</ErrorText>}
Issues and pull requests replace intents and attempts780 </div>
781 )}
Acceptance checks in sandboxes, line comments and review verdicts782 {actionData?.action === "comment" && (
783 <div className="mt-2">
784 <ErrorText>{actionData.error}</ErrorText>
785 </div>
786 )}
Issues and pull requests replace intents and attempts787 </div>
788 </div>
789
Agents as a team: lifecycle, merge queue, billing and a new shell790 <aside className={tab === "changes" ? "hidden" : "space-y-6"}>
Acceptance checks in sandboxes, line comments and review verdicts791 <ChecksPanel
792 run={checks}
793 commands={issue?.checks ?? []}
794 canRerun={canManage && pull.status === "open"}
795 />
796 {actionData?.action === "recheck" && <ErrorText>{actionData.error}</ErrorText>}
797
Agents as a team: lifecycle, merge queue, billing and a new shell798 <section>
799 <h3 className="text-sm font-medium">Reviewers</h3>
800 <ul className="mt-2 space-y-1.5 text-sm">
801 {reviewerNames.map((name) => {
802 const verdict = reviews.find(({ reviewer }) => reviewer === name)?.verdict;
803 const pending = name === "g1t-agent" && reviewPending;
804 return (
805 <li key={name} className="flex items-center gap-2 px-1">
806 {name === "g1t-agent" ? (
807 <Sparkles size={16} className="shrink-0 text-accent" />
808 ) : (
809 <Avatar name={name} size={20} />
810 )}
811 <span className="grow truncate font-mono text-xs">{name}</span>
812 {pending ? (
813 <span className="flex items-center gap-1.5 text-xs text-muted">
814 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
815 Reviewing
816 </span>
817 ) : verdict === "approve" ? (
818 <span className="flex items-center gap-1 text-xs text-accent">
819 <CircleCheck size={13} /> Approved
820 </span>
821 ) : verdict === "request_changes" ? (
822 <span className="flex items-center gap-1 text-xs text-danger">
823 <CircleSlash size={13} /> Changes requested
824 </span>
825 ) : (
826 <span className="text-xs text-faint">Review requested</span>
827 )}
828 </li>
829 );
830 })}
831 {reviewerNames.length === 0 && (
832 <li className="px-1 text-xs text-faint">No reviews requested yet.</li>
833 )}
834 </ul>
835 {pull.status === "open" && canManage && (
836 <div className="mt-3 space-y-2">
837 {agentsEnabled && !reviewPending && (
838 <Form method="post">
839 <input type="hidden" name="action" value="agent-review" />
840 {pull.reviewers.map((name) => (
841 <input key={name} type="hidden" name="reviewer" value={name} />
842 ))}
843 <div className="*:w-full">
844 <Button variant="quiet" type="submit">
845 <Sparkles size={14} className="text-accent" />
846 Request review from g1t agent
847 </Button>
848 </div>
849 </Form>
Issues and pull requests replace intents and attempts850 )}
Agents as a team: lifecycle, merge queue, billing and a new shell851 <details>
852 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
853 Request review from people
854 </summary>
855 <Form method="post" className="mt-2 space-y-2" key={pull.reviewers.join()}>
856 <input type="hidden" name="action" value="reviewers" />
857 {pull.reviewers.includes("g1t-agent") && (
858 <input type="hidden" name="reviewer" value="g1t-agent" />
859 )}
860 <PeoplePicker
861 name="reviewer"
862 members={members.filter((name) => name !== pull.author.username)}
863 chosen={pull.reviewers.filter((name) => name !== "g1t-agent")}
864 />
865 <Button variant="quiet" type="submit">
866 Save reviewers
867 </Button>
868 </Form>
869 </details>
870 </div>
871 )}
872 {["agent-review", "reviewers"].includes(String(actionData?.action)) && (
873 <ErrorText>{actionData?.error}</ErrorText>
874 )}
875 </section>
Issues and pull requests replace intents and attempts876
Agents as a team: lifecycle, merge queue, billing and a new shell877 <section>
878 <h3 className="text-sm font-medium">Assignees</h3>
879 <ul className="mt-2 space-y-1.5 text-sm">
880 {pull.assignees.map((name) => (
881 <li key={name} className="flex items-center gap-2 px-1">
882 <Avatar name={name} size={20} />
883 <span className="grow truncate font-mono text-xs">{name}</span>
884 </li>
885 ))}
886 {pull.assignees.length === 0 && (
887 <li className="px-1 text-xs text-faint">No one yet.</li>
Issues and pull requests replace intents and attempts888 )}
Agents as a team: lifecycle, merge queue, billing and a new shell889 </ul>
890 {viewer && canManage && active && (
891 <div className="mt-3 space-y-2">
892 {!pull.assignees.includes(viewer.username) && (
893 <Form method="post">
894 <input type="hidden" name="action" value="assign" />
895 {pull.assignees.map((name) => (
896 <input key={name} type="hidden" name="assignee" value={name} />
897 ))}
898 <input type="hidden" name="assignee" value={viewer.username} />
899 <div className="*:w-full">
900 <Button variant="quiet" type="submit">
901 Assign yourself
902 </Button>
903 </div>
904 </Form>
905 )}
906 <details>
907 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
908 Assign people
909 </summary>
910 <Form method="post" className="mt-2 space-y-2" key={pull.assignees.join()}>
911 <input type="hidden" name="action" value="assign" />
912 <PeoplePicker name="assignee" members={members} chosen={pull.assignees} />
913 <Button variant="quiet" type="submit">
914 Save assignees
915 </Button>
916 </Form>
917 </details>
918 </div>
919 )}
920 {actionData?.action === "assign" && <ErrorText>{actionData.error}</ErrorText>}
921 </section>
Issues and pull requests replace intents and attempts922
923 <section>
924 <h3 className="text-sm font-medium">Working copy</h3>
Pull requests from branches925 {pull.branch ? (
926 <>
Agents as a team: lifecycle, merge queue, billing and a new shell927 <p className="mt-1 text-xs text-muted">
928 <GitBranch size={13} className="mr-1 inline align-[-2px]" />
Pull requests from branches929 Branch <span className="font-mono text-fg">{pull.branch}</span> of this
930 repository. Pushes to it show up here.
931 </p>
932 <div className="mt-2">
933 <CopyLine text={`git clone -b ${pull.branch} ${remote}`} />
934 </div>
935 </>
936 ) : (
937 <>
938 <p className="mt-1 text-xs text-muted">
939 This fork belongs to the pull request. Pushes to it show up here.
940 </p>
941 <div className="mt-2">
942 <CopyLine text={`git clone ${remote}`} />
943 </div>
944 </>
945 )}
Issues and pull requests replace intents and attempts946 <p className="mt-3 flex items-center gap-1.5 font-mono text-xs text-faint">
947 <GitCommitHorizontal size={13} />
948 {pull.headCommit?.slice(0, 12) ?? "no commits pushed yet"}
949 </p>
950 </section>
951 </aside>
952 </div>
953 );
954}