Skip to content

g1t/services/work/src/mentions.rs

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