g1t/services/work/src/mentions.rs

925 lines33,561 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 self.db
571 .prepare("UPDATE pulls SET stalled = NULL WHERE id = ?")
572 .bind(&[pull.id.as_str().into()])?
573 .run()
574 .await?;
575 if !self.claim(&pull.id, "revision", REVISION_MINUTES, true).await? {
576 return Ok(Outcome::fail(
577 FailureCode::Conflict,
578 "g1t is already taking a step on this pull request.",
579 ));
580 }
581 let round = self
582 .db
583 .prepare("SELECT revisions FROM pulls WHERE id = ?")
584 .bind(&[pull.id.as_str().into()])?
585 .first::<RevisionsRow>(None)
586 .await?
587 .map_or(1, |row| row.revisions);
588 let issue = match pull.issue {
589 Some(number) => self.issue(&pull.repo_id, number).await?,
590 None => None,
591 };
592 self.note(
593 &pull.repo_id,
594 pull.number,
595 (POLICY_ACTOR_ID, POLICY_ACTOR_NAME),
596 &format!("sent g1t back to address {}'s comment", actor.username),
597 )
598 .await?;
599 Ok(Outcome::Ok(LifecycleJob {
600 pull_id: pull.id,
601 repo: RepoPath {
602 namespace: repo.namespace,
603 name: repo.name,
604 },
605 number: pull.number,
606 author: pull.requested_by.unwrap_or(pull.author),
607 source,
608 branch: None,
609 default_branch: repo.default_branch,
610 title: pull.title,
611 description: pull.body.unwrap_or_default(),
612 issue,
613 feedback: format!(
614 "{} mentioned you in a comment on this pull request:\n\n{}\n\nThis is a change a person asked for. Make it.",
615 actor.username,
616 row.body.trim()
617 ),
618 round,
619 }))
620 }
621
622 /// Says something in a mention's thread as g1t, once per mention.
623 pub(crate) async fn reply_mention(&self, a: ReplyMentionArgs) -> Result<bool> {
624 let body: String = a.body.trim().chars().take(MAX_REPLY_CHARS).collect();
625 if body.is_empty() {
626 return Ok(false);
627 }
628 let Some(row) = self
629 .db
630 .prepare(
631 "UPDATE agent_mentions SET status = 'replied', outcome = ?
632 WHERE comment_id = ? AND status = 'taken'
633 RETURNING *",
634 )
635 .bind(&[body.as_str().into(), a.comment_id.as_str().into()])?
636 .first::<MentionRow>(None)
637 .await?
638 else {
639 return Ok(false);
640 };
641 let now = now_ms();
642 let id = new_id("cmt", now);
643 let at = rfc3339(now);
644 let table = if row.pull_id.is_some() { "pulls" } else { "issues" };
645 self.db
646 .batch(vec![
647 self.db
648 .prepare(
649 "INSERT INTO comments
650 (id, repo_id, number, author_id, author_name, body, created_at)
651 VALUES (?, ?, ?, ?, ?, ?, ?)",
652 )
653 .bind(&[
654 id.as_str().into(),
655 row.repo_id.as_str().into(),
656 row.number.into(),
657 AGENT_ID.into(),
658 AGENT_NAME.into(),
659 body.as_str().into(),
660 at.as_str().into(),
661 ])?,
662 self.db
663 .prepare(format!(
664 "UPDATE {table} SET updated_at = ? WHERE repo_id = ? AND number = ?"
665 ))
666 .bind(&[at.as_str().into(), row.repo_id.as_str().into(), row.number.into()])?,
667 ])
668 .await?;
669 self.publish_as(
670 "comment.created",
671 &row.repo_id,
672 Some(AGENT_ID.to_owned()),
673 CommentCreated {
674 comment_id: id,
675 repo_id: row.repo_id.clone(),
676 number: row.number,
677 pull_id: row.pull_id,
678 verdict: None,
679 },
680 )
681 .await?;
682 Ok(true)
683 }
684
685 // --- The label rule ----------------------------------------------------
686
687 async fn rules(&self, repo_id: &str) -> Result<AgentRules> {
688 Ok(self
689 .db
690 .prepare("SELECT label, updated_by, updated_at FROM agent_rules WHERE repo_id = ?")
691 .bind(&[repo_id.into()])?
692 .first::<RulesRow>(None)
693 .await?
694 .map_or_else(AgentRules::default, |row| AgentRules {
695 label: row.label,
696 updated_by: Some(row.updated_by),
697 updated_at: Some(row.updated_at),
698 }))
699 }
700
701 pub(crate) async fn get_agent_rules(&self, a: GetAgentRulesArgs) -> Result<Outcome<AgentRules>> {
702 let repo = match self.repo(&a.repo, &a.viewer).await? {
703 Outcome::Ok(repo) => repo,
704 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
705 };
706 Ok(Outcome::Ok(self.rules(&repo.id).await?))
707 }
708
709 pub(crate) async fn set_agent_rules(&self, a: SetAgentRulesArgs) -> Result<Outcome<AgentRules>> {
710 let repo = match self.repo(&a.repo, &Some(a.actor.clone())).await? {
711 Outcome::Ok(repo) => repo,
712 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
713 };
714 if let Outcome::Fail(failure) = crate::retired::writable(&repo) {
715 return Ok(Outcome::Fail(failure));
716 }
717 if !a.actor.verified {
718 return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
719 }
720 if let Outcome::Fail(failure) = crate::allowed(Some(&a.actor), &repo, Capability::ManageSettings) {
721 return Ok(Outcome::Fail(failure));
722 }
723 let label = match normalize_label(a.label.as_deref()) {
724 Ok(label) => label,
725 Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
726 };
727 let rules = AgentRules {
728 label,
729 updated_by: Some(a.actor.username.clone()),
730 updated_at: Some(rfc3339(now_ms())),
731 };
732 self.db
733 .prepare(
734 "INSERT INTO agent_rules (repo_id, label, updated_by, updated_at)
735 VALUES (?, ?, ?, ?)
736 ON CONFLICT (repo_id) DO UPDATE SET
737 label = excluded.label,
738 updated_by = excluded.updated_by,
739 updated_at = excluded.updated_at",
740 )
741 .bind(&[
742 repo.id.as_str().into(),
743 rules.label.as_deref().map_or(JsValue::NULL, JsValue::from),
744 rules.updated_by.as_deref().unwrap_or_default().into(),
745 rules.updated_at.as_deref().unwrap_or_default().into(),
746 ])?
747 .run()
748 .await?;
749 Ok(Outcome::Ok(rules))
750 }
751
752 /// Queues an issue for g1t when it has just been given the label
753 /// the repository's rule names, by someone who may run agents in it.
754 /// The runner starts queued issues as there is room, as it does a
755 /// plan's.
756 pub(crate) async fn apply_label_rule(&self, actor: &User, issue: &Issue, before: &[String]) -> Result<()> {
757 if issue.state != State::Open || issue.queued || issue.agent.is_some() {
758 return Ok(());
759 }
760 let Some(label) = self.rules(&issue.repo_id).await?.label else {
761 return Ok(());
762 };
763 if !issue.labels.contains(&label) || before.contains(&label) {
764 return Ok(());
765 }
766 let path: Option<RepoPath> = g1t_kit::call(
767 &self.repos,
768 "path_by_id",
769 &PathByIdArgs {
770 id: issue.repo_id.clone(),
771 },
772 )
773 .await?;
774 let Some(path) = path else {
775 return Ok(());
776 };
777 // Running needs Write, which public alone never gives, so whether
778 // the repository is public does not matter here.
779 let target = access::RepoRef { id: &issue.repo_id, namespace: &path.namespace, private: true };
780 if !actor.verified
781 || actor.kind == PrincipalKind::Agent
782 || !access::can(Some(actor), target, Capability::Run)
783 {
784 return Ok(());
785 }
786 let queued = self
787 .db
788 .prepare(
789 "UPDATE issues SET queued_by = ? WHERE id = ? AND queued_by IS NULL
790 RETURNING id AS value",
791 )
792 .bind(&[serde_json::to_string(actor)?.into(), issue.id.as_str().into()])?
793 .first::<ValueRow>(None)
794 .await?;
795 if queued.is_some() {
796 self.note(
797 &issue.repo_id,
798 issue.number,
799 (POLICY_ACTOR_ID, POLICY_ACTOR_NAME),
800 &format!("queued this for g1t, because it was labelled {label}"),
801 )
802 .await?;
803 }
804 Ok(())
805 }
806}
807
808#[cfg(test)]
809mod tests {
810 use super::*;
811
812 #[test]
813 fn a_mention_is_found_whatever_its_case() {
814 assert!(mentions_agent("@g1t take this"));
815 assert!(mentions_agent("@G1T take this"));
816 assert!(mentions_agent("Thanks, @g1t."));
817 assert!(mentions_agent("(@g1t) and"));
818 assert!(mentions_agent("cc @g1t, please"));
819 assert!(mentions_agent("first line\n@g1t"));
820 }
821
822 #[test]
823 fn code_does_not_mention_anyone() {
824 assert!(!mentions_agent("Type `@g1t take this` to hand it over."));
825 assert!(!mentions_agent("Use ``@g1t `x` `` like so."));
826 assert!(!mentions_agent("```\n@g1t take this\n```"));
827 assert!(!mentions_agent("~~~md\n@g1t\n~~~"));
828 assert!(!mentions_agent("````\n```\n@g1t\n```\n````"));
829 // Outside the code, it still counts.
830 assert!(mentions_agent("`code` then @g1t fix it"));
831 assert!(mentions_agent("```\nx\n```\n@g1t fix it"));
832 // A backtick that opens nothing is text.
833 assert!(mentions_agent("a ` b @g1t"));
834 }
835
836 #[test]
837 fn quoting_a_mention_does_not_repeat_it() {
838 assert!(!mentions_agent("> @g1t take this\n\nI don't think we should."));
839 assert!(!mentions_agent(" > > @g1t"));
840 assert!(mentions_agent("> earlier\n\n@g1t yes, do it"));
841 }
842
843 #[test]
844 fn email_addresses_and_longer_names_are_not_mentions() {
845 assert!(!mentions_agent("write to ops@g1t.sh"));
846 assert!(!mentions_agent("mail g1t@users.noreply.g1t.sh"));
847 assert!(!mentions_agent("bot@g1t"));
848 assert!(!mentions_agent("@g1t.dev is the address"));
849 assert!(!mentions_agent("@g1t.sh"));
850 assert!(!mentions_agent("@g1ts"));
851 assert!(!mentions_agent("@g1t2"));
852 assert!(!mentions_agent("@g1t-2"));
853 assert!(!mentions_agent("@g1t-bot take this"));
854 assert!(!mentions_agent("@g1t-agent take this"));
855 assert!(!mentions_agent("@g1t_x"));
856 assert!(!mentions_agent("https://g1t.sh/@g1t"));
857 assert!(!mentions_agent("see https://g1t.sh/g1t/docs"));
858 assert!(!mentions_agent("\\@g1t"));
859 assert!(!mentions_agent("g1t without the at"));
860 assert!(!mentions_agent("import { x } from \"@g1t/contracts\";"));
861 assert!(!mentions_agent("npm i @g1t/contracts"));
862 assert!(!mentions_agent("@@g1t"));
863 assert!(!mentions_agent("name@g1t: hi"));
864 }
865
866 #[test]
867 fn a_mention_ends_at_punctuation() {
868 assert!(mentions_agent("@g1t: take this"));
869 assert!(mentions_agent("@g1t! fix it"));
870 assert!(mentions_agent("ok @g1t?"));
871 assert!(mentions_agent("\"@g1t\" take this"));
872 assert!(mentions_agent("**@g1t** take this"));
873 assert!(mentions_agent("@g1t.\nThanks"));
874 assert_eq!(mentions_in("@g1t and @g1t-bot and @g1t"), vec![(0, 4), (22, 26)]);
875 }
876
877 #[test]
878 fn non_ascii_text_around_a_mention_is_fine() {
879 assert!(mentions_agent("é @g1t ü"));
880 assert!(!mentions_agent("é@g1t"));
881 assert_eq!(mentions_in("ü @g1t"), vec![(3, 3 + HANDLE.len())]);
882 }
883
884 #[test]
885 fn a_request_is_work() {
886 assert_eq!(intent("@g1t take this"), Intent::Work);
887 assert_eq!(intent("@g1t"), Intent::Work);
888 assert_eq!(intent("@g1t please fix the typo in the README"), Intent::Work);
889 assert_eq!(intent("@g1t can you add tests for this?"), Intent::Work);
890 assert_eq!(intent("Looks close. @g1t rename `foo` to `bar`."), Intent::Work);
891 assert_eq!(intent("@G1T Handle the empty case too"), Intent::Work);
892 }
893
894 #[test]
895 fn a_question_is_answered() {
896 assert_eq!(intent("@g1t why does this fail on Windows?"), Intent::Question);
897 assert_eq!(intent("@g1t how is the cache invalidated"), Intent::Question);
898 assert_eq!(intent("@g1t can you explain the retry logic"), Intent::Question);
899 assert_eq!(intent("@g1t is this safe to merge as it is?"), Intent::Question);
900 assert_eq!(intent("@g1t the parser or the lexer?"), Intent::Question);
901 assert_eq!(intent("What does this do, @g1t?"), Intent::Question);
902 }
903
904 #[test]
905 fn a_review_is_a_review() {
906 assert_eq!(intent("@g1t review this"), Intent::Review);
907 assert_eq!(intent("@g1t please review"), Intent::Review);
908 assert_eq!(intent("@g1t could you review the migration?"), Intent::Review);
909 assert_eq!(intent("@g1t re-review"), Intent::Review);
910 }
911
912 #[test]
913 fn quoted_and_code_text_does_not_change_the_intent() {
914 assert_eq!(intent("> why?\n\n@g1t fix it"), Intent::Work);
915 assert_eq!(intent("@g1t fix `why?`"), Intent::Work);
916 }
917
918 #[test]
919 fn labels_are_stored_as_issues_store_them() {
920 assert_eq!(normalize_label(Some(" Agent ")), Ok(Some("agent".to_owned())));
921 assert_eq!(normalize_label(Some(" ")), Ok(None));
922 assert_eq!(normalize_label(None), Ok(None));
923 assert!(normalize_label(Some(&"x".repeat(41))).is_err());
924 }
925}