Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

157 linesCodeBlameRaw
1import { env } from "cloudflare:workers";
2import { Sparkles } from "lucide-react";
3import { Form, Link, redirect } from "react-router";
4
5import type { Plan } from "@g1t/contracts";
6
7import type { Route } from "./+types/plans";
8import { refusal, requireRepo, requireWritableRepo } from "../../lib/access.server";
9import { whyNot } from "../../lib/access";
10import { page } from "../../lib/meta";
11import { ComputeNote, EmptyState, ErrorText, SubmitButton, Textarea, TimeAgo } from "../../components/ui";
12import { Card } from "../../components/ui/card";
13import { Hint } from "../../components/ui/hint";
14import { computeNoteFor } from "../../lib/compute.server";
15import { work } from "../../lib/services.server";
16import {
17 assertSameOrigin,
18 getViewer,
19 requireUser,
20 unwrap,
21} from "../../lib/session.server";
22
23export function meta({ params, ...args }: Route.MetaArgs) {
24 return page(args, { title: `Plan · ${params.owner}/${params.repo} · g1t` });
25}
26
27export async function loader({ params, context }: Route.LoaderArgs) {
28 const viewer = getViewer(context);
29 // Anyone who can read the repository: a public one's to anyone, a private one's to people with a role.
30 const { access } = await requireRepo(context, params, "read");
31 const path = { namespace: params.owner, name: params.repo };
32 const [plans, computeNote] = await Promise.all([
33 work.listPlans(path, viewer),
34 // Planning is an agent run: said before it is refused for the plan.
35 computeNoteFor(params.owner, "agent"),
36 ]);
37 return { plans: unwrap(plans), computeNote, can: access.can };
38}
39
40export async function action({ request, params, context }: Route.ActionArgs) {
41 assertSameOrigin(request);
42 const user = requireUser(context, request);
43 await requireWritableRepo(context, params);
44 const form = await request.formData();
45 const path = { namespace: params.owner, name: params.repo };
46 // Planning is an agent run: Write and up.
47 const refused = await refusal(context, params, "run");
48 if (refused) return { error: refused };
49 const started = await env.RUNNER.plan(user, path, String(form.get("brief") ?? ""));
50 if (!started.ok) return { error: started.error.message };
51 throw redirect(`/${params.owner}/${params.repo}/plans/${started.value.planId}`);
52}
53
54const STATUS: Record<Plan["status"], string> = {
55 planning: "Being written",
56 ready: "Ready to read",
57 failed: "Could not be written",
58 applied: "Applied",
59};
60
61export default function Plans({ loaderData, actionData, params }: Route.ComponentProps) {
62 const { plans } = loaderData;
63 const base = `/${params.owner}/${params.repo}`;
64
65 return (
66 <div className="grid gap-10 lg:grid-cols-[1fr_20rem]">
67 <div className="min-w-0">
68 <h2 className="text-xl font-semibold tracking-tight">Plan work</h2>
69 <p className="mt-1 max-w-2xl text-sm text-muted">
70 Say what you want to be true when the work is done. An agent reads this
71 repository and proposes the issues that would get there, with what each
72 must pass and which have to land before which. You read it, change what
73 you like, and put g1t on it with one click.
74 </p>
75 <Form method="post" className="mt-5 space-y-3">
76 <Textarea
77 name="brief"
78 rows={7}
79 required
80 placeholder={
81 "The outcome, in your own words. For example:\n\nThe greeter should greet in Italian with --lang it and in Portuguese with --lang pt. Each language should be documented in the README and covered by tests."
82 }
83 />
84 <div className="flex flex-wrap items-center gap-3">
85 <Hint label={whyNot(loaderData.can, "run")} disabled={!loaderData.can.run}>
86 <SubmitButton variant="accent" pending="Starting the planner…" disabled={!loaderData.can.run}>
87 <Sparkles size={15} />
88 Plan it
89 </SubmitButton>
90 </Hint>
91 <span className="text-xs text-muted">
92 Nothing is opened until you have read the plan.
93 </span>
94 </div>
95 {actionData?.error ? <ErrorText>{actionData.error}</ErrorText> : <ComputeNote note={loaderData.computeNote} />}
96 </Form>
97
98 <h3 className="mt-10 text-sm font-medium text-muted">Plans</h3>
99 <div className="mt-3">
100 {plans.length === 0 ? (
101 <EmptyState title="No plans yet" />
102 ) : (
103 <Card asChild tone="plain" divided className="overflow-hidden">
104 <ul>
105 {plans.map((plan) => (
106 <li key={plan.id}>
107 <Link
108 to={`${base}/plans/${plan.id}`}
109 className="flex items-center gap-4 px-4 py-3 transition-colors hover:bg-surface"
110 >
111 <span className="min-w-0 grow">
112 <span className="block truncate font-medium">{plan.brief}</span>
113 <span className="mt-0.5 block text-xs text-faint">
114 {plan.author.username} · <TimeAgo at={plan.createdAt} />
115 {plan.issues.length > 0 &&
116 ` · ${plan.issues.length} ${plan.issues.length === 1 ? "issue" : "issues"}`}
117 </span>
118 </span>
119 <span
120 className={`shrink-0 text-xs ${
121 plan.status === "failed"
122 ? "text-danger"
123 : plan.status === "ready"
124 ? "text-success"
125 : "text-muted"
126 }`}
127 >
128 {STATUS[plan.status]}
129 </span>
130 </Link>
131 </li>
132 ))}
133 </ul>
134 </Card>
135 )}
136 </div>
137 </div>
138
139 <aside className="space-y-5 text-sm">
140 <Card asChild className="p-5">
141 <section>
142 <h3 className="font-medium">What a plan gives you</h3>
143 <ul className="mt-2 list-disc space-y-1.5 pl-4 text-muted">
144 <li>Issues small enough to be merged one at a time, written so that an agent needs nothing else.</li>
145 <li>What done looks like for each, in plain words, added to its description.</li>
146 <li>The files each will touch, and a dependency wherever two would collide.</li>
147 <li>
148 Independent issues are worked on at the same time. One that depends on
149 another starts when that has merged, from its result.
150 </li>
151 </ul>
152 </section>
153 </Card>
154 </aside>
155 </div>
156 );
157}