g1t/apps/web/app/routes/repo/settings-agents.tsx

122 lines5,539 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { AtSign, Tag } from "lucide-react";
3import { Form, useNavigation } from "react-router";
4
5import { AGENT_HANDLE, mentionsClient } from "@g1t/contracts";
6
7import type { Route } from "./+types/settings-agents";
8import { page } from "../../lib/meta";
9import { RepoSettingsHeading } from "../../components/repo-settings-heading";
10import { Button, ErrorText, Field, Input, TimeAgo } from "../../components/ui";
11import { work } from "../../lib/services.server";
12import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
13import { requireCapability, requireInsider } from "../../lib/access.server";
14
15const mentions = mentionsClient(env.WORK);
16
17export function meta({ params, ...args }: Route.MetaArgs) {
18 return page(args, { title: `Agent settings · ${params.owner}/${params.repo} · g1t` });
19}
20
21export async function loader({ params, context }: Route.LoaderArgs) {
22 const viewer = getViewer(context);
23 // Maintain and up; to anyone without a role here the page does not exist.
24 await requireInsider(context, params, "manage_settings");
25 const path = { namespace: params.owner, name: params.repo };
26 const [rules, labels] = await Promise.all([mentions.getAgentRules(path, viewer), work.listLabels(path, viewer)]);
27 return { rules: unwrap(rules), labels: labels.ok ? labels.value : [] };
28}
29
30export async function action({ request, params, context }: Route.ActionArgs) {
31 assertSameOrigin(request);
32 const user = requireUser(context, request);
33 await requireCapability(context, params, "manage_settings");
34 const form = await request.formData();
35 const path = { namespace: params.owner, name: params.repo };
36 const label = form.get("intent") === "off" ? null : String(form.get("label") ?? "").trim() || null;
37 const saved = await mentions.setAgentRules(user, path, { label });
38 if (!saved.ok) return { error: saved.error.message, notice: null };
39 return {
40 error: null,
41 notice: saved.value.label
42 ? `Issues labelled ${saved.value.label} now go to g1t-agent.`
43 : "Labels no longer put g1t-agent to work.",
44 };
45}
46
47export default function AgentSettings({ loaderData, actionData, params }: Route.ComponentProps) {
48 const { rules, labels } = loaderData;
49 const busy = useNavigation().state === "submitting";
50 const base = `/${params.owner}/${params.repo}`;
51 return (
52 <div className="max-w-4xl">
53 <RepoSettingsHeading base={base} />
54 <div className="min-h-6">
55 {actionData?.notice && <p className="text-sm text-accent">{actionData.notice}</p>}
56 <ErrorText>{actionData?.error ?? null}</ErrorText>
57 </div>
58
59 <section className="grid gap-x-10 gap-y-4 lg:grid-cols-[16rem_1fr]">
60 <div>
61 <h2 className="flex items-center gap-2 font-medium">
62 <Tag size={15} className="text-muted" />
63 Label rule
64 </h2>
65 <p className="mt-1 text-sm text-muted">
66 When a member gives an issue this label, g1t-agent takes it, as if they had assigned it. It starts as soon as
67 the project has room for another agent and nothing the issue depends on is still open.
68 </p>
69 </div>
70 <Form method="post" className="space-y-3 rounded-xl border border-line bg-surface p-4">
71 <Field label="Label" hint="An issue that already has it is not affected; adding it again is.">
72 <Input name="label" list="known-labels" defaultValue={rules.label ?? ""} placeholder="agent" maxLength={40} />
73 </Field>
74 <datalist id="known-labels">
75 {labels.map((label) => (
76 <option key={label} value={label} />
77 ))}
78 </datalist>
79 <div className="flex flex-wrap items-center gap-3">
80 <Button type="submit" name="intent" value="save" disabled={busy}>
81 Save
82 </Button>
83 {rules.label && (
84 <Button type="submit" variant="quiet" name="intent" value="off" disabled={busy}>
85 Turn off
86 </Button>
87 )}
88 {rules.updatedBy && rules.updatedAt && (
89 <span className="text-xs text-faint">
90 Changed by {rules.updatedBy} <TimeAgo at={rules.updatedAt} />
91 </span>
92 )}
93 </div>
94 </Form>
95 </section>
96
97 <section className="mt-8 grid gap-x-10 gap-y-4 border-t border-line pt-8 lg:grid-cols-[16rem_1fr]">
98 <div>
99 <h2 className="flex items-center gap-2 font-medium">
100 <AtSign size={15} className="text-muted" />
101 Mentions
102 </h2>
103 <p className="mt-1 text-sm text-muted">Always on, for members of the workspace.</p>
104 </div>
105 <ul className="space-y-2 text-sm text-muted">
106 <li>
107 <span className="font-mono text-fg">{AGENT_HANDLE} take this</span> on an issue assigns it to g1t-agent. A
108 question gets an answer in the thread instead.
109 </li>
110 <li>
111 On a pull request g1t-agent made, a request sends it back to make the change. On any pull request,{" "}
112 <span className="font-mono text-fg">{AGENT_HANDLE} review</span> starts a review and a question is answered.
113 </li>
114 <li>
115 Mentions in code, in quotes or from people outside the workspace start nothing. Each comment starts one run at
116 most, and g1t-agent replies saying what it did, or why it could not.
117 </li>
118 </ul>
119 </section>
120 </div>
121 );
122}