Skip to content

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

307 lines13,876 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import type { G1tEvent } from "@g1t/contracts";
3import { ArrowRight, CircleCheck, FileCode2, Sparkles } from "lucide-react";
4import { Form, Link, data } from "react-router";
5
6import type { Route } from "./+types/plan";
7import { refusal, requireRepo } from "../../lib/access.server";
8import { whyNot } from "../../lib/access";
9import { page } from "../../lib/meta";
10import { Markdown } from "../../components/markdown";
11import { Activity, Exchanges } from "../../components/activity";
12import { Outcome } from "../../components/outcome";
13import { ErrorText, SubmitButton, TimeAgo, usePending } from "../../components/ui";
14import { Checkbox } from "../../components/ui/checkbox";
15import { Hint } from "../../components/ui/hint";
16import { Label } from "../../components/work";
17import { billing, events, identity, work } from "../../lib/services.server";
18import {
19 assertSameOrigin,
20 getViewer,
21 requireUser,
22 unwrap,
23} from "../../lib/session.server";
24import { useRefreshWhile } from "../../lib/refresh";
25
26
27export function meta({ params, ...args }: Route.MetaArgs) {
28 return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
29}
30
31export async function loader({ params, context }: Route.LoaderArgs) {
32 const viewer = getViewer(context);
33 // Anyone who can read the repository: a public one's to anyone, a private one's to people with a role.
34 const { access } = await requireRepo(context, params, "read");
35 const path = { namespace: params.owner, name: params.repo };
36 const [plan, ledger] = await Promise.all([
37 work.getPlan(path, viewer, params.id),
38 billing.ledger(params.owner, viewer),
39 ]);
40 const found = unwrap(plan);
41 // What the agents working on this outcome have cost so far.
42 const pulls = new Set(found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])));
43 const repo = `${params.owner}/${params.repo}`;
44 const costMicros = ledger.ok
45 ? ledger.value
46 .filter((entry) => entry.kind === "usage" && entry.repo === repo && entry.number != null && pulls.has(entry.number))
47 .reduce((sum, entry) => sum - entry.amountMicros, 0)
48 : null;
49 // What happened across the outcome's issues and pull requests.
50 let activity: G1tEvent[] = [];
51 if (found.status === "applied" && found.progress.length > 0) {
52 const numbers = new Set([
53 ...found.progress.map((item) => item.number),
54 ...found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])),
55 ]);
56 const since = found.finishedAt ?? found.createdAt;
57 const recent = await events.list({ repoId: found.repoId, limit: 200 }).catch(() => []);
58 activity = recent
59 .filter((event) => event.time >= since)
60 .filter((event) => {
61 const data = event.data as { number?: number; issue?: number };
62 // Issues an agent filed while working on this outcome belong to it too.
63 if (event.type === "issue.opened" && event.actor === "usr_g1t_agent") return true;
64 return (data.number != null && numbers.has(data.number)) || (data.issue != null && numbers.has(data.issue));
65 })
66 .slice(0, 40);
67 // Events name accounts by id; show names.
68 const named = await identity
69 .usernames([...new Set(activity.flatMap((event) => (event.actor ? [event.actor] : [])))])
70 .catch(() => ({}) as Record<string, string>);
71 const known: Record<string, string> = { ...named, usr_g1t_agent: "g1t", g1t_policy: "g1t" };
72 activity = activity.map((event) => ({ ...event, actor: event.actor ? (known[event.actor] ?? event.actor) : null }));
73 }
74 return { plan: found, costMicros, activity, can: access.can };
75}
76
77export async function action({ request, params, context }: Route.ActionArgs) {
78 assertSameOrigin(request);
79 const user = requireUser(context, request);
80 const form = await request.formData();
81 const path = { namespace: params.owner, name: params.repo };
82 // Opening the issues and putting agents on them: Write and up.
83 const refused = await refusal(context, params, "run");
84 if (refused) return { error: refused };
85 const applied = await env.RUNNER.applyPlan(user, path, params.id, {
86 assign: form.get("action") === "assign",
87 keep: form.getAll("keep").map(Number),
88 });
89 return applied.ok ? null : { error: applied.error.message };
90}
91
92export default function PlanPage({ loaderData, actionData, params }: Route.ComponentProps) {
93 const { plan, costMicros, activity } = loaderData;
94 const base = `/${params.owner}/${params.repo}`;
95 // Either way of applying it, so the other button waits too.
96 const applying = usePending();
97
98 // The agent is still writing it.
99 const planning = plan.status === "planning";
100 // Planning, or agents still converging what was applied.
101 const moving =
102 planning || plan.progress.some((item) => item.state !== "landed" && item.state !== "closed");
103 useRefreshWhile(moving);
104 const converging = plan.status === "applied" && plan.progress.length > 0;
105
106 const independent = plan.issues.filter((issue) => issue.dependsOn.length === 0).length;
107 return (
108 <div className={converging ? "" : "max-w-4xl"}>
109 <p className="text-sm">
110 <Link to={`${base}/plans`} className="text-muted hover:text-fg">
111 Plans
112 </Link>
113 </p>
114 <h2 className="mt-2 text-xl font-semibold tracking-tight">The outcome</h2>
115 <div className="mt-3 rounded-xl border border-line bg-surface p-5 text-sm">
116 <Markdown source={plan.brief} repo={{ namespace: params.owner, name: params.repo }} />
117 </div>
118 <p className="mt-2 text-xs text-faint">
119 Asked for by {plan.author.username} <TimeAgo at={plan.createdAt} />
120 </p>
121
122 {converging && (
123 <section className="mt-8">
124 <Outcome plan={plan} base={base} costMicros={costMicros} />
125 {plan.exchanges.length > 0 && (
126 <div className="mt-10">
127 <h3 className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">Agents talking</h3>
128 <div className="mt-3 max-w-3xl">
129 <Exchanges exchanges={plan.exchanges} base={base} />
130 </div>
131 </div>
132 )}
133 {activity.length > 0 && (
134 <div className="mt-10">
135 <h3 className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">What happened</h3>
136 <div className="mt-3 max-w-3xl">
137 <Activity events={activity} base={base} />
138 </div>
139 </div>
140 )}
141 </section>
142 )}
143
144 {planning && (
145 <div className="mt-8 flex items-center gap-3 rounded-xl border border-accent/30 bg-accent/5 px-4 py-4 text-sm">
146 <span className="size-2 animate-pulse rounded-full bg-accent" />
147 <span>
148 An agent is reading the repository and writing the plan. This takes a
149 minute or two; the page fills in when it is done.
150 </span>
151 </div>
152 )}
153 {plan.status === "failed" && (
154 <div className="mt-8 rounded-xl border border-danger/40 bg-danger/5 px-4 py-4 text-sm">
155 <p className="font-medium">The plan could not be written</p>
156 <p className="mt-1 text-muted">{plan.error}</p>
157 <p className="mt-3">
158 <Link to={`${base}/plans`} className="text-fg underline underline-offset-4">
159 Try again
160 </Link>
161 </p>
162 </div>
163 )}
164
165 {plan.issues.length > 0 && (
166 <Form method="post" className="mt-8">
167 <div className="flex flex-wrap items-baseline justify-between gap-2">
168 <h2 className="text-xl font-semibold tracking-tight">
169 {plan.status === "applied" ? "What was opened" : "The plan"}
170 </h2>
171 <p className="text-sm text-muted">
172 {plan.issues.length} {plan.issues.length === 1 ? "issue" : "issues"},{" "}
173 {independent} that can start at once
174 </p>
175 </div>
176 {plan.summary && <p className="mt-2 text-sm text-muted">{plan.summary}</p>}
177
178 <ol className="mt-5 space-y-3">
179 {plan.issues.map((issue, index) => {
180 const position = index + 1;
181 return (
182 <li
183 key={position}
184 className="flex gap-3 rounded-xl border border-line bg-surface p-4"
185 >
186 {plan.status === "ready" ? (
187 <Checkbox
188 name="keep"
189 value={String(position)}
190 defaultChecked
191 aria-label={`Open issue ${position}`}
192 className="mt-1"
193 />
194 ) : (
195 <span className="mt-0.5 w-5 shrink-0 text-center font-mono text-sm text-faint">
196 {position}
197 </span>
198 )}
199 <div className="min-w-0 grow">
200 <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
201 {issue.number != null ? (
202 <Link
203 to={`${base}/issues/${issue.number}`}
204 className="font-medium hover:underline"
205 >
206 {issue.title}{" "}
207 <span className="font-normal text-faint">#{issue.number}</span>
208 </Link>
209 ) : (
210 <span className="font-medium">
211 <span className="mr-1.5 font-mono text-sm font-normal text-faint">
212 {position}.
213 </span>
214 {issue.title}
215 </span>
216 )}
217 {issue.labels.map((name) => (
218 <Label key={name} name={name} />
219 ))}
220 </div>
221 <p className="mt-1.5 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted">
222 {issue.dependsOn.length === 0 ? (
223 <span className="text-success">Starts at once</span>
224 ) : (
225 <span className="flex items-center gap-1">
226 <ArrowRight size={12} />
227 After{" "}
228 {issue.dependsOn
229 .map((earlier) => {
230 const number = plan.issues[earlier - 1]?.number;
231 return number != null ? `#${number}` : `${earlier}`;
232 })
233 .join(", ")}
234 </span>
235 )}
236 {(issue.done ?? []).map((point) => (
237 <span key={point} className="flex items-center gap-1">
238 <CircleCheck size={12} />
239 {point}
240 </span>
241 ))}
242 </p>
243 {issue.files.length > 0 && (
244 <p className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-xs text-faint">
245 <FileCode2 size={12} />
246 {issue.files.map((file) => (
247 <span key={file}>{file}</span>
248 ))}
249 </p>
250 )}
251 <details className="mt-2">
252 <summary className="cursor-pointer text-xs text-faint hover:text-fg">
253 What the agent will be told
254 </summary>
255 <div className="mt-2 border-t border-line pt-3">
256 <Markdown source={issue.body} repo={{ namespace: params.owner, name: params.repo }} />
257 </div>
258 </details>
259 </div>
260 </li>
261 );
262 })}
263 </ol>
264
265 {plan.status === "ready" && (
266 <div className="mt-6 flex flex-wrap items-center gap-3">
267 <Hint label={whyNot(loaderData.can, "run")} disabled={!loaderData.can.run}>
268 <SubmitButton variant="accent" name="action" value="assign" pending="Opening issues…" disabled={applying || !loaderData.can.run}>
269 <Sparkles size={15} />
270 Open these and assign g1t
271 </SubmitButton>
272 </Hint>
273 <Hint label={whyNot(loaderData.can, "run")} disabled={!loaderData.can.run}>
274 <SubmitButton variant="quiet" name="action" value="open" pending="Opening issues…" disabled={applying || !loaderData.can.run}>
275 Only open the issues
276 </SubmitButton>
277 </Hint>
278 <span className="text-xs text-muted">
279 Untick any you do not want. Agents work on the independent ones at once
280 and the rest follow as what they depend on merges.
281 </span>
282 </div>
283 )}
284 {plan.status === "applied" && !moving && (
285 <p className="mt-6 text-sm text-muted">Everything in this plan has landed.</p>
286 )}
287 {plan.status === "applied" && moving && (
288 <p className="mt-6 text-sm text-muted">
289 Some are still open. Follow them under{" "}
290 <Link to={`${base}/issues`} className="text-fg underline underline-offset-4">
291 Issues
292 </Link>{" "}
293 or in{" "}
294 <Link to="/" className="text-fg underline underline-offset-4">
295 mission control
296 </Link>
297 .
298 </p>
299 )}
300 <div className="mt-3">
301 <ErrorText>{actionData?.error}</ErrorText>
302 </div>
303 </Form>
304 )}
305 </div>
306 );
307}