flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/runner/src/mentions.ts

177 lines8,237 bytesCodeBlame

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.

Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1/**
2 * What g1t does when a comment mentions `@g1t-agent`: 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-agent
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-agent 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/** What a mention leads to. */
15export type MentionPlan =
16 | { kind: "not_member" }
17 | { kind: "closed"; reason: string }
18 /** Assign the issue to g1t-agent: it opens a pull request and makes the change. */
19 | { kind: "assign" }
20 /** Answer in the thread, reading the code, changing nothing. */
21 | { kind: "answer" }
22 /** Send g1t-agent back to the pull request it made, to address the comment. */
23 | { kind: "revise" }
24 /** Pass the comment to g1t-agent while it is still making the change. */
25 | { kind: "message" }
26 | { kind: "review" };
27
28export function planMention(job: MentionJob): MentionPlan {
29 if (!job.member) return { kind: "not_member" };
30 const { pull, intent } = job;
31 if (!pull) {
32 if (intent !== "work") return { kind: "answer" };
33 if (job.issueOpen === false) return { kind: "closed", reason: "This issue is closed, so there is nothing to start on." };
34 if (job.workingPull != null) {
35 return { kind: "closed", reason: `I am already working on this in #${job.workingPull}. Mention me there to change what I am doing.` };
36 }
37 return { kind: "assign" };
38 }
39 const done = pull.status === "merged" || pull.status === "closed";
40 if (intent === "question") return { kind: "answer" };
41 if (done) return { kind: "closed", reason: `This pull request is already ${pull.status}.` };
42 if (intent === "review") return { kind: "review" };
43 if (pull.agentAuthored) return pull.status === "draft" ? { kind: "message" } : { kind: "revise" };
44 // Someone else's change is theirs to make: say what to change instead.
45 return { kind: "answer" };
46}
47
48/** What `handleMention` needs from the runner. */
49export interface MentionPorts {
50 mentions: MentionsApi;
51 /** Why `actor` cannot put g1t agents to work in `repo` now, or null. */
52 refusal(actor: User, repo: RepoPath): Promise<string | null>;
53 assign(job: MentionJob): Promise<Result<Pull>>;
54 revise(job: LifecycleJob, startedBy: string): Promise<void>;
55 review(job: MentionJob): Promise<Result<boolean>>;
56 answer(job: MentionJob): Promise<Result<true>>;
57 message(job: MentionJob): Promise<Result<unknown>>;
58 /** Records a mention that started nothing as a failed agent run, so it shows with the others. */
59 record(job: MentionJob, why: string): Promise<void>;
60}
61
62function reason(error: unknown): string {
63 return error instanceof Error ? error.message : String(error);
64}
65
66/** Acts on one mention, taken from the work service. Never throws. */
67export async function handleMention(job: MentionJob, ports: MentionPorts): Promise<MentionPlan> {
68 const plan = planMention(job);
69 const who = `@${job.actor.username}`;
70 const reply = (text: string) => ports.mentions.replyMention(job.commentId, text).catch(() => false);
71 const refuse = async (why: string) => {
72 await ports.record(job, why).catch(() => undefined);
73 await reply(`${who}, I could not start on this: ${why}`);
74 };
75 try {
76 if (plan.kind === "not_member") {
77 await reply(
78 `Thanks for the mention, ${who}. Only members of the ${job.repo.namespace} workspace can put g1t-agent to work here, so I have left this for them.`,
79 );
80 return plan;
81 }
82 if (plan.kind === "closed") {
83 await reply(`Thanks, ${who}. ${plan.reason}`);
84 return plan;
85 }
86 const refused = await ports.refusal(job.actor, job.repo);
87 if (refused) {
88 await refuse(refused);
89 return plan;
90 }
91 switch (plan.kind) {
92 case "assign": {
93 const started = await ports.assign(job);
94 if (!started.ok) await refuse(started.error.message);
95 else await reply(`On it, ${who}: I opened #${started.value.number} and will mark it ready for review when the change is made.`);
96 break;
97 }
98 case "revise": {
99 const revision = await ports.mentions.mentionRevision(job.commentId);
100 if (!revision.ok) {
101 // A step is already under way: what was said reaches it as a message.
102 if (revision.error.code === "conflict") {
103 const sent = await ports.message(job);
104 if (sent.ok) {
105 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.`);
106 break;
107 }
108 }
109 await refuse(revision.error.message);
110 break;
111 }
112 await ports.revise(revision.value, job.actor.username);
113 await reply(`Going back to this, ${who}. I will push the change here when it is made.`);
114 break;
115 }
116 case "message": {
117 const sent = await ports.message(job);
118 if (!sent.ok) await refuse(sent.error.message);
119 else await reply(`${who}, I am still making this change, so I have passed your comment on: I read it at my next step.`);
120 break;
121 }
122 case "review": {
123 const started = await ports.review(job);
124 if (!started.ok) await refuse(started.error.message);
125 else await reply(`Reviewing this now, ${who}. The review will be posted here.`);
126 break;
127 }
128 case "answer": {
129 // The answer is the reply, posted by the run itself.
130 const started = await ports.answer(job);
131 if (!started.ok) await refuse(started.error.message);
132 break;
133 }
134 }
135 } catch (error) {
136 await refuse(reason(error));
137 }
138 return plan;
139}
140
141/** Longest the conversation before a question is passed on. */
142const MAX_THREAD_CHARS = 6000;
143
144/** A thread's comments up to and including `commentId`, oldest first, for an agent answering in it. */
145export function describeThread(comments: Comment[], commentId: string): string | null {
146 const upTo = comments.findIndex((comment) => comment.id === commentId);
147 const said = (upTo < 0 ? comments : comments.slice(0, upTo))
148 .filter((comment) => comment.kind !== "event" && comment.body.trim())
149 .map((comment) => {
150 const where = comment.path ? ` on ${comment.path}${comment.line ? ` line ${comment.line}` : ""}` : "";
151 return `- ${comment.author.username}${where}: ${comment.body.trim()}`;
152 });
153 if (said.length === 0) return null;
154 const text = said.join("\n");
155 return text.length > MAX_THREAD_CHARS ? `…${text.slice(-MAX_THREAD_CHARS)}` : text;
156}
157
158/** What the agent is told when asked something in a comment. */
159export function buildMentionPrompt(job: MentionJob, context: { title: string; body: string; thread: string | null }): string {
160 const where = job.pull
161 ? `pull request #${job.number}, checked out at its head`
162 : `issue #${job.number}, with the repository checked out at its default branch, ${job.defaultBranch}`;
163 const parts = [
164 `You are g1t-agent, a coding agent. ${job.actor.username} mentioned you in a comment on ${where}. The repository is in the current directory. Answer what they asked.`,
165 `${job.pull ? "Pull request" : "Issue"} #${job.number}: ${context.title}`,
166 context.body,
167 context.thread && `The conversation so far, oldest first:\n\n${context.thread}`,
168 `What ${job.actor.username} wrote, mentioning you:\n\n${job.body.trim()}`,
169 job.pull && !job.pull.agentAuthored
170 ? "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."
171 : null,
172 job.pull
173 ? "Do not change any files."
174 : "Do not change any files. If answering properly needs work done, say so, and say that mentioning @g1t-agent with a request (such as \"@g1t-agent take this\") starts it.",
175 ];
176 return parts.filter(Boolean).join("\n\n");
177}