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