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

303 lines12,342 bytesCodeBlame
1import { CheckCircle2, ChevronRight, CircleSlash, FileCode2, Play, XCircle, Zap } from "lucide-react";
2import { Form, Link, useNavigation } from "react-router";
3
4import type { Automation, AutomationRun } from "@g1t/contracts";
5
6import type { Route } from "./+types/automations";
7import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui";
8import { automations } from "../../lib/services.server";
9import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
10
11export function meta({ params }: Route.MetaArgs) {
12 return [{ title: `Automations · ${params.owner}/${params.repo} · g1t` }];
13}
14
15export async function loader({ params, context }: Route.LoaderArgs) {
16 const viewer = getViewer(context);
17 const repo = { namespace: params.owner, name: params.repo };
18 const [list, runs] = await Promise.all([automations.list(repo, viewer), automations.runs(repo, viewer)]);
19 return {
20 automations: unwrap(list),
21 runs: runs.ok ? runs.value : [],
22 member: roleIn(viewer, params.owner) != null,
23 };
24}
25
26export async function action({ request, params, context }: Route.ActionArgs) {
27 assertSameOrigin(request);
28 const user = requireUser(context, request);
29 const repo = { namespace: params.owner, name: params.repo };
30 const form = await request.formData();
31 const id = String(form.get("id") ?? "");
32 if (form.get("intent") === "toggle") {
33 const changed = await automations.setEnabled(user, repo, id, form.get("enabled") === "true");
34 return changed.ok ? {} : { error: changed.error.message };
35 }
36 const number = Number(form.get("number"));
37 const ran = await automations.run(user, repo, id, Number.isInteger(number) && number > 0 ? number : undefined);
38 return ran.ok ? { ran: ran.value } : { error: ran.error.message };
39}
40
41/** Ready-made automations to start from. */
42const TEMPLATES: { file: string; title: string; about: string; yaml: string }[] = [
43 {
44 file: "bugs.yml",
45 title: "Put an agent on every bug",
46 about: "A new issue labelled bug gets a g1t agent at once, and a note saying so.",
47 yaml: `name: Put an agent on every bug
48on: issue.opened
49if:
50 labels: bug
51do:
52 - comment: "Thanks, {{actor}}. A g1t agent is on it."
53 - assign_agent`,
54 },
55 {
56 file: "failed-checks.yml",
57 title: "Tell the agent when checks fail",
58 about: "When a pull request's checks fail, the agent working on it hears at once.",
59 yaml: `name: Tell the agent when checks fail
60on: checks.completed
61if:
62 status: failed
63do:
64 - message_agent: "The acceptance checks failed. Read their output on #{{number}} and fix the cause."`,
65 },
66 {
67 file: "announce-merges.yml",
68 title: "Announce merges in chat",
69 about: "Every merge posts to a Slack or Discord channel through its incoming webhook.",
70 yaml: `name: Announce merges
71on: pull.merged
72do:
73 - notify:
74 url: https://hooks.slack.com/services/...
75 text: "Merged into {{repo}}: {{title}} {{url}}"`,
76 },
77 {
78 file: "weekly-tidy.yml",
79 title: "A weekly tidy-up",
80 about: "Every Monday morning an issue opens and an agent takes it.",
81 yaml: `name: Weekly tidy-up
82on:
83 schedule: "0 9 * * mon"
84do:
85 - open_issue:
86 title: Weekly tidy-up
87 body: Update dependencies that have new patch releases, and fix any new warnings.
88 labels: chore
89 assign_agent: true`,
90 },
91];
92
93function RunStatus({ run }: { run: AutomationRun }) {
94 if (run.status === "succeeded") return <CheckCircle2 size={15} className="shrink-0 text-accent" />;
95 if (run.status === "failed") return <XCircle size={15} className="shrink-0 text-danger" />;
96 return <CircleSlash size={15} className="shrink-0 text-faint" />;
97}
98
99function RunRow({ run, base }: { run: AutomationRun; base: string }) {
100 return (
101 <details className="group border-t border-line first:border-t-0">
102 <summary className="flex cursor-pointer list-none items-center gap-3 px-4 py-2.5 text-sm hover:bg-raised/40">
103 <ChevronRight size={14} className="shrink-0 text-faint transition-transform group-open:rotate-90" />
104 <RunStatus run={run} />
105 <span className="min-w-0 truncate font-medium">{run.name}</span>
106 <span className="shrink-0 font-mono text-xs text-muted">{run.event}</span>
107 {run.number != null && (
108 <Link to={`${base}/issues/${run.number}`} className="shrink-0 font-mono text-xs text-muted hover:text-fg">
109 #{run.number}
110 </Link>
111 )}
112 <span className="ml-auto shrink-0 text-xs text-faint">
113 {run.actor && `${run.actor} · `}
114 <TimeAgo at={run.startedAt} />
115 </span>
116 </summary>
117 <div className="border-t border-line bg-bg/40 px-4 py-3 text-sm">
118 {run.reason && <p className="text-muted">{run.reason}</p>}
119 {run.steps.length > 0 && (
120 <ol className="space-y-1.5">
121 {run.steps.map((step, index) => (
122 <li key={index} className="flex items-start gap-2">
123 {step.ok ? (
124 <CheckCircle2 size={14} className="mt-0.5 shrink-0 text-accent" />
125 ) : (
126 <XCircle size={14} className="mt-0.5 shrink-0 text-danger" />
127 )}
128 <span>
129 <span className="font-medium">{step.step}</span>
130 <span className="text-muted"> · {step.detail}</span>
131 </span>
132 </li>
133 ))}
134 </ol>
135 )}
136 </div>
137 </details>
138 );
139}
140
141function AutomationCard({ automation, base, member }: { automation: Automation; base: string; member: boolean }) {
142 const busy = useNavigation().state === "submitting";
143 return (
144 <li className="border-t border-line p-4 first:border-t-0">
145 <div className="flex flex-wrap items-start gap-3">
146 <span
147 className={`mt-0.5 inline-flex size-8 shrink-0 items-center justify-center rounded-lg ring-1 ${
148 automation.error
149 ? "bg-danger/10 text-danger ring-danger/30"
150 : automation.enabled
151 ? "bg-accent/10 text-accent ring-accent/30"
152 : "bg-surface text-faint ring-line"
153 }`}
154 >
155 <Zap size={15} />
156 </span>
157 <div className="min-w-0 grow">
158 <p className="flex flex-wrap items-center gap-2">
159 <span className="font-medium">{automation.name}</span>
160 {!automation.enabled && !automation.error && (
161 <span className="rounded-full px-2 py-px text-xs text-muted ring-1 ring-line">Off</span>
162 )}
163 </p>
164 <Link
165 to={`${base}/blob/HEAD/${automation.path}`}
166 className="inline-flex items-center gap-1 font-mono text-xs text-faint hover:text-fg"
167 >
168 <FileCode2 size={12} />
169 {automation.path}
170 </Link>
171 {automation.error ? (
172 <p className="mt-2 text-sm text-danger">{automation.error}</p>
173 ) : (
174 <p className="mt-2 text-sm text-muted">
175 <span className="text-fg">On</span> {automation.trigger}
176 {automation.conditions.length > 0 && (
177 <>
178 , <span className="text-fg">if</span> {automation.conditions.join(" and ")}
179 </>
180 )}
181 : {automation.steps.join(", then ")}.
182 </p>
183 )}
184 {automation.lastRun && (
185 <p className="mt-2 flex items-center gap-1.5 text-xs text-faint">
186 <RunStatus run={automation.lastRun} />
187 Last run {automation.lastRun.status} <TimeAgo at={automation.lastRun.startedAt} />
188 {automation.lastRun.reason && ` · ${automation.lastRun.reason}`}
189 </p>
190 )}
191 </div>
192 {member && !automation.error && (
193 <div className="flex shrink-0 items-center gap-2">
194 <Form method="post" className="flex items-center gap-1.5">
195 <input type="hidden" name="id" value={automation.id} />
196 <input
197 name="number"
198 inputMode="numeric"
199 placeholder="#"
200 aria-label="Issue or pull request to run it on"
201 className="w-14 rounded-md border border-line bg-bg px-2 py-1.5 text-center font-mono text-xs outline-none focus:border-accent-dim"
202 />
203 <Button type="submit" variant="quiet" disabled={busy} name="intent" value="run">
204 <Play size={13} />
205 Run now
206 </Button>
207 </Form>
208 <Form method="post">
209 <input type="hidden" name="intent" value="toggle" />
210 <input type="hidden" name="id" value={automation.id} />
211 <input type="hidden" name="enabled" value={automation.enabled ? "false" : "true"} />
212 <Button type="submit" variant="quiet" disabled={busy}>
213 {automation.enabled ? "Turn off" : "Turn on"}
214 </Button>
215 </Form>
216 </div>
217 )}
218 </div>
219 </li>
220 );
221}
222
223function Templates({ repo }: { repo: string }) {
224 return (
225 <section>
226 <h3 className="text-sm font-medium">Start from one of these</h3>
227 <p className="mt-1 text-sm text-muted">
228 Add the file to <code className="text-fg">.g1t/automations/</code> on {repo}'s default branch. It is read the moment
229 you push.
230 </p>
231 <div className="mt-4 grid gap-3 lg:grid-cols-2">
232 {TEMPLATES.map((template) => (
233 <div key={template.file} className="rounded-xl border border-line bg-surface p-4">
234 <p className="font-medium">{template.title}</p>
235 <p className="mt-1 text-xs text-muted">{template.about}</p>
236 <p className="mt-3 font-mono text-xs text-faint">.g1t/automations/{template.file}</p>
237 <pre className="mt-1.5 rounded-lg bg-bg p-3 font-mono text-xs leading-relaxed break-words whitespace-pre-wrap ring-1 ring-line">
238 <code>{template.yaml}</code>
239 </pre>
240 </div>
241 ))}
242 </div>
243 </section>
244 );
245}
246
247export default function Automations({ loaderData, actionData, params }: Route.ComponentProps) {
248 const { automations: list, runs, member } = loaderData;
249 const base = `/${params.owner}/${params.repo}`;
250 const ran = actionData && "ran" in actionData ? actionData.ran : null;
251 return (
252 <div className="max-w-5xl space-y-10">
253 <header className="flex flex-wrap items-start justify-between gap-4">
254 <div>
255 <h2 className="flex items-center gap-2 text-xl font-semibold tracking-tight">
256 <Zap size={18} className="text-accent" />
257 Automations
258 </h2>
259 <p className="mt-1 max-w-2xl text-sm text-muted">
260 Rules in <code className="text-fg">.g1t/automations</code> that act when something happens: comment, label,
261 put an agent on it, tell the agent working on it, open or close issues, post to chat.
262 </p>
263 </div>
264 <a href="https://docs.g1t.sh/guides/automations/" className="text-sm text-muted underline underline-offset-4 hover:text-fg">
265 How automations work
266 </a>
267 </header>
268
269 {ran && (
270 <p className="text-sm text-muted">
271 Ran <span className="text-fg">{ran.name}</span>: {ran.status}
272 {ran.reason ? `. ${ran.reason}` : "."}
273 </p>
274 )}
275 <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>
276
277 {list.length === 0 ? (
278 <EmptyState title="No automations yet">
279 Commit a file to <code>.g1t/automations/</code> and it shows up here.
280 </EmptyState>
281 ) : (
282 <ul className="overflow-hidden rounded-xl border border-line bg-surface">
283 {list.map((automation) => (
284 <AutomationCard key={automation.id} automation={automation} base={base} member={member} />
285 ))}
286 </ul>
287 )}
288
289 {runs.length > 0 && (
290 <section>
291 <h3 className="mb-3 text-sm font-medium">Recent runs</h3>
292 <div className="overflow-hidden rounded-xl border border-line bg-surface">
293 {runs.map((run) => (
294 <RunRow key={run.id} run={run} base={base} />
295 ))}
296 </div>
297 </section>
298 )}
299
300 <Templates repo={`${params.owner}/${params.repo}`} />
301 </div>
302 );
303}