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/plan.tsx

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