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

454 lines16,735 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.

Issues and pull requests replace intents and attempts1import {
2 Bot,
3 ChevronRight,
4 FileDiff,
Pull requests from branches5 GitBranch,
Issues and pull requests replace intents and attempts6 GitCommitHorizontal,
7 GitMerge,
8 MessageSquare,
9 MessagesSquare,
10 StickyNote,
11 User,
12 Wrench,
13} from "lucide-react";
14import { useEffect } from "react";
15import { Form, Link, redirect, useRevalidator } from "react-router";
16
Pull requests from branches17import { type Comparison, type SessionEntry, pullComparison } from "@g1t/contracts";
Issues and pull requests replace intents and attempts18
19import type { Route } from "./+types/pull";
20import { DiffView } from "../../components/diff-view";
21import { Markdown } from "../../components/markdown";
22import {
23 Avatar,
24 Button,
25 CopyLine,
26 EmptyState,
27 ErrorText,
28 Textarea,
29 TimeAgo,
30} from "../../components/ui";
31import { Comments, IssueIcon, PullState } from "../../components/work";
32import { repos, work } from "../../lib/services.server";
33import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
34
35const REFRESH_MS = 4000;
36const EMPTY_COMPARISON: Comparison = { base: null, head: "", files: [], truncated: false };
37const TABS = ["conversation", "session", "changes"] as const;
38type Tab = (typeof TABS)[number];
39
40export function meta({ loaderData, params }: Route.MetaArgs) {
41 const title = loaderData
42 ? `${loaderData.pull.title} · Pull request #${loaderData.pull.number} · `
43 : "";
44 return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
45}
46
47export async function loader({ params, context, request }: Route.LoaderArgs) {
48 const viewer = getViewer(context);
49 const path = { namespace: params.owner, name: params.repo };
50 const number = Number(params.number);
51 const asked = new URL(request.url).searchParams.get("tab");
52 const tab: Tab = TABS.find((name) => name === asked) ?? "conversation";
53
54 const [found, repo] = await Promise.all([
55 work.getPull(path, number, viewer),
56 repos.get(path, viewer),
57 ]);
58 if (!found.ok) {
59 // Issues and pull requests share numbers; this one may be an issue.
60 const issue = await work.getIssue(path, number, viewer);
61 if (issue.ok) throw redirect(`/${params.owner}/${params.repo}/issues/${number}`);
62 throw new Response("Pull request not found.", { status: 404 });
63 }
64 const { pull } = found.value;
Pull requests from branches65 const range = pullComparison(pull);
Issues and pull requests replace intents and attempts66 const [session, comparison] = await Promise.all([
67 tab === "session" ? work.readSession(path, number, viewer) : null,
Pull requests from branches68 tab === "changes"
69 ? repos.compare(range.repoId, viewer, range.base, range.head)
70 : null,
Issues and pull requests replace intents and attempts71 ]);
72 const member = (viewer?.workspaces ?? []).some(
73 (membership) => membership.slug === params.owner,
74 );
75 return {
76 ...found.value,
77 tab,
78 session: session?.ok ? session.value : [],
79 // An empty comparison if it could not be made.
80 comparison: comparison && (comparison.ok ? comparison.value : EMPTY_COMPARISON),
81 viewer,
82 // Members of the repository's workspace can merge.
83 canMerge: member,
84 canManage: member || viewer?.id === pull.author.id,
85 defaultBranch: repo.ok ? repo.value.defaultBranch : "main",
86 };
87}
88
89export async function action({ request, params, context }: Route.ActionArgs) {
90 assertSameOrigin(request);
91 const user = requireUser(context, request);
92 const form = await request.formData();
93 const path = { namespace: params.owner, name: params.repo };
94 const number = Number(params.number);
95 const action = form.get("action");
96 const result =
97 action === "merge"
98 ? await work.mergePull(user, path, number, form.get("keepIssueOpen") === "on")
99 : action === "close"
100 ? await work.closePull(user, path, number)
101 : action === "comment"
102 ? await work.addComment(user, path, number, String(form.get("body") ?? ""))
103 : await work.readyPull(user, path, number, String(form.get("summary") ?? ""));
104 return result.ok ? null : { error: result.error.message, action };
105}
106
107function TabLink({
108 to,
109 active,
110 children,
111}: {
112 to: string;
113 active: boolean;
114 children: React.ReactNode;
115}) {
116 return (
117 <Link
118 to={to}
119 preventScrollReset
120 className={
121 "-mb-px flex items-center gap-2 border-b-2 px-1 pb-2.5 text-sm transition-colors " +
122 (active
123 ? "border-accent font-medium text-fg"
124 : "border-transparent text-muted hover:text-fg")
125 }
126 >
127 {children}
128 </Link>
129 );
130}
131
132/** One step of the session, on the timeline's rail. */
133function Entry({ entry, agent }: { entry: SessionEntry; agent: string }) {
134 const isTool = entry.kind === "tool_call" || entry.kind === "tool_result";
135 const Icon =
136 entry.kind === "prompt"
137 ? User
138 : entry.kind === "note"
139 ? StickyNote
140 : isTool
141 ? Wrench
142 : Bot;
143 return (
144 <li className="relative pl-10">
145 <span
146 className={`absolute top-0.5 left-0 flex size-7 items-center justify-center rounded-full border bg-bg ${
147 entry.kind === "prompt"
148 ? "border-accent/50 text-accent"
149 : "border-line text-faint"
150 }`}
151 >
152 <Icon size={14} />
153 </span>
154 {isTool ? (
155 <details className="group rounded-lg border border-line bg-surface">
156 <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-1.5 text-sm">
157 <ChevronRight
158 size={14}
159 className="text-faint transition-transform group-open:rotate-90"
160 />
161 <span className="font-mono text-xs text-accent">
162 {entry.tool ?? "tool"}
163 </span>
164 <span className="truncate font-mono text-xs text-muted">
165 {entry.kind === "tool_result" ? "→ " : ""}
166 {entry.text.split("\n")[0]}
167 </span>
168 </summary>
169 <pre className="overflow-x-auto border-t border-line p-3 font-mono text-xs whitespace-pre-wrap text-muted">
170 {entry.text}
171 </pre>
172 </details>
173 ) : (
174 <div>
175 <p className="text-xs font-medium text-faint">
176 {entry.kind === "prompt"
177 ? "Prompt"
178 : entry.kind === "note"
179 ? "Note"
180 : agent}
181 </p>
182 {entry.kind === "message" ? (
183 <div className="mt-1">
184 <Markdown source={entry.text} />
185 </div>
186 ) : (
187 <p
188 className={`mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap ${
189 entry.kind === "prompt" ? "font-medium" : ""
190 }`}
191 >
192 {entry.text}
193 </p>
194 )}
195 </div>
196 )}
197 {entry.commit && (
198 <p className="mt-1.5 flex items-center gap-1 font-mono text-xs text-faint">
199 <GitCommitHorizontal size={12} />
200 {entry.commit.slice(0, 7)}
201 </p>
202 )}
203 </li>
204 );
205}
206
207export default function PullPage({ loaderData, actionData, params }: Route.ComponentProps) {
208 const {
209 pull,
210 issue,
211 comments,
212 tab,
213 session,
214 comparison,
215 viewer,
216 canMerge,
217 canManage,
218 defaultBranch,
219 } = loaderData;
220 const base = `/${params.owner}/${params.repo}`;
221 const here = `${base}/pull/${pull.number}`;
Pull requests from branches222 const remote = pull.fork
223 ? `https://g1t.sh/${pull.fork.namespace}/${pull.fork.name}.git`
224 : `https://g1t.sh/${params.owner}/${params.repo}.git`;
Issues and pull requests replace intents and attempts225 const active = pull.status === "draft" || pull.status === "open";
226
227 // Follow an agent at work without a manual reload.
228 const revalidator = useRevalidator();
229 const working = pull.status === "draft";
230 useEffect(() => {
231 if (!working) return;
232 const timer = setInterval(() => {
233 if (document.visibilityState === "visible") revalidator.revalidate();
234 }, REFRESH_MS);
235 return () => clearInterval(timer);
236 }, [working, revalidator]);
237
238 return (
239 <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
240 <div className="min-w-0">
241 <h2 className="text-2xl font-semibold tracking-tight text-balance">
242 {pull.title} <span className="font-normal text-faint">#{pull.number}</span>
243 </h2>
244 <div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-2 text-sm text-muted">
245 <PullState status={pull.status} />
246 <span className="flex items-center gap-2">
247 <Avatar name={pull.author.username} size={18} />
248 <span>
249 <span className="font-medium text-fg">{pull.author.username}</span>{" "}
Pull requests from branches250 {pull.status === "merged" ? "merged" : "wants to merge"}
251 {pull.branch && (
252 <>
253 {" "}
254 <span className="font-mono text-fg">{pull.branch}</span>
255 </>
256 )}{" "}
257 into <span className="font-mono text-fg">{defaultBranch}</span>
Issues and pull requests replace intents and attempts258 </span>
259 </span>
Pull requests from branches260 {/* A pull request from a branch was made by its author, not an agent. */}
261 {!pull.branch && (
262 <span className="flex items-center gap-1.5 font-mono text-xs">
263 <Bot size={14} />
264 {pull.agent}
265 {pull.runtime === "hosted" && <span className="text-faint">on g1t</span>}
266 </span>
267 )}
Issues and pull requests replace intents and attempts268 </div>
269
270 {issue && (
271 <Link
272 to={`${base}/issues/${issue.number}`}
273 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"
274 >
275 <IssueIcon issue={issue} />
276 <span className="text-muted">
277 {pull.status === "merged" && issue.resolvedBy === pull.number
278 ? "Resolved"
279 : "For issue"}
280 </span>
281 <span className="truncate font-medium">{issue.title}</span>
282 <span className="text-faint">#{issue.number}</span>
283 {issue.pullCount > 1 && (
284 <span className="ml-auto shrink-0 text-xs text-faint">
285 1 of {issue.pullCount} pull requests
286 </span>
287 )}
288 </Link>
289 )}
290
291 {pull.status === "merged" && (
292 <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">
293 <GitMerge size={16} className="shrink-0 text-merged" />
294 <span>
295 Merged into <span className="font-mono">{defaultBranch}</span> by{" "}
296 <span className="font-medium">{pull.mergedBy}</span>{" "}
297 {pull.mergedAt && <TimeAgo at={pull.mergedAt} />}
298 {pull.headCommit && (
299 <span className="font-mono text-muted"> · {pull.headCommit.slice(0, 7)}</span>
300 )}
301 </span>
302 </p>
303 )}
304 {pull.supersededBy != null && (
305 <p className="mt-4 rounded-xl border border-line bg-surface px-4 py-2.5 text-sm text-muted">
306 Closed because{" "}
307 <Link
308 to={`${base}/pull/${pull.supersededBy}`}
309 className="font-medium text-fg hover:underline"
310 >
311 #{pull.supersededBy}
312 </Link>{" "}
313 was merged for this issue instead.
314 </p>
315 )}
316
317 <nav className="mt-8 flex gap-6 border-b border-line">
318 <TabLink to={here} active={tab === "conversation"}>
319 <MessageSquare size={15} />
320 Conversation
321 </TabLink>
322 <TabLink to={here + "?tab=session"} active={tab === "session"}>
323 <MessagesSquare size={15} />
324 Session
325 </TabLink>
326 <TabLink to={here + "?tab=changes"} active={tab === "changes"}>
327 <FileDiff size={15} />
328 Changes
329 </TabLink>
330 </nav>
331 <div className="mt-5">
332 {comparison ? (
333 <DiffView comparison={comparison} />
334 ) : tab === "session" ? (
335 session.length === 0 ? (
336 <EmptyState title="Nothing recorded yet">
337 The agent's prompts, reasoning and tool calls appear here as it
338 works.
339 </EmptyState>
340 ) : (
341 <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line">
342 {session.map((entry) => (
343 <Entry key={entry.seq} entry={entry} agent={pull.agent} />
344 ))}
345 </ol>
346 )
347 ) : (
348 <div className="space-y-4">
349 {pull.body ? (
350 <section className="rounded-xl border border-line bg-surface p-5">
351 <Markdown source={pull.body} />
352 </section>
353 ) : (
354 <p className="rounded-xl border border-dashed border-line px-5 py-4 text-sm text-muted">
355 {working
356 ? "No description yet. It is written when the pull request is marked ready for review."
357 : "No description."}
358 </p>
359 )}
360 <Comments comments={comments} canComment={Boolean(viewer)} />
361 {actionData?.action === "comment" && <ErrorText>{actionData.error}</ErrorText>}
362 </div>
363 )}
364 </div>
365 </div>
366
367 <aside className="space-y-6">
368 {canMerge && pull.status === "open" && (
369 <section className="rounded-xl border border-accent/30 bg-accent/5 p-4">
370 <h3 className="text-sm font-medium">Merge this pull request</h3>
371 <p className="mt-1 text-xs text-muted">
372 Lands its commits on {defaultBranch}.
373 {issue?.state === "open" &&
374 ` Closes issue #${issue.number}, and any other pull requests still open for it.`}
375 </p>
376 <Form method="post" className="mt-3 space-y-3">
377 {issue?.state === "open" && (
378 <label className="flex items-start gap-2 text-xs text-muted">
379 <input type="checkbox" name="keepIssueOpen" className="mt-0.5 accent-accent" />
380 <span>
381 Keep #{issue.number} open. This is only part of the work.
382 </span>
383 </label>
384 )}
385 <div className="*:w-full">
386 <Button variant="accent" type="submit" name="action" value="merge">
387 <GitMerge size={15} />
388 Merge into {defaultBranch}
389 </Button>
390 </div>
391 </Form>
392 {actionData?.action === "merge" && <ErrorText>{actionData.error}</ErrorText>}
393 </section>
394 )}
395
396 {canManage && active && (
397 <section className="rounded-xl border border-line bg-surface p-4">
398 <h3 className="text-sm font-medium">
399 {pull.status === "draft" ? "Ready for review" : "Update description"}
400 </h3>
401 <Form method="post" className="mt-3 space-y-2">
402 <Textarea
403 name="summary"
404 rows={4}
405 placeholder="What changed and why"
406 defaultValue={pull.body ?? ""}
407 />
408 <div className="flex flex-wrap gap-2">
409 <Button type="submit">
410 {pull.status === "draft" ? "Mark ready" : "Save"}
411 </Button>
412 <Button variant="quiet" type="submit" name="action" value="close">
413 Close pull request
414 </Button>
415 </div>
416 </Form>
417 {actionData && actionData.action !== "merge" && actionData.action !== "comment" && (
418 <ErrorText>{actionData.error}</ErrorText>
419 )}
420 </section>
421 )}
422
423 <section>
424 <h3 className="text-sm font-medium">Working copy</h3>
Pull requests from branches425 {pull.branch ? (
426 <>
427 <p className="mt-1 flex items-center gap-1.5 text-xs text-muted">
428 <GitBranch size={13} />
429 Branch <span className="font-mono text-fg">{pull.branch}</span> of this
430 repository. Pushes to it show up here.
431 </p>
432 <div className="mt-2">
433 <CopyLine text={`git clone -b ${pull.branch} ${remote}`} />
434 </div>
435 </>
436 ) : (
437 <>
438 <p className="mt-1 text-xs text-muted">
439 This fork belongs to the pull request. Pushes to it show up here.
440 </p>
441 <div className="mt-2">
442 <CopyLine text={`git clone ${remote}`} />
443 </div>
444 </>
445 )}
Issues and pull requests replace intents and attempts446 <p className="mt-3 flex items-center gap-1.5 font-mono text-xs text-faint">
447 <GitCommitHorizontal size={13} />
448 {pull.headCommit?.slice(0, 12) ?? "no commits pushed yet"}
449 </p>
450 </section>
451 </aside>
452 </div>
453 );
454}