| 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> |
| 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 | |
| 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 | } |