Skip to content

g1t/services/work/src/mentions.rs

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