flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/services/work/src/mentions.rs

901 lines32,800 bytesCodeBlame
//! `@g1t-agent` in a comment, and the label rule.
//!
//! A comment that mentions `@g1t-agent` is recorded here as it is made,
//! with who wrote it and what they seem to want. The runner hears the
//! comment was made, takes the mention (once: a mention is one run at most)
//! and does what it asks through the flows that already exist: assigning
//! the issue, sending the author of a g1t pull request back, a review, or
//! an answer in the thread. Whatever it does, or why it did nothing, is
//! said back in the thread as `g1t-agent`.
//!
//! The label rule is a repository's "when an issue gets this label, give it
//! to g1t-agent": the issue is queued for an agent, as a plan's issues are.

use g1t_contracts::access::{self, Capability};
use g1t_contracts::events::CommentCreated;
use g1t_contracts::repos::{GetByIdArgs, PathByIdArgs, Repo, RepoPath};
use g1t_contracts::time::rfc3339;
use g1t_contracts::work::*;
use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User, Viewer, new_id};
use g1t_kit::now_ms;
use serde::{Deserialize, Serialize};
use worker::Result;
use worker::wasm_bindgen::JsValue;

use crate::Work;
use crate::lifecycle::{POLICY_ACTOR_ID, POLICY_ACTOR_NAME, made_by_g1t};
use crate::reviews::{AGENT_ID, AGENT_NAME};
use crate::rows::{NumberRow, ValueRow};

/// How g1t's agent is mentioned. Matched without regard to case.
pub(crate) const HANDLE: &str = "@g1t-agent";
/// How long a revision asked for in a comment may take before another step can.
const REVISION_MINUTES: u64 = 60;
const MAX_REPLY_CHARS: usize = 20_000;
const MAX_LABEL_CHARS: usize = 40;

// --- Reading a comment -----------------------------------------------------

/// What a comment says in its own words: fenced code blocks, code spans
/// and quoted lines are left out, so that quoting or showing a mention
/// does not make one.
pub(crate) fn spoken(body: &str) -> String {
    let mut out = String::new();
    // The fence that opened the code block being skipped, if one is.
    let mut fence: Option<String> = None;
    for line in body.lines() {
        let trimmed = line.trim_start();
        if let Some(marker) = &fence {
            if trimmed.starts_with(marker.as_str()) {
                fence = None;
            }
            out.push('\n');
            continue;
        }
        if let Some(first) = trimmed.chars().next()
            && (first == '`' || first == '~')
        {
            let run = trimmed.chars().take_while(|c| *c == first).count();
            if run >= 3 {
                fence = Some(std::iter::repeat_n(first, run).collect());
                out.push('\n');
                continue;
            }
        }
        if trimmed.starts_with('>') {
            out.push('\n');
            continue;
        }
        out.push_str(&without_code_spans(line));
        out.push('\n');
    }
    out
}

/// A line with its code spans blanked: a run of backticks opens one, and
/// the next run of the same length closes it. An unclosed run is text.
fn without_code_spans(line: &str) -> String {
    let chars: Vec<char> = line.chars().collect();
    let run_at = |at: usize| chars[at..].iter().take_while(|c| **c == '`').count();
    let mut out = String::new();
    let mut i = 0;
    while i < chars.len() {
        if chars[i] != '`' {
            out.push(chars[i]);
            i += 1;
            continue;
        }
        let open = run_at(i);
        let mut j = i + open;
        let mut closed = None;
        while j < chars.len() {
            if chars[j] == '`' {
                let run = run_at(j);
                if run == open {
                    closed = Some(j + run);
                    break;
                }
                j += run;
            } else {
                j += 1;
            }
        }
        match closed {
            Some(end) => {
                out.push(' ');
                i = end;
            }
            None => {
                out.extend(std::iter::repeat_n('`', open));
                i += open;
            }
        }
    }
    out
}

/// Where `text` mentions `@g1t-agent`, as byte ranges. Not in an email
/// address or a longer name (`ops@g1t-agent.dev`, `@g1t-agents`).
pub(crate) fn mentions_in(text: &str) -> Vec<(usize, usize)> {
    let bytes = text.as_bytes();
    let handle = HANDLE.as_bytes();
    let mut found = Vec::new();
    let mut at = 0;
    while at + handle.len() <= bytes.len() {
        if !bytes[at..at + handle.len()].eq_ignore_ascii_case(handle) {
            at += 1;
            continue;
        }
        let end = at + handle.len();
        let before = text[..at].chars().next_back();
        let mut after = text[end..].chars();
        let starts_clean =
            before.is_none_or(|c| !(c.is_alphanumeric() || "._%+-/\\@`=".contains(c)));
        let ends_clean = match after.next() {
            None => true,
            Some(c) if c.is_alphanumeric() || c == '_' || c == '-' || c == '@' => false,
            // The end of a sentence, or a domain: `@g1t-agent.dev`.
            Some('.') => after.next().is_none_or(|c| !c.is_alphanumeric()),
            Some(_) => true,
        };
        if starts_clean && ends_clean {
            found.push((at, end));
            at = end;
        } else {
            at += 1;
        }
    }
    found
}

/// Whether a comment mentions `@g1t-agent` in its own words.
pub(crate) fn mentions_agent(body: &str) -> bool {
    !mentions_in(&spoken(body)).is_empty()
}

/// What someone who mentions `@g1t-agent` wants.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Intent {
    /// Do something: take the issue, or change the pull request.
    Work,
    /// Answer in the thread, changing nothing.
    Question,
    /// Review the pull request.
    Review,
}

impl Intent {
    fn as_str(self) -> &'static str {
        match self {
            Intent::Work => "work",
            Intent::Question => "question",
            Intent::Review => "review",
        }
    }

    fn parse(text: &str) -> Self {
        match text {
            "question" => Intent::Question,
            "review" => Intent::Review,
            _ => Intent::Work,
        }
    }
}

/// Words that start a request to do something.
const WORK_VERBS: &[&str] = &[
    "take", "fix", "implement", "do", "work", "handle", "pick", "start", "add", "update",
    "change", "make", "refactor", "write", "remove", "delete", "rename", "address", "try",
    "revise", "go", "build", "resolve", "move", "use", "rewrite", "split", "clean", "finish",
    "apply", "drop", "bump", "upgrade", "rebase", "merge", "test",
];
/// Words that start a question.
const QUESTION_WORDS: &[&str] = &[
    "what", "why", "how", "where", "when", "which", "who", "whose", "whom", "is", "are", "was",
    "were", "does", "did", "explain", "tell", "describe", "summarize", "summarise", "clarify",
];
/// Politeness before the request itself.
const PREFIXES: &[&[&str]] = &[
    &["please"],
    &["pls"],
    &["hey"],
    &["hi"],
    &["can", "you"],
    &["could", "you"],
    &["would", "you"],
    &["will", "you"],
];

/// What a comment mentioning `@g1t-agent` asks for, read from what it says
/// after the mention (or before, when nothing follows it).
pub(crate) fn intent(body: &str) -> Intent {
    let spoken = spoken(body);
    let found = mentions_in(&spoken);
    let request = match (found.first(), found.last()) {
        (Some(&(start, _)), Some(&(_, end))) => {
            let after = spoken[end..].trim();
            if after.is_empty() { spoken[..start].trim().to_owned() } else { after.to_owned() }
        }
        _ => spoken.trim().to_owned(),
    };
    // Other mentions of the agent in the request say nothing about it.
    let mut request = request.to_lowercase();
    for (start, end) in mentions_in(&request).into_iter().rev() {
        request.replace_range(start..end, " ");
    }
    let words: Vec<&str> = request
        .split(|c: char| !(c.is_alphanumeric() || c == '\''))
        .filter(|word| !word.is_empty())
        .collect();
    let mut rest: &[&str] = &words;
    'strip: loop {
        for prefix in PREFIXES {
            if rest.starts_with(prefix) {
                rest = &rest[prefix.len()..];
                continue 'strip;
            }
        }
        break;
    }
    if rest.iter().take(4).any(|word| *word == "review" || *word == "re-review") {
        return Intent::Review;
    }
    let first = rest.first().copied().unwrap_or_default();
    if WORK_VERBS.contains(&first) {
        return Intent::Work;
    }
    // The question is the sentence the mention starts.
    let sentence = request.split_inclusive(['.', '!', '\n']).next().unwrap_or_default();
    if QUESTION_WORDS.contains(&first)
        || words.first().is_some_and(|word| QUESTION_WORDS.contains(word))
        || sentence.trim_end().ends_with('?')
        || request.trim_end().ends_with('?')
    {
        return Intent::Question;
    }
    Intent::Work
}

// --- Contracts -------------------------------------------------------------
// Mirrored in TypeScript by `packages/contracts/src/mentions.ts`.

/// `take_mention`: claims the mention a comment made, once. Null when the
/// comment made none, or it was already taken. Returns `Option<MentionJob>`.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct TakeMentionArgs {
    comment_id: String,
}

/// What the runner needs to act on a mention.
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MentionJob {
    comment_id: String,
    /// Who wrote it, with the memberships they had then.
    actor: User,
    repo: RepoPath,
    number: u32,
    /// The comment as written.
    body: String,
    intent: Intent,
    /// Whether they may put agents to work in the repository: the Write
    /// role or higher (the name is from when that meant a member).
    member: bool,
    default_branch: String,
    /// Set when the comment is on an issue: whether it is still open.
    issue_open: Option<bool>,
    /// On an issue: the pull request g1t-agent is already working on for it, if any.
    working_pull: Option<u32>,
    /// Set when the comment is on a pull request.
    pull: Option<MentionPull>,
}

#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MentionPull {
    id: String,
    status: PullStatus,
    /// Made by g1t-agent, which g1t sees through.
    agent_authored: bool,
    /// Where its change is: its fork, or the repository itself.
    source: RepoPath,
    /// The head is a branch of the repository itself, not a fork.
    in_repo: bool,
    branch: Option<String>,
    head_commit: Option<String>,
    files: Vec<String>,
}

/// `mention_revision`: sends the author of a g1t pull request back to
/// address a comment that mentioned it. Returns `Outcome<LifecycleJob>`.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct MentionRevisionArgs {
    comment_id: String,
}

/// `reply_mention`: g1t-agent's answer to a mention, in its thread.
/// Returns `bool`: false when there was no such mention.
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ReplyMentionArgs {
    comment_id: String,
    body: String,
}

/// A repository's rules for putting g1t-agent to work by itself.
#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct AgentRules {
    /// When an issue is given this label, g1t-agent takes it.
    label: Option<String>,
    updated_by: Option<String>,
    updated_at: Option<String>,
}

/// `get_agent_rules`. Returns `Outcome<AgentRules>`.
#[derive(Debug, Deserialize)]
pub(crate) struct GetAgentRulesArgs {
    repo: RepoPath,
    viewer: Viewer,
}

/// `set_agent_rules`: needs the Maintain role. Returns `Outcome<AgentRules>`.
#[derive(Debug, Deserialize)]
pub(crate) struct SetAgentRulesArgs {
    actor: User,
    repo: RepoPath,
    #[serde(default)]
    label: Option<String>,
}

#[derive(Deserialize)]
struct MentionRow {
    comment_id: String,
    repo_id: String,
    number: u32,
    pull_id: Option<String>,
    actor: String,
    body: String,
    intent: String,
    member: u8,
}

#[derive(Deserialize)]
struct RulesRow {
    label: Option<String>,
    updated_by: String,
    updated_at: String,
}

#[derive(Deserialize)]
struct RevisionsRow {
    revisions: u32,
}

/// A label as issues store them, or `None` for no rule.
fn normalize_label(label: Option<&str>) -> std::result::Result<Option<String>, &'static str> {
    let Some(label) = label.map(str::trim).filter(|label| !label.is_empty()) else {
        return Ok(None);
    };
    if label.chars().count() > MAX_LABEL_CHARS {
        return Err("A label is up to 40 characters.");
    }
    Ok(Some(label.to_lowercase()))
}

impl Work {
    /// Records a comment's mention of `@g1t-agent`, if it makes one, for
    /// the runner to take when it hears of the comment. Agents mentioning
    /// themselves are not recorded, so no agent can set another to work.
    pub(crate) async fn note_mention(
        &self,
        actor: &User,
        repo: &Repo,
        number: u32,
        comment: &Comment,
        pull_id: Option<&str>,
    ) -> Result<()> {
        if actor.kind == PrincipalKind::Agent
            || actor.id == AGENT_ID
            || !mentions_agent(&comment.body)
        {
            return Ok(());
        }
        // Whether it may set the agent to work: mentioning spends compute.
        let member = actor.verified && access::can(Some(actor), repo, Capability::Run);
        self.db
            .prepare(
                "INSERT OR IGNORE INTO agent_mentions
                   (comment_id, repo_id, number, pull_id, actor, body, intent, member, created_at)
                 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
            )
            .bind(&[
                comment.id.as_str().into(),
                repo.id.as_str().into(),
                number.into(),
                pull_id.map_or(JsValue::NULL, JsValue::from),
                serde_json::to_string(actor)?.into(),
                comment.body.as_str().into(),
                intent(&comment.body).as_str().into(),
                u32::from(member).into(),
                comment.created_at.as_str().into(),
            ])?
            .run()
            .await?;
        Ok(())
    }

    pub(crate) async fn take_mention(&self, a: TakeMentionArgs) -> Result<Option<MentionJob>> {
        let Some(row) = self
            .db
            .prepare(
                "UPDATE agent_mentions SET status = 'taken', taken_at = ?
                 WHERE comment_id = ? AND status = 'pending'
                 RETURNING *",
            )
            .bind(&[rfc3339(now_ms()).into(), a.comment_id.as_str().into()])?
            .first::<MentionRow>(None)
            .await?
        else {
            return Ok(None);
        };
        let Ok(actor) = serde_json::from_str::<User>(&row.actor) else {
            return Ok(None);
        };
        // Where the repository is now, as whoever commented: they could see it.
        let repo: Outcome<Repo> = g1t_kit::call(
            &self.repos,
            "get_by_id",
            &GetByIdArgs {
                id: row.repo_id.clone(),
                viewer: Some(actor.clone()),
            },
        )
        .await?;
        let Outcome::Ok(repo) = crate::retired::unless_archived(repo) else {
            return Ok(None);
        };
        let path = RepoPath {
            namespace: repo.namespace.clone(),
            name: repo.name.clone(),
        };
        let pull = match &row.pull_id {
            Some(id) => self.pull_by_id(id).await?,
            None => None,
        };
        let issue = match &pull {
            Some(_) => None,
            None => self.issue(&repo.id, row.number).await?,
        };
        let issue_open = pull.is_none().then(|| issue.as_ref().is_some_and(|issue| issue.state == State::Open));
        let working_pull = match &issue {
            Some(issue) => self
                .db
                .prepare(
                    "SELECT number AS n FROM pulls
                     WHERE issue_id = ? AND agent = ? AND status IN ('draft', 'open')
                     ORDER BY number DESC LIMIT 1",
                )
                .bind(&[issue.id.as_str().into(), AGENT_NAME.into()])?
                .first::<NumberRow>(None)
                .await?
                .map(|row| row.n),
            None => None,
        };
        Ok(Some(MentionJob {
            comment_id: row.comment_id,
            actor,
            number: row.number,
            body: row.body,
            intent: Intent::parse(&row.intent),
            member: row.member != 0,
            default_branch: repo.default_branch.clone(),
            issue_open,
            working_pull,
            pull: pull.map(|pull| MentionPull {
                agent_authored: made_by_g1t(&pull),
                source: pull.fork.clone().unwrap_or_else(|| path.clone()),
                in_repo: pull.fork.is_none(),
                files: pull.files.iter().map(|file| file.path.clone()).collect(),
                id: pull.id,
                status: pull.status,
                branch: pull.branch,
                head_commit: pull.head_commit,
            }),
            repo: path,
        }))
    }

    pub(crate) async fn mention_revision(
        &self,
        a: MentionRevisionArgs,
    ) -> Result<Outcome<LifecycleJob>> {
        let Some(row) = self
            .db
            .prepare("SELECT * FROM agent_mentions WHERE comment_id = ? AND status = 'taken'")
            .bind(&[a.comment_id.as_str().into()])?
            .first::<MentionRow>(None)
            .await?
        else {
            return Ok(Outcome::fail(FailureCode::NotFound, "No such mention."));
        };
        let actor: User = serde_json::from_str(&row.actor)?;
        let Some(pull) = (match &row.pull_id {
            Some(id) => self.pull_by_id(id).await?,
            None => None,
        }) else {
            return Ok(Outcome::fail(FailureCode::NotFound, "Pull request not found."));
        };
        if row.member == 0 || !made_by_g1t(&pull) {
            return Ok(Outcome::fail(
                FailureCode::Forbidden,
                "Only someone with the Write role or higher can send g1t-agent back to a pull request it made.",
            ));
        }
        match pull.status {
            PullStatus::Open => {}
            PullStatus::Draft => {
                return Ok(Outcome::fail(
                    FailureCode::Conflict,
                    "g1t-agent is still making this change.",
                ));
            }
            status => {
                return Ok(Outcome::fail(
                    FailureCode::Conflict,
                    format!("This pull request is already {}.", serde_json::to_value(status)?.as_str().unwrap_or("closed")),
                ));
            }
        }
        let viewer = self.author_viewer(&pull).await?;
        let repo: Outcome<Repo> = g1t_kit::call(
            &self.repos,
            "get_by_id",
            &GetByIdArgs {
                id: pull.repo_id.clone(),
                viewer,
            },
        )
        .await?;
        let (Outcome::Ok(repo), Some(source)) = (crate::retired::unless_archived(repo), pull.fork.clone()) else {
            return Ok(Outcome::fail(FailureCode::NotFound, "Pull request not found."));
        };
        // A person asking outranks a stop and the limit on revisions.
        self.db
            .prepare("UPDATE pulls SET stalled = NULL WHERE id = ?")
            .bind(&[pull.id.as_str().into()])?
            .run()
            .await?;
        if !self.claim(&pull.id, "revision", REVISION_MINUTES, true).await? {
            return Ok(Outcome::fail(
                FailureCode::Conflict,
                "g1t-agent is already taking a step on this pull request.",
            ));
        }
        let round = self
            .db
            .prepare("SELECT revisions FROM pulls WHERE id = ?")
            .bind(&[pull.id.as_str().into()])?
            .first::<RevisionsRow>(None)
            .await?
            .map_or(1, |row| row.revisions);
        let issue = match pull.issue {
            Some(number) => self.issue(&pull.repo_id, number).await?,
            None => None,
        };
        self.note(
            &pull.repo_id,
            pull.number,
            (POLICY_ACTOR_ID, POLICY_ACTOR_NAME),
            &format!("sent g1t-agent back to address {}'s comment", actor.username),
        )
        .await?;
        Ok(Outcome::Ok(LifecycleJob {
            pull_id: pull.id,
            repo: RepoPath {
                namespace: repo.namespace,
                name: repo.name,
            },
            number: pull.number,
            author: pull.author,
            source,
            branch: None,
            default_branch: repo.default_branch,
            title: pull.title,
            description: pull.body.unwrap_or_default(),
            issue,
            feedback: format!(
                "{} mentioned you in a comment on this pull request:\n\n{}\n\nThis is a change a person asked for. Make it.",
                actor.username,
                row.body.trim()
            ),
            round,
        }))
    }

    /// Says something in a mention's thread as g1t-agent, once per mention.
    pub(crate) async fn reply_mention(&self, a: ReplyMentionArgs) -> Result<bool> {
        let body: String = a.body.trim().chars().take(MAX_REPLY_CHARS).collect();
        if body.is_empty() {
            return Ok(false);
        }
        let Some(row) = self
            .db
            .prepare(
                "UPDATE agent_mentions SET status = 'replied', outcome = ?
                 WHERE comment_id = ? AND status = 'taken'
                 RETURNING *",
            )
            .bind(&[body.as_str().into(), a.comment_id.as_str().into()])?
            .first::<MentionRow>(None)
            .await?
        else {
            return Ok(false);
        };
        let now = now_ms();
        let id = new_id("cmt", now);
        let at = rfc3339(now);
        let table = if row.pull_id.is_some() { "pulls" } else { "issues" };
        self.db
            .batch(vec![
                self.db
                    .prepare(
                        "INSERT INTO comments
                           (id, repo_id, number, author_id, author_name, body, created_at)
                         VALUES (?, ?, ?, ?, ?, ?, ?)",
                    )
                    .bind(&[
                        id.as_str().into(),
                        row.repo_id.as_str().into(),
                        row.number.into(),
                        AGENT_ID.into(),
                        AGENT_NAME.into(),
                        body.as_str().into(),
                        at.as_str().into(),
                    ])?,
                self.db
                    .prepare(format!(
                        "UPDATE {table} SET updated_at = ? WHERE repo_id = ? AND number = ?"
                    ))
                    .bind(&[at.as_str().into(), row.repo_id.as_str().into(), row.number.into()])?,
            ])
            .await?;
        self.publish_as(
            "comment.created",
            &row.repo_id,
            Some(AGENT_ID.to_owned()),
            CommentCreated {
                comment_id: id,
                repo_id: row.repo_id.clone(),
                number: row.number,
                pull_id: row.pull_id,
                verdict: None,
            },
        )
        .await?;
        Ok(true)
    }

    // --- The label rule ----------------------------------------------------

    async fn rules(&self, repo_id: &str) -> Result<AgentRules> {
        Ok(self
            .db
            .prepare("SELECT label, updated_by, updated_at FROM agent_rules WHERE repo_id = ?")
            .bind(&[repo_id.into()])?
            .first::<RulesRow>(None)
            .await?
            .map_or_else(AgentRules::default, |row| AgentRules {
                label: row.label,
                updated_by: Some(row.updated_by),
                updated_at: Some(row.updated_at),
            }))
    }

    pub(crate) async fn get_agent_rules(&self, a: GetAgentRulesArgs) -> Result<Outcome<AgentRules>> {
        let repo = match self.repo(&a.repo, &a.viewer).await? {
            Outcome::Ok(repo) => repo,
            Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
        };
        Ok(Outcome::Ok(self.rules(&repo.id).await?))
    }

    pub(crate) async fn set_agent_rules(&self, a: SetAgentRulesArgs) -> Result<Outcome<AgentRules>> {
        let repo = match self.repo(&a.repo, &Some(a.actor.clone())).await? {
            Outcome::Ok(repo) => repo,
            Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
        };
        if let Outcome::Fail(failure) = crate::retired::writable(&repo) {
            return Ok(Outcome::Fail(failure));
        }
        if !a.actor.verified {
            return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED));
        }
        if let Outcome::Fail(failure) = crate::allowed(Some(&a.actor), &repo, Capability::ManageSettings) {
            return Ok(Outcome::Fail(failure));
        }
        let label = match normalize_label(a.label.as_deref()) {
            Ok(label) => label,
            Err(message) => return Ok(Outcome::fail(FailureCode::Invalid, message)),
        };
        let rules = AgentRules {
            label,
            updated_by: Some(a.actor.username.clone()),
            updated_at: Some(rfc3339(now_ms())),
        };
        self.db
            .prepare(
                "INSERT INTO agent_rules (repo_id, label, updated_by, updated_at)
                 VALUES (?, ?, ?, ?)
                 ON CONFLICT (repo_id) DO UPDATE SET
                   label = excluded.label,
                   updated_by = excluded.updated_by,
                   updated_at = excluded.updated_at",
            )
            .bind(&[
                repo.id.as_str().into(),
                rules.label.as_deref().map_or(JsValue::NULL, JsValue::from),
                rules.updated_by.as_deref().unwrap_or_default().into(),
                rules.updated_at.as_deref().unwrap_or_default().into(),
            ])?
            .run()
            .await?;
        Ok(Outcome::Ok(rules))
    }

    /// Queues an issue for g1t-agent when it has just been given the label
    /// the repository's rule names, by someone who may run agents in it.
    /// The runner starts queued issues as there is room, as it does a
    /// plan's.
    pub(crate) async fn apply_label_rule(&self, actor: &User, issue: &Issue, before: &[String]) -> Result<()> {
        if issue.state != State::Open || issue.queued || issue.agent.is_some() {
            return Ok(());
        }
        let Some(label) = self.rules(&issue.repo_id).await?.label else {
            return Ok(());
        };
        if !issue.labels.contains(&label) || before.contains(&label) {
            return Ok(());
        }
        let path: Option<RepoPath> = g1t_kit::call(
            &self.repos,
            "path_by_id",
            &PathByIdArgs {
                id: issue.repo_id.clone(),
            },
        )
        .await?;
        let Some(path) = path else {
            return Ok(());
        };
        // Running needs Write, which public alone never gives, so whether
        // the repository is public does not matter here.
        let target = access::RepoRef { id: &issue.repo_id, namespace: &path.namespace, private: true };
        if !actor.verified
            || actor.kind == PrincipalKind::Agent
            || !access::can(Some(actor), target, Capability::Run)
        {
            return Ok(());
        }
        let queued = self
            .db
            .prepare(
                "UPDATE issues SET queued_by = ? WHERE id = ? AND queued_by IS NULL
                 RETURNING id AS value",
            )
            .bind(&[serde_json::to_string(actor)?.into(), issue.id.as_str().into()])?
            .first::<ValueRow>(None)
            .await?;
        if queued.is_some() {
            self.note(
                &issue.repo_id,
                issue.number,
                (POLICY_ACTOR_ID, POLICY_ACTOR_NAME),
                &format!("queued this for g1t-agent, because it was labelled {label}"),
            )
            .await?;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_mention_is_found_whatever_its_case() {
        assert!(mentions_agent("@g1t-agent take this"));
        assert!(mentions_agent("@G1T-Agent take this"));
        assert!(mentions_agent("Thanks, @g1t-agent."));
        assert!(mentions_agent("(@g1t-agent) and"));
        assert!(mentions_agent("cc @g1t-agent, please"));
        assert!(mentions_agent("first line\n@g1t-agent"));
    }

    #[test]
    fn code_does_not_mention_anyone() {
        assert!(!mentions_agent("Type `@g1t-agent take this` to hand it over."));
        assert!(!mentions_agent("Use ``@g1t-agent `x` `` like so."));
        assert!(!mentions_agent("```\n@g1t-agent take this\n```"));
        assert!(!mentions_agent("~~~md\n@g1t-agent\n~~~"));
        assert!(!mentions_agent("````\n```\n@g1t-agent\n```\n````"));
        // Outside the code, it still counts.
        assert!(mentions_agent("`code` then @g1t-agent fix it"));
        assert!(mentions_agent("```\nx\n```\n@g1t-agent fix it"));
        // A backtick that opens nothing is text.
        assert!(mentions_agent("a ` b @g1t-agent"));
    }

    #[test]
    fn quoting_a_mention_does_not_repeat_it() {
        assert!(!mentions_agent("> @g1t-agent take this\n\nI don't think we should."));
        assert!(!mentions_agent("  > > @g1t-agent"));
        assert!(mentions_agent("> earlier\n\n@g1t-agent yes, do it"));
    }

    #[test]
    fn email_addresses_and_longer_names_are_not_mentions() {
        assert!(!mentions_agent("write to ops@g1t-agent.dev"));
        assert!(!mentions_agent("bot@g1t-agent"));
        assert!(!mentions_agent("@g1t-agent.dev is the address"));
        assert!(!mentions_agent("@g1t-agents"));
        assert!(!mentions_agent("@g1t-agent-2"));
        assert!(!mentions_agent("@g1t-agent_x"));
        assert!(!mentions_agent("https://g1t.sh/@g1t-agent"));
        assert!(!mentions_agent("\\@g1t-agent"));
        assert!(!mentions_agent("g1t-agent without the at"));
    }

    #[test]
    fn non_ascii_text_around_a_mention_is_fine() {
        assert!(mentions_agent("é @g1t-agent ü"));
        assert!(!mentions_agent("é@g1t-agent"));
        assert_eq!(mentions_in("ü @g1t-agent"), vec![(3, 3 + HANDLE.len())]);
    }

    #[test]
    fn a_request_is_work() {
        assert_eq!(intent("@g1t-agent take this"), Intent::Work);
        assert_eq!(intent("@g1t-agent"), Intent::Work);
        assert_eq!(intent("@g1t-agent please fix the typo in the README"), Intent::Work);
        assert_eq!(intent("@g1t-agent can you add tests for this?"), Intent::Work);
        assert_eq!(intent("Looks close. @g1t-agent rename `foo` to `bar`."), Intent::Work);
        assert_eq!(intent("@G1T-AGENT Handle the empty case too"), Intent::Work);
    }

    #[test]
    fn a_question_is_answered() {
        assert_eq!(intent("@g1t-agent why does this fail on Windows?"), Intent::Question);
        assert_eq!(intent("@g1t-agent how is the cache invalidated"), Intent::Question);
        assert_eq!(intent("@g1t-agent can you explain the retry logic"), Intent::Question);
        assert_eq!(intent("@g1t-agent is this safe to merge as it is?"), Intent::Question);
        assert_eq!(intent("@g1t-agent the parser or the lexer?"), Intent::Question);
        assert_eq!(intent("What does this do, @g1t-agent?"), Intent::Question);
    }

    #[test]
    fn a_review_is_a_review() {
        assert_eq!(intent("@g1t-agent review this"), Intent::Review);
        assert_eq!(intent("@g1t-agent please review"), Intent::Review);
        assert_eq!(intent("@g1t-agent could you review the migration?"), Intent::Review);
        assert_eq!(intent("@g1t-agent re-review"), Intent::Review);
    }

    #[test]
    fn quoted_and_code_text_does_not_change_the_intent() {
        assert_eq!(intent("> why?\n\n@g1t-agent fix it"), Intent::Work);
        assert_eq!(intent("@g1t-agent fix `why?`"), Intent::Work);
    }

    #[test]
    fn labels_are_stored_as_issues_store_them() {
        assert_eq!(normalize_label(Some("  Agent ")), Ok(Some("agent".to_owned())));
        assert_eq!(normalize_label(Some("   ")), Ok(None));
        assert_eq!(normalize_label(None), Ok(None));
        assert!(normalize_label(Some(&"x".repeat(41))).is_err());
    }
}