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/work/src/mentions.rs

892 lines32,151 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//! `@g1t-agent` in a comment, and the label rule.
2//!
3//! A comment that mentions `@g1t-agent` is recorded here as it is made,
4//! with who wrote it and what they seem to want. The runner hears the
5//! comment was made, takes the mention (once: a mention is one run at most)
6//! and does what it asks through the flows that already exist: assigning
7//! the issue, sending the author of a g1t pull request back, a review, or
8//! an answer in the thread. Whatever it does, or why it did nothing, is
9//! said back in the thread as `g1t-agent`.
10//!
11//! The label rule is a repository's "when an issue gets this label, give it
12//! to g1t-agent": the issue is queued for an agent, as a plan's issues are.
13
14use g1t_contracts::events::CommentCreated;
15use g1t_contracts::repos::{GetByIdArgs, PathByIdArgs, Repo, RepoPath};
16use g1t_contracts::time::rfc3339;
17use g1t_contracts::work::*;
18use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer, new_id};
19use g1t_kit::now_ms;
20use serde::{Deserialize, Serialize};
21use worker::Result;
22use worker::wasm_bindgen::JsValue;
23
24use crate::Work;
25use crate::lifecycle::{POLICY_ACTOR_ID, POLICY_ACTOR_NAME, made_by_g1t};
26use crate::reviews::{AGENT_ID, AGENT_NAME};
27use crate::rows::{NumberRow, ValueRow};
28
29/// How g1t's agent is mentioned. Matched without regard to case.
30pub(crate) const HANDLE: &str = "@g1t-agent";
31/// How long a revision asked for in a comment may take before another step can.
32const REVISION_MINUTES: u64 = 60;
33const MAX_REPLY_CHARS: usize = 20_000;
34const MAX_LABEL_CHARS: usize = 40;
35
36// --- Reading a comment -----------------------------------------------------
37
38/// What a comment says in its own words: fenced code blocks, code spans
39/// and quoted lines are left out, so that quoting or showing a mention
40/// does not make one.
41pub(crate) fn spoken(body: &str) -> String {
42 let mut out = String::new();
43 // The fence that opened the code block being skipped, if one is.
44 let mut fence: Option<String> = None;
45 for line in body.lines() {
46 let trimmed = line.trim_start();
47 if let Some(marker) = &fence {
48 if trimmed.starts_with(marker.as_str()) {
49 fence = None;
50 }
51 out.push('\n');
52 continue;
53 }
54 if let Some(first) = trimmed.chars().next()
55 && (first == '`' || first == '~')
56 {
57 let run = trimmed.chars().take_while(|c| *c == first).count();
58 if run >= 3 {
59 fence = Some(std::iter::repeat_n(first, run).collect());
60 out.push('\n');
61 continue;
62 }
63 }
64 if trimmed.starts_with('>') {
65 out.push('\n');
66 continue;
67 }
68 out.push_str(&without_code_spans(line));
69 out.push('\n');
70 }
71 out
72}
73
74/// A line with its code spans blanked: a run of backticks opens one, and
75/// the next run of the same length closes it. An unclosed run is text.
76fn without_code_spans(line: &str) -> String {
77 let chars: Vec<char> = line.chars().collect();
78 let run_at = |at: usize| chars[at..].iter().take_while(|c| **c == '`').count();
79 let mut out = String::new();
80 let mut i = 0;
81 while i < chars.len() {
82 if chars[i] != '`' {
83 out.push(chars[i]);
84 i += 1;
85 continue;
86 }
87 let open = run_at(i);
88 let mut j = i + open;
89 let mut closed = None;
90 while j < chars.len() {
91 if chars[j] == '`' {
92 let run = run_at(j);
93 if run == open {
94 closed = Some(j + run);
95 break;
96 }
97 j += run;
98 } else {
99 j += 1;
100 }
101 }
102 match closed {
103 Some(end) => {
104 out.push(' ');
105 i = end;
106 }
107 None => {
108 out.extend(std::iter::repeat_n('`', open));
109 i += open;
110 }
111 }
112 }
113 out
114}
115
116/// Where `text` mentions `@g1t-agent`, as byte ranges. Not in an email
117/// address or a longer name (`ops@g1t-agent.dev`, `@g1t-agents`).
118pub(crate) fn mentions_in(text: &str) -> Vec<(usize, usize)> {
119 let bytes = text.as_bytes();
120 let handle = HANDLE.as_bytes();
121 let mut found = Vec::new();
122 let mut at = 0;
123 while at + handle.len() <= bytes.len() {
124 if !bytes[at..at + handle.len()].eq_ignore_ascii_case(handle) {
125 at += 1;
126 continue;
127 }
128 let end = at + handle.len();
129 let before = text[..at].chars().next_back();
130 let mut after = text[end..].chars();
131 let starts_clean =
132 before.is_none_or(|c| !(c.is_alphanumeric() || "._%+-/\\@`=".contains(c)));
133 let ends_clean = match after.next() {
134 None => true,
135 Some(c) if c.is_alphanumeric() || c == '_' || c == '-' || c == '@' => false,
136 // The end of a sentence, or a domain: `@g1t-agent.dev`.
137 Some('.') => after.next().is_none_or(|c| !c.is_alphanumeric()),
138 Some(_) => true,
139 };
140 if starts_clean && ends_clean {
141 found.push((at, end));
142 at = end;
143 } else {
144 at += 1;
145 }
146 }
147 found
148}
149
150/// Whether a comment mentions `@g1t-agent` in its own words.
151pub(crate) fn mentions_agent(body: &str) -> bool {
152 !mentions_in(&spoken(body)).is_empty()
153}
154
155/// What someone who mentions `@g1t-agent` wants.
156#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
157#[serde(rename_all = "lowercase")]
158pub(crate) enum Intent {
159 /// Do something: take the issue, or change the pull request.
160 Work,
161 /// Answer in the thread, changing nothing.
162 Question,
163 /// Review the pull request.
164 Review,
165}
166
167impl Intent {
168 fn as_str(self) -> &'static str {
169 match self {
170 Intent::Work => "work",
171 Intent::Question => "question",
172 Intent::Review => "review",
173 }
174 }
175
176 fn parse(text: &str) -> Self {
177 match text {
178 "question" => Intent::Question,
179 "review" => Intent::Review,
180 _ => Intent::Work,
181 }
182 }
183}
184
185/// Words that start a request to do something.
186const WORK_VERBS: &[&str] = &[
187 "take", "fix", "implement", "do", "work", "handle", "pick", "start", "add", "update",
188 "change", "make", "refactor", "write", "remove", "delete", "rename", "address", "try",
189 "revise", "go", "build", "resolve", "move", "use", "rewrite", "split", "clean", "finish",
190 "apply", "drop", "bump", "upgrade", "rebase", "merge", "test",
191];
192/// Words that start a question.
193const QUESTION_WORDS: &[&str] = &[
194 "what", "why", "how", "where", "when", "which", "who", "whose", "whom", "is", "are", "was",
195 "were", "does", "did", "explain", "tell", "describe", "summarize", "summarise", "clarify",
196];
197/// Politeness before the request itself.
198const PREFIXES: &[&[&str]] = &[
199 &["please"],
200 &["pls"],
201 &["hey"],
202 &["hi"],
203 &["can", "you"],
204 &["could", "you"],
205 &["would", "you"],
206 &["will", "you"],
207];
208
209/// What a comment mentioning `@g1t-agent` asks for, read from what it says
210/// after the mention (or before, when nothing follows it).
211pub(crate) fn intent(body: &str) -> Intent {
212 let spoken = spoken(body);
213 let found = mentions_in(&spoken);
214 let request = match (found.first(), found.last()) {
215 (Some(&(start, _)), Some(&(_, end))) => {
216 let after = spoken[end..].trim();
217 if after.is_empty() { spoken[..start].trim().to_owned() } else { after.to_owned() }
218 }
219 _ => spoken.trim().to_owned(),
220 };
221 // Other mentions of the agent in the request say nothing about it.
222 let mut request = request.to_lowercase();
223 for (start, end) in mentions_in(&request).into_iter().rev() {
224 request.replace_range(start..end, " ");
225 }
226 let words: Vec<&str> = request
227 .split(|c: char| !(c.is_alphanumeric() || c == '\''))
228 .filter(|word| !word.is_empty())
229 .collect();
230 let mut rest: &[&str] = &words;
231 'strip: loop {
232 for prefix in PREFIXES {
233 if rest.starts_with(prefix) {
234 rest = &rest[prefix.len()..];
235 continue 'strip;
236 }
237 }
238 break;
239 }
240 if rest.iter().take(4).any(|word| *word == "review" || *word == "re-review") {
241 return Intent::Review;
242 }
243 let first = rest.first().copied().unwrap_or_default();
244 if WORK_VERBS.contains(&first) {
245 return Intent::Work;
246 }
247 // The question is the sentence the mention starts.
248 let sentence = request.split_inclusive(['.', '!', '\n']).next().unwrap_or_default();
249 if QUESTION_WORDS.contains(&first)
250 || words.first().is_some_and(|word| QUESTION_WORDS.contains(word))
251 || sentence.trim_end().ends_with('?')
252 || request.trim_end().ends_with('?')
253 {
254 return Intent::Question;
255 }
256 Intent::Work
257}
258
259// --- Contracts -------------------------------------------------------------
260// Mirrored in TypeScript by `packages/contracts/src/mentions.ts`.
261
262/// `take_mention`: claims the mention a comment made, once. Null when the
263/// comment made none, or it was already taken. Returns `Option<MentionJob>`.
264#[derive(Debug, Deserialize)]
265#[serde(rename_all = "camelCase")]
266pub(crate) struct TakeMentionArgs {
267 comment_id: String,
268}
269
270/// What the runner needs to act on a mention.
271#[derive(Debug, Serialize)]
272#[serde(rename_all = "camelCase")]
273pub(crate) struct MentionJob {
274 comment_id: String,
275 /// Who wrote it, with the memberships they had then.
276 actor: User,
277 repo: RepoPath,
278 number: u32,
279 /// The comment as written.
280 body: String,
281 intent: Intent,
282 /// Whether they belong to the repository's workspace.
283 member: bool,
284 default_branch: String,
285 /// Set when the comment is on an issue: whether it is still open.
286 issue_open: Option<bool>,
287 /// On an issue: the pull request g1t-agent is already working on for it, if any.
288 working_pull: Option<u32>,
289 /// Set when the comment is on a pull request.
290 pull: Option<MentionPull>,
291}
292
293#[derive(Debug, Serialize)]
294#[serde(rename_all = "camelCase")]
295pub(crate) struct MentionPull {
296 id: String,
297 status: PullStatus,
298 /// Made by g1t-agent, which g1t sees through.
299 agent_authored: bool,
300 /// Where its change is: its fork, or the repository itself.
301 source: RepoPath,
302 /// The head is a branch of the repository itself, not a fork.
303 in_repo: bool,
304 branch: Option<String>,
305 head_commit: Option<String>,
306 files: Vec<String>,
307}
308
309/// `mention_revision`: sends the author of a g1t pull request back to
310/// address a comment that mentioned it. Returns `Outcome<LifecycleJob>`.
311#[derive(Debug, Deserialize)]
312#[serde(rename_all = "camelCase")]
313pub(crate) struct MentionRevisionArgs {
314 comment_id: String,
315}
316
317/// `reply_mention`: g1t-agent's answer to a mention, in its thread.
318/// Returns `bool`: false when there was no such mention.
319#[derive(Debug, Deserialize)]
320#[serde(rename_all = "camelCase")]
321pub(crate) struct ReplyMentionArgs {
322 comment_id: String,
323 body: String,
324}
325
326/// A repository's rules for putting g1t-agent to work by itself.
327#[derive(Debug, Default, Serialize, Deserialize)]
328#[serde(rename_all = "camelCase")]
329pub(crate) struct AgentRules {
330 /// When an issue is given this label, g1t-agent takes it.
331 label: Option<String>,
332 updated_by: Option<String>,
333 updated_at: Option<String>,
334}
335
336/// `get_agent_rules`. Returns `Outcome<AgentRules>`.
337#[derive(Debug, Deserialize)]
338pub(crate) struct GetAgentRulesArgs {
339 repo: RepoPath,
340 viewer: Viewer,
341}
342
343/// `set_agent_rules`: members only. Returns `Outcome<AgentRules>`.
344#[derive(Debug, Deserialize)]
345pub(crate) struct SetAgentRulesArgs {
346 actor: User,
347 repo: RepoPath,
348 #[serde(default)]
349 label: Option<String>,
350}
351
352#[derive(Deserialize)]
353struct MentionRow {
354 comment_id: String,
355 repo_id: String,
356 number: u32,
357 pull_id: Option<String>,
358 actor: String,
359 body: String,
360 intent: String,
361 member: u8,
362}
363
364#[derive(Deserialize)]
365struct RulesRow {
366 label: Option<String>,
367 updated_by: String,
368 updated_at: String,
369}
370
371#[derive(Deserialize)]
372struct RevisionsRow {
373 revisions: u32,
374}
375
376/// A label as issues store them, or `None` for no rule.
377fn normalize_label(label: Option<&str>) -> std::result::Result<Option<String>, &'static str> {
378 let Some(label) = label.map(str::trim).filter(|label| !label.is_empty()) else {
379 return Ok(None);
380 };
381 if label.chars().count() > MAX_LABEL_CHARS {
382 return Err("A label is up to 40 characters.");
383 }
384 Ok(Some(label.to_lowercase()))
385}
386
387impl Work {
388 /// Records a comment's mention of `@g1t-agent`, if it makes one, for
389 /// the runner to take when it hears of the comment. Agents mentioning
390 /// themselves are not recorded, so no agent can set another to work.
391 pub(crate) async fn note_mention(
392 &self,
393 actor: &User,
394 repo: &Repo,
395 number: u32,
396 comment: &Comment,
397 pull_id: Option<&str>,
398 ) -> Result<()> {
399 if actor.kind == PrincipalKind::Agent
400 || actor.id == AGENT_ID
401 || !mentions_agent(&comment.body)
402 {
403 return Ok(());
404 }
405 let member = actor.verified
406 && (actor.is_member(&repo.namespace) || actor.is_member(&repo.namespace.to_lowercase()));
407 self.db
408 .prepare(
409 "INSERT OR IGNORE INTO agent_mentions
410 (comment_id, repo_id, number, pull_id, actor, body, intent, member, created_at)
411 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
412 )
413 .bind(&[
414 comment.id.as_str().into(),
415 repo.id.as_str().into(),
416 number.into(),
417 pull_id.map_or(JsValue::NULL, JsValue::from),
418 serde_json::to_string(actor)?.into(),
419 comment.body.as_str().into(),
420 intent(&comment.body).as_str().into(),
421 u32::from(member).into(),
422 comment.created_at.as_str().into(),
423 ])?
424 .run()
425 .await?;
426 Ok(())
427 }
428
429 pub(crate) async fn take_mention(&self, a: TakeMentionArgs) -> Result<Option<MentionJob>> {
430 let Some(row) = self
431 .db
432 .prepare(
433 "UPDATE agent_mentions SET status = 'taken', taken_at = ?
434 WHERE comment_id = ? AND status = 'pending'
435 RETURNING *",
436 )
437 .bind(&[rfc3339(now_ms()).into(), a.comment_id.as_str().into()])?
438 .first::<MentionRow>(None)
439 .await?
440 else {
441 return Ok(None);
442 };
443 let Ok(actor) = serde_json::from_str::<User>(&row.actor) else {
444 return Ok(None);
445 };
446 // Where the repository is now, as whoever commented: they could see it.
447 let repo: Outcome<Repo> = g1t_kit::call(
448 &self.repos,
449 "get_by_id",
450 &GetByIdArgs {
451 id: row.repo_id.clone(),
452 viewer: Some(actor.clone()),
453 },
454 )
455 .await?;
456 let Outcome::Ok(repo) = repo else {
457 return Ok(None);
458 };
459 let path = RepoPath {
460 namespace: repo.namespace.clone(),
461 name: repo.name.clone(),
462 };
463 let pull = match &row.pull_id {
464 Some(id) => self.pull_by_id(id).await?,
465 None => None,
466 };
467 let issue = match &pull {
468 Some(_) => None,
469 None => self.issue(&repo.id, row.number).await?,
470 };
471 let issue_open = pull.is_none().then(|| issue.as_ref().is_some_and(|issue| issue.state == State::Open));
472 let working_pull = match &issue {
473 Some(issue) => self
474 .db
475 .prepare(
476 "SELECT number AS n FROM pulls
477 WHERE issue_id = ? AND agent = ? AND status IN ('draft', 'open')
478 ORDER BY number DESC LIMIT 1",
479 )
480 .bind(&[issue.id.as_str().into(), AGENT_NAME.into()])?
481 .first::<NumberRow>(None)
482 .await?
483 .map(|row| row.n),
484 None => None,
485 };
486 Ok(Some(MentionJob {
487 comment_id: row.comment_id,
488 actor,
489 number: row.number,
490 body: row.body,
491 intent: Intent::parse(&row.intent),
492 member: row.member != 0,
493 default_branch: repo.default_branch.clone(),
494 issue_open,
495 working_pull,
496 pull: pull.map(|pull| MentionPull {
497 agent_authored: made_by_g1t(&pull),
498 source: pull.fork.clone().unwrap_or_else(|| path.clone()),
499 in_repo: pull.fork.is_none(),
500 files: pull.files.iter().map(|file| file.path.clone()).collect(),
501 id: pull.id,
502 status: pull.status,
503 branch: pull.branch,
504 head_commit: pull.head_commit,
505 }),
506 repo: path,
507 }))
508 }
509
510 pub(crate) async fn mention_revision(
511 &self,
512 a: MentionRevisionArgs,
513 ) -> Result<Outcome<LifecycleJob>> {
514 let Some(row) = self
515 .db
516 .prepare("SELECT * FROM agent_mentions WHERE comment_id = ? AND status = 'taken'")
517 .bind(&[a.comment_id.as_str().into()])?
518 .first::<MentionRow>(None)
519 .await?
520 else {
521 return Ok(Outcome::fail(FailureCode::NotFound, "No such mention."));
522 };
523 let actor: User = serde_json::from_str(&row.actor)?;
524 let Some(pull) = (match &row.pull_id {
525 Some(id) => self.pull_by_id(id).await?,
526 None => None,
527 }) else {
528 return Ok(Outcome::fail(FailureCode::NotFound, "Pull request not found."));
529 };
530 if row.member == 0 || !made_by_g1t(&pull) {
531 return Ok(Outcome::fail(
532 FailureCode::Forbidden,
533 "Only a member can send g1t-agent back to a pull request it made.",
534 ));
535 }
536 match pull.status {
537 PullStatus::Open => {}
538 PullStatus::Draft => {
539 return Ok(Outcome::fail(
540 FailureCode::Conflict,
541 "g1t-agent is still making this change.",
542 ));
543 }
544 status => {
545 return Ok(Outcome::fail(
546 FailureCode::Conflict,
547 format!("This pull request is already {}.", serde_json::to_value(status)?.as_str().unwrap_or("closed")),
548 ));
549 }
550 }
551 let viewer = self.author_viewer(&pull).await?;
552 let repo: Outcome<Repo> = g1t_kit::call(
553 &self.repos,
554 "get_by_id",
555 &GetByIdArgs {
556 id: pull.repo_id.clone(),
557 viewer,
558 },
559 )
560 .await?;
561 let (Outcome::Ok(repo), Some(source)) = (repo, pull.fork.clone()) else {
562 return Ok(Outcome::fail(FailureCode::NotFound, "Pull request not found."));
563 };
564 // A person asking outranks a stop and the limit on revisions.
565 self.db
566 .prepare("UPDATE pulls SET stalled = NULL WHERE id = ?")
567 .bind(&[pull.id.as_str().into()])?
568 .run()
569 .await?;
570 if !self.claim(&pull.id, "revision", REVISION_MINUTES, true).await? {
571 return Ok(Outcome::fail(
572 FailureCode::Conflict,
573 "g1t-agent is already taking a step on this pull request.",
574 ));
575 }
576 let round = self
577 .db
578 .prepare("SELECT revisions FROM pulls WHERE id = ?")
579 .bind(&[pull.id.as_str().into()])?
580 .first::<RevisionsRow>(None)
581 .await?
582 .map_or(1, |row| row.revisions);
583 let issue = match pull.issue {
584 Some(number) => self.issue(&pull.repo_id, number).await?,
585 None => None,
586 };
587 self.note(
588 &pull.repo_id,
589 pull.number,
590 (POLICY_ACTOR_ID, POLICY_ACTOR_NAME),
591 &format!("sent g1t-agent back to address {}'s comment", actor.username),
592 )
593 .await?;
594 Ok(Outcome::Ok(LifecycleJob {
595 pull_id: pull.id,
596 repo: RepoPath {
597 namespace: repo.namespace,
598 name: repo.name,
599 },
600 number: pull.number,
601 author: pull.author,
602 source,
603 branch: None,
604 default_branch: repo.default_branch,
605 title: pull.title,
606 description: pull.body.unwrap_or_default(),
607 issue,
608 feedback: format!(
609 "{} mentioned you in a comment on this pull request:\n\n{}\n\nThis is a change a person asked for. Make it.",
610 actor.username,
611 row.body.trim()
612 ),
613 round,
614 }))
615 }
616
617 /// Says something in a mention's thread as g1t-agent, once per mention.
618 pub(crate) async fn reply_mention(&self, a: ReplyMentionArgs) -> Result<bool> {
619 let body: String = a.body.trim().chars().take(MAX_REPLY_CHARS).collect();
620 if body.is_empty() {
621 return Ok(false);
622 }
623 let Some(row) = self
624 .db
625 .prepare(
626 "UPDATE agent_mentions SET status = 'replied', outcome = ?
627 WHERE comment_id = ? AND status = 'taken'
628 RETURNING *",
629 )
630 .bind(&[body.as_str().into(), a.comment_id.as_str().into()])?
631 .first::<MentionRow>(None)
632 .await?
633 else {
634 return Ok(false);
635 };
636 let now = now_ms();
637 let id = new_id("cmt", now);
638 let at = rfc3339(now);
639 let table = if row.pull_id.is_some() { "pulls" } else { "issues" };
640 self.db
641 .batch(vec![
642 self.db
643 .prepare(
644 "INSERT INTO comments
645 (id, repo_id, number, author_id, author_name, body, created_at)
646 VALUES (?, ?, ?, ?, ?, ?, ?)",
647 )
648 .bind(&[
649 id.as_str().into(),
650 row.repo_id.as_str().into(),
651 row.number.into(),
652 AGENT_ID.into(),
653 AGENT_NAME.into(),
654 body.as_str().into(),
655 at.as_str().into(),
656 ])?,
657 self.db
658 .prepare(format!(
659 "UPDATE {table} SET updated_at = ? WHERE repo_id = ? AND number = ?"
660 ))
661 .bind(&[at.as_str().into(), row.repo_id.as_str().into(), row.number.into()])?,
662 ])
663 .await?;
664 self.publish_as(
665 "comment.created",
666 &row.repo_id,
667 Some(AGENT_ID.to_owned()),
668 CommentCreated {
669 comment_id: id,
670 repo_id: row.repo_id.clone(),
671 number: row.number,
672 pull_id: row.pull_id,
673 verdict: None,
674 },
675 )
676 .await?;
677 Ok(true)
678 }
679
680 // --- The label rule ----------------------------------------------------
681
682 async fn rules(&self, repo_id: &str) -> Result<AgentRules> {
683 Ok(self
684 .db
685 .prepare("SELECT label, updated_by, updated_at FROM agent_rules WHERE repo_id = ?")
686 .bind(&[repo_id.into()])?
687 .first::<RulesRow>(None)
688 .await?
689 .map_or_else(AgentRules::default, |row| AgentRules {
690 label: row.label,
691 updated_by: Some(row.updated_by),
692 updated_at: Some(row.updated_at),
693 }))
694 }
695
696 pub(crate) async fn get_agent_rules(&self, a: GetAgentRulesArgs) -> Result<Outcome<AgentRules>> {
697 let repo = match self.repo(&a.repo, &a.viewer).await? {
698 Outcome::Ok(repo) => repo,
699 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
700 };
701 Ok(Outcome::Ok(self.rules(&repo.id).await?))
702 }
703
704 pub(crate) async fn set_agent_rules(&self, a: SetAgentRulesArgs) -> Result<Outcome<AgentRules>> {
705 let repo = match self.repo(&a.repo, &Some(a.actor.clone())).await? {
706 Outcome::Ok(repo) => repo,
707 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
708 };
709 if !a.actor.verified || !a.actor.is_member(&repo.namespace) {
710 return Ok(Outcome::fail(
711 FailureCode::Forbidden,
712 "Only members of the workspace can change how g1t-agent is put to work.",
713 ));
714 }
715 let label = match normalize_label(a.label.as_deref()) {
716 Ok(label) => label,
717 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
718 };
719 let rules = AgentRules {
720 label,
721 updated_by: Some(a.actor.username.clone()),
722 updated_at: Some(rfc3339(now_ms())),
723 };
724 self.db
725 .prepare(
726 "INSERT INTO agent_rules (repo_id, label, updated_by, updated_at)
727 VALUES (?, ?, ?, ?)
728 ON CONFLICT (repo_id) DO UPDATE SET
729 label = excluded.label,
730 updated_by = excluded.updated_by,
731 updated_at = excluded.updated_at",
732 )
733 .bind(&[
734 repo.id.as_str().into(),
735 rules.label.as_deref().map_or(JsValue::NULL, JsValue::from),
736 rules.updated_by.as_deref().unwrap_or_default().into(),
737 rules.updated_at.as_deref().unwrap_or_default().into(),
738 ])?
739 .run()
740 .await?;
741 Ok(Outcome::Ok(rules))
742 }
743
744 /// Queues an issue for g1t-agent when it has just been given the label
745 /// the repository's rule names, by a member. The runner starts queued
746 /// issues as there is room, as it does a plan's.
747 pub(crate) async fn apply_label_rule(&self, actor: &User, issue: &Issue, before: &[String]) -> Result<()> {
748 if issue.state != State::Open || issue.queued || issue.agent.is_some() {
749 return Ok(());
750 }
751 let Some(label) = self.rules(&issue.repo_id).await?.label else {
752 return Ok(());
753 };
754 if !issue.labels.contains(&label) || before.contains(&label) {
755 return Ok(());
756 }
757 let path: Option<RepoPath> = g1t_kit::call(
758 &self.repos,
759 "path_by_id",
760 &PathByIdArgs {
761 id: issue.repo_id.clone(),
762 },
763 )
764 .await?;
765 let Some(path) = path else {
766 return Ok(());
767 };
768 if !actor.verified
769 || actor.kind == PrincipalKind::Agent
770 || !(actor.is_member(&path.namespace) || actor.is_member(&path.namespace.to_lowercase()))
771 {
772 return Ok(());
773 }
774 let queued = self
775 .db
776 .prepare(
777 "UPDATE issues SET queued_by = ? WHERE id = ? AND queued_by IS NULL
778 RETURNING id AS value",
779 )
780 .bind(&[serde_json::to_string(actor)?.into(), issue.id.as_str().into()])?
781 .first::<ValueRow>(None)
782 .await?;
783 if queued.is_some() {
784 self.note(
785 &issue.repo_id,
786 issue.number,
787 (POLICY_ACTOR_ID, POLICY_ACTOR_NAME),
788 &format!("queued this for g1t-agent, because it was labelled {label}"),
789 )
790 .await?;
791 }
792 Ok(())
793 }
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799
800 #[test]
801 fn a_mention_is_found_whatever_its_case() {
802 assert!(mentions_agent("@g1t-agent take this"));
803 assert!(mentions_agent("@G1T-Agent take this"));
804 assert!(mentions_agent("Thanks, @g1t-agent."));
805 assert!(mentions_agent("(@g1t-agent) and"));
806 assert!(mentions_agent("cc @g1t-agent, please"));
807 assert!(mentions_agent("first line\n@g1t-agent"));
808 }
809
810 #[test]
811 fn code_does_not_mention_anyone() {
812 assert!(!mentions_agent("Type `@g1t-agent take this` to hand it over."));
813 assert!(!mentions_agent("Use ``@g1t-agent `x` `` like so."));
814 assert!(!mentions_agent("```\n@g1t-agent take this\n```"));
815 assert!(!mentions_agent("~~~md\n@g1t-agent\n~~~"));
816 assert!(!mentions_agent("````\n```\n@g1t-agent\n```\n````"));
817 // Outside the code, it still counts.
818 assert!(mentions_agent("`code` then @g1t-agent fix it"));
819 assert!(mentions_agent("```\nx\n```\n@g1t-agent fix it"));
820 // A backtick that opens nothing is text.
821 assert!(mentions_agent("a ` b @g1t-agent"));
822 }
823
824 #[test]
825 fn quoting_a_mention_does_not_repeat_it() {
826 assert!(!mentions_agent("> @g1t-agent take this\n\nI don't think we should."));
827 assert!(!mentions_agent(" > > @g1t-agent"));
828 assert!(mentions_agent("> earlier\n\n@g1t-agent yes, do it"));
829 }
830
831 #[test]
832 fn email_addresses_and_longer_names_are_not_mentions() {
833 assert!(!mentions_agent("write to ops@g1t-agent.dev"));
834 assert!(!mentions_agent("bot@g1t-agent"));
835 assert!(!mentions_agent("@g1t-agent.dev is the address"));
836 assert!(!mentions_agent("@g1t-agents"));
837 assert!(!mentions_agent("@g1t-agent-2"));
838 assert!(!mentions_agent("@g1t-agent_x"));
839 assert!(!mentions_agent("https://g1t.sh/@g1t-agent"));
840 assert!(!mentions_agent("\\@g1t-agent"));
841 assert!(!mentions_agent("g1t-agent without the at"));
842 }
843
844 #[test]
845 fn non_ascii_text_around_a_mention_is_fine() {
846 assert!(mentions_agent("é @g1t-agent ü"));
847 assert!(!mentions_agent("é@g1t-agent"));
848 assert_eq!(mentions_in("ü @g1t-agent"), vec![(3, 3 + HANDLE.len())]);
849 }
850
851 #[test]
852 fn a_request_is_work() {
853 assert_eq!(intent("@g1t-agent take this"), Intent::Work);
854 assert_eq!(intent("@g1t-agent"), Intent::Work);
855 assert_eq!(intent("@g1t-agent please fix the typo in the README"), Intent::Work);
856 assert_eq!(intent("@g1t-agent can you add tests for this?"), Intent::Work);
857 assert_eq!(intent("Looks close. @g1t-agent rename `foo` to `bar`."), Intent::Work);
858 assert_eq!(intent("@G1T-AGENT Handle the empty case too"), Intent::Work);
859 }
860
861 #[test]
862 fn a_question_is_answered() {
863 assert_eq!(intent("@g1t-agent why does this fail on Windows?"), Intent::Question);
864 assert_eq!(intent("@g1t-agent how is the cache invalidated"), Intent::Question);
865 assert_eq!(intent("@g1t-agent can you explain the retry logic"), Intent::Question);
866 assert_eq!(intent("@g1t-agent is this safe to merge as it is?"), Intent::Question);
867 assert_eq!(intent("@g1t-agent the parser or the lexer?"), Intent::Question);
868 assert_eq!(intent("What does this do, @g1t-agent?"), Intent::Question);
869 }
870
871 #[test]
872 fn a_review_is_a_review() {
873 assert_eq!(intent("@g1t-agent review this"), Intent::Review);
874 assert_eq!(intent("@g1t-agent please review"), Intent::Review);
875 assert_eq!(intent("@g1t-agent could you review the migration?"), Intent::Review);
876 assert_eq!(intent("@g1t-agent re-review"), Intent::Review);
877 }
878
879 #[test]
880 fn quoted_and_code_text_does_not_change_the_intent() {
881 assert_eq!(intent("> why?\n\n@g1t-agent fix it"), Intent::Work);
882 assert_eq!(intent("@g1t-agent fix `why?`"), Intent::Work);
883 }
884
885 #[test]
886 fn labels_are_stored_as_issues_store_them() {
887 assert_eq!(normalize_label(Some(" Agent ")), Ok(Some("agent".to_owned())));
888 assert_eq!(normalize_label(Some(" ")), Ok(None));
889 assert_eq!(normalize_label(None), Ok(None));
890 assert!(normalize_label(Some(&"x".repeat(41))).is_err());
891 }
892}