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,448 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 { Label } from "../../components/work";
14import { billing, events, identity, work } from "../../lib/services.server";
15import {
16 assertSameOrigin,
17 getViewer,
18 requireUser,
19 roleIn,
20 unwrap,
21} from "../../lib/session.server";
22
23const REFRESH_MS = 4000;
24
25export function meta({ params, ...args }: Route.MetaArgs) {
26 return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
27}
28
29export async function loader({ params, context }: Route.LoaderArgs) {
30 const viewer = getViewer(context);
31 // Members only; to anyone else the page does not exist.
32 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
33 const path = { namespace: params.owner, name: params.repo };
34 const [plan, ledger] = await Promise.all([
35 work.getPlan(path, viewer, params.id),
36 billing.ledger(params.owner, viewer),
37 ]);
38 const found = unwrap(plan);
39 // What the agents working on this outcome have cost so far.
40 const pulls = new Set(found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])));
41 const repo = `${params.owner}/${params.repo}`;
42 const costMicros = ledger.ok
43 ? ledger.value
44 .filter((entry) => entry.kind === "usage" && entry.repo === repo && entry.number != null && pulls.has(entry.number))
45 .reduce((sum, entry) => sum - entry.amountMicros, 0)
46 : null;
47 // What happened across the outcome's issues and pull requests.
48 let activity: G1tEvent[] = [];
49 if (found.status === "applied" && found.progress.length > 0) {
50 const numbers = new Set([
51 ...found.progress.map((item) => item.number),
52 ...found.progress.flatMap((item) => (item.pull != null ? [item.pull] : [])),
53 ]);
54 const since = found.finishedAt ?? found.createdAt;
55 const recent = await events.list({ repoId: found.repoId, limit: 200 }).catch(() => []);
56 activity = recent
57 .filter((event) => event.time >= since)
58 .filter((event) => {
59 const data = event.data as { number?: number; issue?: number };
60 // Issues an agent filed while working on this outcome belong to it too.
61 if (event.type === "issue.opened" && event.actor === "usr_g1t_agent") return true;
62 return (data.number != null && numbers.has(data.number)) || (data.issue != null && numbers.has(data.issue));
63 })
64 .slice(0, 40);
65 // Events name accounts by id; show names.
66 const named = await identity
67 .usernames([...new Set(activity.flatMap((event) => (event.actor ? [event.actor] : [])))])
68 .catch(() => ({}) as Record<string, string>);
69 const known: Record<string, string> = { ...named, usr_g1t_agent: "g1t-agent", g1t_policy: "g1t" };
70 activity = activity.map((event) => ({ ...event, actor: event.actor ? (known[event.actor] ?? event.actor) : null }));
71 }
72 return { plan: found, costMicros, activity };
73}
74
75export async function action({ request, params, context }: Route.ActionArgs) {
76 assertSameOrigin(request);
77 const user = requireUser(context, request);
78 const form = await request.formData();
79 const path = { namespace: params.owner, name: params.repo };
80 const applied = await env.RUNNER.applyPlan(user, path, params.id, {
81 assign: form.get("action") === "assign",
82 keep: form.getAll("keep").map(Number),
83 });
84 return applied.ok ? null : { error: applied.error.message };
85}
86
87export default function PlanPage({ loaderData, actionData, params }: Route.ComponentProps) {
88 const { plan, costMicros, activity } = loaderData;
89 const base = `/${params.owner}/${params.repo}`;
90 const applying = useNavigation().state === "submitting";
91
92 // The agent is still writing it.
93 const revalidator = useRevalidator();
94 const planning = plan.status === "planning";
95 // Planning, or agents still converging what was applied.
96 const moving =
97 planning || plan.progress.some((item) => item.state !== "landed" && item.state !== "closed");
98 useEffect(() => {
99 if (!moving) return;
100 const timer = setInterval(() => {
101 if (document.visibilityState === "visible") revalidator.revalidate();
102 }, REFRESH_MS);
103 return () => clearInterval(timer);
104 }, [moving, revalidator]);
105 const converging = plan.status === "applied" && plan.progress.length > 0;
106
107 const independent = plan.issues.filter((issue) => issue.dependsOn.length === 0).length;
108 return (
109 <div className={converging ? "" : "max-w-4xl"}>
110 <p className="text-sm">
111 <Link to={`${base}/plans`} className="text-muted hover:text-fg">
112 Plans
113 </Link>
114 </p>
115 <h2 className="mt-2 text-xl font-semibold tracking-tight">The outcome</h2>
116 <div className="mt-3 rounded-xl border border-line bg-surface p-5 text-sm">
117 <Markdown source={plan.brief} repo={{ namespace: params.owner, name: params.repo }} />
118 </div>
119 <p className="mt-2 text-xs text-faint">
120 Asked for by {plan.author.username} <TimeAgo at={plan.createdAt} />
121 </p>
122
123 {converging && (
124 <section className="mt-8">
125 <Outcome plan={plan} base={base} costMicros={costMicros} />
126 {plan.exchanges.length > 0 && (
127 <div className="mt-10">
128 <h3 className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">Agents talking</h3>
129 <div className="mt-3 max-w-3xl">
130 <Exchanges exchanges={plan.exchanges} base={base} />
131 </div>
132 </div>
133 )}
134 {activity.length > 0 && (
135 <div className="mt-10">
136 <h3 className="font-mono text-[0.6875rem] tracking-[0.2em] text-faint uppercase">What happened</h3>
137 <div className="mt-3 max-w-3xl">
138 <Activity events={activity} base={base} />
139 </div>
140 </div>
141 )}
142 </section>
143 )}
144
145 {planning && (
146 <div className="mt-8 flex items-center gap-3 rounded-xl border border-accent/30 bg-accent/5 px-4 py-4 text-sm">
147 <span className="size-2 animate-pulse rounded-full bg-accent" />
148 <span>
149 An agent is reading the repository and writing the plan. This takes a
150 minute or two; the page fills in when it is done.
151 </span>
152 </div>
153 )}
154 {plan.status === "failed" && (
155 <div className="mt-8 rounded-xl border border-danger/40 bg-danger/5 px-4 py-4 text-sm">
156 <p className="font-medium">The plan could not be written</p>
157 <p className="mt-1 text-muted">{plan.error}</p>
158 <p className="mt-3">
159 <Link to={`${base}/plans`} className="text-fg underline underline-offset-4">
160 Try again
161 </Link>
162 </p>
163 </div>
164 )}
165
166 {plan.issues.length > 0 && (
167 <Form method="post" className="mt-8">
168 <div className="flex flex-wrap items-baseline justify-between gap-2">
169 <h2 className="text-xl font-semibold tracking-tight">
170 {plan.status === "applied" ? "What was opened" : "The plan"}
171 </h2>
172 <p className="text-sm text-muted">
173 {plan.issues.length} {plan.issues.length === 1 ? "issue" : "issues"},{" "}
174 {independent} that can start at once
175 </p>
176 </div>
177 {plan.summary && <p className="mt-2 text-sm text-muted">{plan.summary}</p>}
178
179 <ol className="mt-5 space-y-3">
180 {plan.issues.map((issue, index) => {
181 const position = index + 1;
182 return (
183 <li
184 key={position}
185 className="flex gap-3 rounded-xl border border-line bg-surface p-4"
186 >
187 {plan.status === "ready" ? (
188 <input
189 type="checkbox"
190 name="keep"
191 value={position}
192 defaultChecked
193 aria-label={`Open issue ${position}`}
194 className="mt-1.5 accent-accent"
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}