Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Automations: rules in .g1t/automations that act when something happens | 1 | import { CheckCircle2, ChevronRight, CircleSlash, FileCode2, Play, XCircle, Zap } from "lucide-react"; |
| 2 | import { Form, Link, useNavigation } from "react-router"; | |
| 3 | ||
| 4 | import type { Automation, AutomationRun } from "@g1t/contracts"; | |
| 5 | ||
| 6 | import type { Route } from "./+types/automations"; | |
| 7 | import { Button, EmptyState, ErrorText, TimeAgo } from "../../components/ui"; | |
| 8 | import { automations } from "../../lib/services.server"; | |
| 9 | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 10 | ||
| 11 | export function meta({ params }: Route.MetaArgs) { | |
| 12 | return [{ title: `Automations · ${params.owner}/${params.repo} · g1t` }]; | |
| 13 | } | |
| 14 | ||
| 15 | export 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 | ||
| 26 | export 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. */ | |
| 42 | const 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 | |
| 48 | on: issue.opened | |
| 49 | if: | |
| 50 | labels: bug | |
| 51 | do: | |
| 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 | |
| 60 | on: checks.completed | |
| 61 | if: | |
| 62 | status: failed | |
| 63 | do: | |
| 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 | |
| 71 | on: pull.merged | |
| 72 | do: | |
| 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 | |
| 82 | on: | |
| 83 | schedule: "0 9 * * mon" | |
| 84 | do: | |
| 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 | ||
| 93 | function 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 | ||
| 99 | function 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 | ||
| 141 | function 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 | ||
| 223 | function 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> | |
| Automations: say plainly why a run was skipped to avoid a loop | 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"> |
| Automations: rules in .g1t/automations that act when something happens | 238 | <code>{template.yaml}</code> |
| 239 | </pre> | |
| 240 | </div> | |
| 241 | ))} | |
| 242 | </div> | |
| 243 | </section> | |
| 244 | ); | |
| 245 | } | |
| 246 | ||
| 247 | export 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 | } |