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

193 lines8,856 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>>;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look54 /**
55 * Sends g1t-agent back to revise. Returns what to say instead when the
56 * revision waits for a free slot.
57 */
58 revise(job: LifecycleJob, startedBy: string): Promise<string | null | void>;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API59 review(job: MentionJob): Promise<Result<boolean>>;
60 answer(job: MentionJob): Promise<Result<true>>;
61 message(job: MentionJob): Promise<Result<unknown>>;
62 /** Records a mention that started nothing as a failed agent run, so it shows with the others. */
63 record(job: MentionJob, why: string): Promise<void>;
64}
65
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look66/**
67 * How a run that waits for a free agent slot says so: `WAITING_PREFIX` in
68 * @g1t/contracts compute.ts, kept here so this file has no runtime imports.
69 */
70export const WAITING = "Waiting for a free slot";
71
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API72function reason(error: unknown): string {
73 return error instanceof Error ? error.message : String(error);
74}
75
76/** Acts on one mention, taken from the work service. Never throws. */
77export async function handleMention(job: MentionJob, ports: MentionPorts): Promise<MentionPlan> {
78 const plan = planMention(job);
79 const who = `@${job.actor.username}`;
80 const reply = (text: string) => ports.mentions.replyMention(job.commentId, text).catch(() => false);
81 const refuse = async (why: string) => {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look82 // Not refused, only waiting: said as it is, and not recorded as a failure.
83 if (why.startsWith(WAITING)) {
84 await reply(`${who}, ${why}`);
85 return;
86 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API87 await ports.record(job, why).catch(() => undefined);
88 await reply(`${who}, I could not start on this: ${why}`);
89 };
90 try {
91 if (plan.kind === "not_member") {
92 await reply(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look93 `Thanks for the mention, ${who}. Putting g1t-agent to work needs the Write role on ${job.repo.namespace}/${job.repo.name}, so I have left this for someone who has it.`,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API94 );
95 return plan;
96 }
97 if (plan.kind === "closed") {
98 await reply(`Thanks, ${who}. ${plan.reason}`);
99 return plan;
100 }
101 const refused = await ports.refusal(job.actor, job.repo);
102 if (refused) {
103 await refuse(refused);
104 return plan;
105 }
106 switch (plan.kind) {
107 case "assign": {
108 const started = await ports.assign(job);
109 if (!started.ok) await refuse(started.error.message);
110 else await reply(`On it, ${who}: I opened #${started.value.number} and will mark it ready for review when the change is made.`);
111 break;
112 }
113 case "revise": {
114 const revision = await ports.mentions.mentionRevision(job.commentId);
115 if (!revision.ok) {
116 // A step is already under way: what was said reaches it as a message.
117 if (revision.error.code === "conflict") {
118 const sent = await ports.message(job);
119 if (sent.ok) {
120 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.`);
121 break;
122 }
123 }
124 await refuse(revision.error.message);
125 break;
126 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look127 const waiting = await ports.revise(revision.value, job.actor.username);
128 if (waiting) await reply(`${who}, ${waiting}`);
129 else await reply(`Going back to this, ${who}. I will push the change here when it is made.`);
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API130 break;
131 }
132 case "message": {
133 const sent = await ports.message(job);
134 if (!sent.ok) await refuse(sent.error.message);
135 else await reply(`${who}, I am still making this change, so I have passed your comment on: I read it at my next step.`);
136 break;
137 }
138 case "review": {
139 const started = await ports.review(job);
140 if (!started.ok) await refuse(started.error.message);
141 else await reply(`Reviewing this now, ${who}. The review will be posted here.`);
142 break;
143 }
144 case "answer": {
145 // The answer is the reply, posted by the run itself.
146 const started = await ports.answer(job);
147 if (!started.ok) await refuse(started.error.message);
148 break;
149 }
150 }
151 } catch (error) {
152 await refuse(reason(error));
153 }
154 return plan;
155}
156
157/** Longest the conversation before a question is passed on. */
158const MAX_THREAD_CHARS = 6000;
159
160/** A thread's comments up to and including `commentId`, oldest first, for an agent answering in it. */
161export function describeThread(comments: Comment[], commentId: string): string | null {
162 const upTo = comments.findIndex((comment) => comment.id === commentId);
163 const said = (upTo < 0 ? comments : comments.slice(0, upTo))
164 .filter((comment) => comment.kind !== "event" && comment.body.trim())
165 .map((comment) => {
166 const where = comment.path ? ` on ${comment.path}${comment.line ? ` line ${comment.line}` : ""}` : "";
167 return `- ${comment.author.username}${where}: ${comment.body.trim()}`;
168 });
169 if (said.length === 0) return null;
170 const text = said.join("\n");
171 return text.length > MAX_THREAD_CHARS ? `…${text.slice(-MAX_THREAD_CHARS)}` : text;
172}
173
174/** What the agent is told when asked something in a comment. */
175export function buildMentionPrompt(job: MentionJob, context: { title: string; body: string; thread: string | null }): string {
176 const where = job.pull
177 ? `pull request #${job.number}, checked out at its head`
178 : `issue #${job.number}, with the repository checked out at its default branch, ${job.defaultBranch}`;
179 const parts = [
180 `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.`,
181 `${job.pull ? "Pull request" : "Issue"} #${job.number}: ${context.title}`,
182 context.body,
183 context.thread && `The conversation so far, oldest first:\n\n${context.thread}`,
184 `What ${job.actor.username} wrote, mentioning you:\n\n${job.body.trim()}`,
185 job.pull && !job.pull.agentAuthored
186 ? "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."
187 : null,
188 job.pull
189 ? "Do not change any files."
190 : "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.",
191 ];
192 return parts.filter(Boolean).join("\n\n");
193}