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

123 lines5,628 bytesCodeBlame
1import { env } from "cloudflare:workers";
2import { AtSign, Tag } from "lucide-react";
3import { Form } 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 { ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
11import { work } from "../../lib/services.server";
12import { instrumented } from "../../lib/perf.server";
13import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server";
14import { requireCapability, requireInsider } from "../../lib/access.server";
15
16const mentions = mentionsClient(instrumented("work", env.WORK));
17
18export function meta({ params, ...args }: Route.MetaArgs) {
19 return page(args, { title: `Agent settings · ${params.owner}/${params.repo} · g1t` });
20}
21
22export async function loader({ params, context }: Route.LoaderArgs) {
23 const viewer = getViewer(context);
24 // Maintain and up; to anyone without a role here the page does not exist.
25 await requireInsider(context, params, "manage_settings");
26 const path = { namespace: params.owner, name: params.repo };
27 const [rules, labels] = await Promise.all([mentions.getAgentRules(path, viewer), work.listLabels(path, viewer)]);
28 return { rules: unwrap(rules), labels: labels.ok ? labels.value : [] };
29}
30
31export async function action({ request, params, context }: Route.ActionArgs) {
32 assertSameOrigin(request);
33 const user = requireUser(context, request);
34 await requireCapability(context, params, "manage_settings");
35 const form = await request.formData();
36 const path = { namespace: params.owner, name: params.repo };
37 const label = form.get("intent") === "off" ? null : String(form.get("label") ?? "").trim() || null;
38 const saved = await mentions.setAgentRules(user, path, { label });
39 if (!saved.ok) return { error: saved.error.message, notice: null };
40 return {
41 error: null,
42 notice: saved.value.label
43 ? `Issues labelled ${saved.value.label} now go to g1t.`
44 : "Labels no longer put g1t to work.",
45 };
46}
47
48export default function AgentSettings({ loaderData, actionData, params }: Route.ComponentProps) {
49 const { rules, labels } = loaderData;
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 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 {/* Keyed to the saved rule, so turning it off empties the box. */}
73 <Input key={rules.label ?? ""} name="label" list="known-labels" defaultValue={rules.label ?? ""} placeholder="agent" maxLength={40} />
74 </Field>
75 <datalist id="known-labels">
76 {labels.map((label) => (
77 <option key={label} value={label} />
78 ))}
79 </datalist>
80 <div className="flex flex-wrap items-center gap-3">
81 <SubmitButton name="intent" value="save" pending="Saving…">
82 Save
83 </SubmitButton>
84 {rules.label && (
85 <SubmitButton variant="quiet" name="intent" value="off" pending="Turning off…">
86 Turn off
87 </SubmitButton>
88 )}
89 {rules.updatedBy && rules.updatedAt && (
90 <span className="text-xs text-faint">
91 Changed by {rules.updatedBy} <TimeAgo at={rules.updatedAt} />
92 </span>
93 )}
94 </div>
95 </Form>
96 </section>
97
98 <section className="mt-8 grid gap-x-10 gap-y-4 border-t border-line pt-8 lg:grid-cols-[16rem_1fr]">
99 <div>
100 <h2 className="flex items-center gap-2 font-medium">
101 <AtSign size={15} className="text-muted" />
102 Mentions
103 </h2>
104 <p className="mt-1 text-sm text-muted">Always on, for members of the workspace.</p>
105 </div>
106 <ul className="space-y-2 text-sm text-muted">
107 <li>
108 <span className="font-mono text-fg">{AGENT_HANDLE} take this</span> on an issue assigns it to g1t. A
109 question gets an answer in the thread instead.
110 </li>
111 <li>
112 On a pull request g1t made, a request sends it back to make the change. On any pull request,{" "}
113 <span className="font-mono text-fg">{AGENT_HANDLE} review</span> starts a review and a question is answered.
114 </li>
115 <li>
116 Mentions in code, in quotes or from people outside the workspace start nothing. Each comment starts one run at
117 most, and g1t replies saying what it did, or why it could not.
118 </li>
119 </ul>
120 </section>
121 </div>
122 );
123}