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