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

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