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