Skip to content
204 linesCodeBlameRaw
1/**
2 * What g1t does when a comment mentions `@g1t`: what it is asked,
3 * through the flows that already exist. On an issue, a request assigns it
4 * and a question is answered in the thread. On a pull request g1t
5 * made, a request sends it back to revise, with the comment as what to
6 * address. On any pull request, a review request starts a review and a
7 * question is answered. Each mention is one run at most, and g1t says
8 * in the thread what it did, or why it did nothing.
9 *
10 * Only type imports, so it can be tested on its own.
11 */
12import type { Comment, LifecycleJob, MentionJob, MentionsApi, Pull, RepoPath, Result, User } from "@g1t/contracts";
13
14/**
15 * Why a workflow job's token (`G1T_TOKEN`) may not put g1t to work, or
16 * null for anyone else. A workflow that could summon an agent on a failing
17 * check would start one whose push runs the workflow again, without end.
18 */
19export function jobTokenRefusal(actor: User | null | undefined): string | null {
20 return actor?.token?.job
21 ? "A workflow job's token (G1T_TOKEN) cannot put g1t to work. A person, or a token of their own, can."
22 : null;
23}
24
25/** What a mention leads to. */
26export type MentionPlan =
27 | { kind: "not_member" }
28 | { kind: "closed"; reason: string }
29 /** Assign the issue to g1t: it opens a pull request and makes the change. */
30 | { kind: "assign" }
31 /** Answer in the thread, reading the code, changing nothing. */
32 | { kind: "answer" }
33 /** Send g1t back to the pull request it made, to address the comment. */
34 | { kind: "revise" }
35 /** Pass the comment to g1t while it is still making the change. */
36 | { kind: "message" }
37 | { kind: "review" };
38
39export function planMention(job: MentionJob): MentionPlan {
40 if (!job.member) return { kind: "not_member" };
41 const { pull, intent } = job;
42 if (!pull) {
43 if (intent !== "work") return { kind: "answer" };
44 if (job.issueOpen === false) return { kind: "closed", reason: "This issue is closed, so there is nothing to start on." };
45 if (job.workingPull != null) {
46 return { kind: "closed", reason: `I am already working on this in #${job.workingPull}. Mention me there to change what I am doing.` };
47 }
48 return { kind: "assign" };
49 }
50 const done = pull.status === "merged" || pull.status === "closed";
51 if (intent === "question") return { kind: "answer" };
52 if (done) return { kind: "closed", reason: `This pull request is already ${pull.status}.` };
53 if (intent === "review") return { kind: "review" };
54 if (pull.agentAuthored) return pull.status === "draft" ? { kind: "message" } : { kind: "revise" };
55 // Someone else's change is theirs to make: say what to change instead.
56 return { kind: "answer" };
57}
58
59/** What `handleMention` needs from the runner. */
60export interface MentionPorts {
61 mentions: MentionsApi;
62 /** Why `actor` cannot put g1t agents to work in `repo` now, or null. */
63 refusal(actor: User, repo: RepoPath): Promise<string | null>;
64 assign(job: MentionJob): Promise<Result<Pull>>;
65 /**
66 * Sends g1t back to revise. Returns what to say instead when the
67 * revision waits for a free slot.
68 */
69 revise(job: LifecycleJob, startedBy: string): Promise<string | null | void>;
70 review(job: MentionJob): Promise<Result<boolean>>;
71 answer(job: MentionJob): Promise<Result<true>>;
72 message(job: MentionJob): Promise<Result<unknown>>;
73 /** Records a mention that started nothing as a failed agent run, so it shows with the others. */
74 record(job: MentionJob, why: string): Promise<void>;
75}
76
77/**
78 * How a run that waits for a free agent slot says so: `WAITING_PREFIX` in
79 * @g1t/contracts compute.ts, kept here so this file has no runtime imports.
80 */
81export const WAITING = "Waiting for a free slot";
82
83function reason(error: unknown): string {
84 return error instanceof Error ? error.message : String(error);
85}
86
87/** Acts on one mention, taken from the work service. Never throws. */
88export async function handleMention(job: MentionJob, ports: MentionPorts): Promise<MentionPlan> {
89 const plan = planMention(job);
90 const who = `@${job.actor.username}`;
91 const reply = (text: string) => ports.mentions.replyMention(job.commentId, text).catch(() => false);
92 const refuse = async (why: string) => {
93 // Not refused, only waiting: said as it is, and not recorded as a failure.
94 if (why.startsWith(WAITING)) {
95 await reply(`${who}, ${why}`);
96 return;
97 }
98 await ports.record(job, why).catch(() => undefined);
99 await reply(`${who}, I could not start on this: ${why}`);
100 };
101 try {
102 if (plan.kind === "not_member") {
103 await reply(
104 `Thanks for the mention, ${who}. Putting g1t to work needs the Write role on ${job.repo.namespace}/${job.repo.name}, so I have left this for someone who has it.`,
105 );
106 return plan;
107 }
108 if (plan.kind === "closed") {
109 await reply(`Thanks, ${who}. ${plan.reason}`);
110 return plan;
111 }
112 const refused = await ports.refusal(job.actor, job.repo);
113 if (refused) {
114 await refuse(refused);
115 return plan;
116 }
117 switch (plan.kind) {
118 case "assign": {
119 const started = await ports.assign(job);
120 if (!started.ok) await refuse(started.error.message);
121 else await reply(`On it, ${who}: I opened #${started.value.number} and will mark it ready for review when the change is made.`);
122 break;
123 }
124 case "revise": {
125 const revision = await ports.mentions.mentionRevision(job.commentId);
126 if (!revision.ok) {
127 // A step is already under way: what was said reaches it as a message.
128 if (revision.error.code === "conflict") {
129 const sent = await ports.message(job);
130 if (sent.ok) {
131 await reply(`${who}, I am already working on this pull request, so I have passed your comment on: I read it at my next step.`);
132 break;
133 }
134 }
135 await refuse(revision.error.message);
136 break;
137 }
138 const waiting = await ports.revise(revision.value, job.actor.username);
139 if (waiting) await reply(`${who}, ${waiting}`);
140 else await reply(`Going back to this, ${who}. I will push the change here when it is made.`);
141 break;
142 }
143 case "message": {
144 const sent = await ports.message(job);
145 if (!sent.ok) await refuse(sent.error.message);
146 else await reply(`${who}, I am still making this change, so I have passed your comment on: I read it at my next step.`);
147 break;
148 }
149 case "review": {
150 const started = await ports.review(job);
151 if (!started.ok) await refuse(started.error.message);
152 else await reply(`Reviewing this now, ${who}. The review will be posted here.`);
153 break;
154 }
155 case "answer": {
156 // The answer is the reply, posted by the run itself.
157 const started = await ports.answer(job);
158 if (!started.ok) await refuse(started.error.message);
159 break;
160 }
161 }
162 } catch (error) {
163 await refuse(reason(error));
164 }
165 return plan;
166}
167
168/** Longest the conversation before a question is passed on. */
169const MAX_THREAD_CHARS = 6000;
170
171/** A thread's comments up to and including `commentId`, oldest first, for an agent answering in it. */
172export function describeThread(comments: Comment[], commentId: string): string | null {
173 const upTo = comments.findIndex((comment) => comment.id === commentId);
174 const said = (upTo < 0 ? comments : comments.slice(0, upTo))
175 .filter((comment) => comment.kind !== "event" && comment.body.trim())
176 .map((comment) => {
177 const where = comment.path ? ` on ${comment.path}${comment.line ? ` line ${comment.line}` : ""}` : "";
178 return `- ${comment.author.username}${where}: ${comment.body.trim()}`;
179 });
180 if (said.length === 0) return null;
181 const text = said.join("\n");
182 return text.length > MAX_THREAD_CHARS ? `…${text.slice(-MAX_THREAD_CHARS)}` : text;
183}
184
185/** What the agent is told when asked something in a comment. */
186export function buildMentionPrompt(job: MentionJob, context: { title: string; body: string; thread: string | null }): string {
187 const where = job.pull
188 ? `pull request #${job.number}, checked out at its head`
189 : `issue #${job.number}, with the repository checked out at its default branch, ${job.defaultBranch}`;
190 const parts = [
191 `You are g1t, a coding agent. ${job.actor.username} mentioned you in a comment on ${where}. The repository is in the current directory. Answer what they asked.`,
192 `${job.pull ? "Pull request" : "Issue"} #${job.number}: ${context.title}`,
193 context.body,
194 context.thread && `The conversation so far, oldest first:\n\n${context.thread}`,
195 `What ${job.actor.username} wrote, mentioning you:\n\n${job.body.trim()}`,
196 job.pull && !job.pull.agentAuthored
197 ? "This change is someone else's, and you cannot push to it. If they asked for a change, say exactly what you would change and where, with a short patch in a code block where that helps."
198 : null,
199 job.pull
200 ? "Do not change any files."
201 : "Do not change any files. If answering properly needs work done, say so, and say that mentioning @g1t with a request (such as \"@g1t take this\") starts it.",
202 ];
203 return parts.filter(Boolean).join("\n\n");
204}