AkurAI Build
Menu

Public repository / b9bf8c65e7cff19e76ae78f73b05d66f6a1b4729

AkurAI-Build / src/linking.rs

Latest change b9bf8c65e7cff19e76ae78f73b05d66f6a1b4729 - OLI-274: commits and pull requests link to the Paperclip issues they mention by Óli

//! Issue linking (OLI-274): commits and pull requests that mention a Paperclip
//! issue id (`OLI-123`, or `fixes OLI-123`) get a comment on that issue with a
//! link back to the forge. A thin link, no second tracker: labels, milestones,
//! projects and epics stay in Paperclip. Each object (commit or pull request
//! action) is announced once per issue.

use anyhow::{Context, Result};
use rusqlite::params;
use std::path::Path;
use std::process::{Command, Stdio};

use crate::db::Database;
use crate::git_process;

const CLOSING_WORDS: [&str; 6] = ["fix", "fixes", "fixed", "close", "closes", "resolves"];
/// Upper-case prefixes that look like issue ids but are not (SHA-1, RFC-7231, …).
const NOT_ISSUE_PREFIXES: [&str; 16] = [
    "SHA", "UTF", "ISO", "RFC", "CVE", "HTTP", "TLS", "SSL", "MD", "GPT", "ARM", "AES", "RSA",
    "ED", "PR", "IPV",
];
const MAX_COMMITS_PER_PUSH: usize = 50;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IssueRef {
    pub id: String,
    /// Preceded by fix/fixes/fixed/close/closes/resolves.
    pub closes: bool,
}

/// Issue ids like `OLI-123` in `text`, in order of first appearance, with
/// whether a closing word precedes them.
pub fn issue_refs(text: &str) -> Vec<IssueRef> {
    let mut refs: Vec<IssueRef> = Vec::new();
    let words: Vec<&str> = text
        .split(|c: char| {
            c.is_whitespace() || matches!(c, '(' | ')' | ',' | ';' | ':' | '[' | ']' | '"' | '\'')
        })
        .filter(|w| !w.is_empty())
        .collect();
    for (i, word) in words.iter().enumerate() {
        let candidate = word.trim_end_matches(['.', '!', '?']);
        let Some((prefix, number)) = candidate.split_once('-') else {
            continue;
        };
        let valid = (2..=10).contains(&prefix.len())
            && prefix.chars().all(|c| c.is_ascii_uppercase())
            && !NOT_ISSUE_PREFIXES.contains(&prefix)
            && !number.is_empty()
            && number.len() <= 9
            && number.chars().all(|c| c.is_ascii_digit());
        if !valid {
            continue;
        }
        let closes = i > 0 && CLOSING_WORDS.contains(&words[i - 1].to_ascii_lowercase().as_str());
        match refs.iter_mut().find(|r| r.id == candidate) {
            Some(existing) => existing.closes |= closes,
            None => refs.push(IssueRef {
                id: candidate.to_string(),
                closes,
            }),
        }
    }
    refs
}

/// Where comments go. The real one talks to Paperclip; tests record.
pub trait Tracker: Send + Sync {
    fn comment(&self, issue: &str, body: &str) -> Result<()>;
    fn enabled(&self) -> bool {
        true
    }
}

/// No tracker configured: links are still recorded, nothing is posted.
pub struct NoTracker;

impl Tracker for NoTracker {
    fn comment(&self, _issue: &str, _body: &str) -> Result<()> {
        Ok(())
    }
    fn enabled(&self) -> bool {
        false
    }
}

/// Paperclip's `POST /api/issues/{id}/comments` with the board key, through curl.
pub struct PaperclipTracker {
    url: String,
    token: String,
}

impl PaperclipTracker {
    pub fn new(url: &str, token: &str) -> PaperclipTracker {
        PaperclipTracker {
            url: url.trim_end_matches('/').to_string(),
            token: token.trim().to_string(),
        }
    }
}

impl Tracker for PaperclipTracker {
    fn comment(&self, issue: &str, body: &str) -> Result<()> {
        let payload = serde_json::json!({ "body": body }).to_string();
        let output = Command::new("curl")
            .args([
                "--silent",
                "--show-error",
                "--fail",
                "--max-time",
                "10",
                "-X",
                "POST",
            ])
            .arg(format!("{}/api/issues/{issue}/comments", self.url))
            .arg("-H")
            .arg(format!("Authorization: Bearer {}", self.token))
            .args([
                "-H",
                "Content-Type: application/json",
                "--data-binary",
                "@-",
            ])
            .stdin(Stdio::piped())
            .stdout(Stdio::null())
            .stderr(Stdio::piped())
            .spawn()
            .and_then(|mut child| {
                use std::io::Write;
                if let Some(mut stdin) = child.stdin.take() {
                    stdin.write_all(payload.as_bytes())?;
                }
                child.wait_with_output()
            })
            .context("could not run curl for the issue tracker")?;
        anyhow::ensure!(
            output.status.success(),
            "tracker refused the comment on {issue}: {}",
            String::from_utf8_lossy(&output.stderr).trim()
        );
        Ok(())
    }
}

/// Remember that `object` (a commit sha or `pr-<n>-<action>`) was announced on
/// `issue`; `false` when it already was.
pub fn record_link(
    database: &Database,
    repository: &str,
    object: &str,
    issue: &str,
) -> Result<bool> {
    let connection = database.connection()?;
    let id: i64 = connection.query_row(
        "SELECT id FROM repositories WHERE name = ?1",
        [repository],
        |row| row.get(0),
    )?;
    Ok(connection.execute(
        "INSERT OR IGNORE INTO issue_links (repository_id, object, issue) VALUES (?1, ?2, ?3)",
        params![id, object, issue],
    )? > 0)
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PushedCommit {
    pub sha: String,
    pub subject: String,
    pub body: String,
}

/// Commits new to the ref: `before..after`, or the last few when the ref was created.
pub fn pushed_commits(repository: &Path, before: &str, after: &str) -> Result<Vec<PushedCommit>> {
    if after.chars().all(|c| c == '0') {
        return Ok(Vec::new());
    }
    let range = if before.chars().all(|c| c == '0') {
        after.to_string()
    } else {
        format!("{before}..{after}")
    };
    let out = git_process::output(
        repository,
        [
            "log",
            &format!("--max-count={MAX_COMMITS_PER_PUSH}"),
            "--format=%H%x1f%s%x1f%b%x1e",
            &range,
        ],
    )?;
    Ok(out
        .split('\u{1e}')
        .filter_map(|record| {
            let mut parts = record.trim_start_matches(['\n', '\r']).splitn(3, '\u{1f}');
            let sha = parts.next()?.trim().to_string();
            if sha.len() != 40 {
                return None;
            }
            Some(PushedCommit {
                sha,
                subject: parts.next().unwrap_or("").trim().to_string(),
                body: parts.next().unwrap_or("").trim().to_string(),
            })
        })
        .collect())
}

/// Comment on every issue the pushed commits mention (once per commit and issue).
/// Returns how many comments were posted.
pub fn announce_push(
    database: &Database,
    tracker: &dyn Tracker,
    repository: &str,
    commits: &[PushedCommit],
    reference: &str,
    pusher: &str,
    public_origin: &str,
) -> Result<usize> {
    let branch = reference.strip_prefix("refs/heads/").unwrap_or(reference);
    let mut posted = 0;
    for commit in commits {
        let text = format!("{}\n{}", commit.subject, commit.body);
        for issue in issue_refs(&text) {
            if !record_link(database, repository, &commit.sha, &issue.id)? {
                continue;
            }
            if !tracker.enabled() {
                continue;
            }
            let link = format!(
                "{}/repos/{repository}/commit/{}",
                public_origin.trim_end_matches('/'),
                commit.sha
            );
            let verb = if issue.closes {
                "fixes this issue"
            } else {
                "mentions this issue"
            };
            let who = if pusher.is_empty() {
                String::new()
            } else {
                format!(" pushed by {pusher}")
            };
            let body = format!(
                "AkurAI Build: commit {} on {repository}/{branch}{who} {verb}: {}\n{link}",
                &commit.sha[..12],
                commit.subject
            );
            tracker.comment(&issue.id, &body)?;
            posted += 1;
        }
    }
    Ok(posted)
}

/// Comment on every issue a pull request's title or body mentions, once per action.
#[allow(clippy::too_many_arguments)]
pub fn announce_pull(
    database: &Database,
    tracker: &dyn Tracker,
    repository: &str,
    number: i64,
    action: &str,
    title: &str,
    body: &str,
    public_origin: &str,
) -> Result<usize> {
    let mut posted = 0;
    let object = format!("pr-{number}-{action}");
    for issue in issue_refs(&format!("{title}\n{body}")) {
        if !record_link(database, repository, &object, &issue.id)? {
            continue;
        }
        if !tracker.enabled() {
            continue;
        }
        let link = format!(
            "{}/app/repositories/{repository}/pulls/{number}",
            public_origin.trim_end_matches('/')
        );
        let verb = if issue.closes && action == "merged" {
            "fixes this issue"
        } else {
            "mentions this issue"
        };
        let text = format!(
            "AkurAI Build: pull request #{number} {action} on {repository} {verb}: {title}\n{link}"
        );
        tracker.comment(&issue.id, &text)?;
        posted += 1;
    }
    Ok(posted)
}

#[cfg(test)]
pub mod testing {
    use super::*;
    use std::sync::Mutex;

    /// Records comments instead of sending them.
    #[derive(Default)]
    pub struct RecordingTracker {
        pub comments: Mutex<Vec<(String, String)>>,
    }

    impl Tracker for RecordingTracker {
        fn comment(&self, issue: &str, body: &str) -> Result<()> {
            self.comments
                .lock()
                .expect("lock")
                .push((issue.to_string(), body.to_string()));
            Ok(())
        }
    }
}

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

    const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";

    fn database() -> Database {
        let db = Database::memory(KEY).expect("db");
        db.add_repository("demo", "https://example.com/demo.git", "main")
            .expect("repo");
        db
    }

    #[test]
    fn issue_ids_are_found_with_closing_words_and_deduplicated() {
        let refs = issue_refs(
            "OLI-314: detect language (fixes OLI-314, see OLI-290). Not SHA-1 or x-1 or oli-5.",
        );
        assert_eq!(refs.len(), 2);
        assert_eq!(
            refs[0],
            IssueRef {
                id: "OLI-314".into(),
                closes: true
            }
        );
        assert_eq!(
            refs[1],
            IssueRef {
                id: "OLI-290".into(),
                closes: false
            }
        );
        assert!(
            issue_refs("Resolves ABC-7.")
                .iter()
                .all(|r| r.id == "ABC-7" && r.closes)
        );
        assert!(issue_refs("nothing here 2026-10-03").is_empty());
    }

    #[test]
    fn links_are_announced_once_per_object_and_issue() {
        let db = database();
        let tracker = RecordingTracker::default();
        let commits = vec![
            PushedCommit {
                sha: "a".repeat(40),
                subject: "OLI-1: first".into(),
                body: "fixes OLI-2".into(),
            },
            PushedCommit {
                sha: "b".repeat(40),
                subject: "no issue".into(),
                body: String::new(),
            },
        ];
        let posted = announce_push(
            &db,
            &tracker,
            "demo",
            &commits,
            "refs/heads/main",
            "ada@example.is",
            "https://build.example/",
        )
        .expect("ok");
        assert_eq!(posted, 2);
        let comments = tracker.comments.lock().expect("lock").clone();
        assert_eq!(comments[0].0, "OLI-1");
        assert!(
            comments[0].1.contains("mentions this issue")
                && comments[0]
                    .1
                    .contains("https://build.example/repos/demo/commit/aaaaaaaaaaaa")
        );
        assert!(
            comments[1].1.contains("fixes this issue")
                && comments[1].1.contains("pushed by ada@example.is")
        );
        drop(comments);
        let again = announce_push(
            &db,
            &tracker,
            "demo",
            &commits,
            "refs/heads/main",
            "",
            "https://build.example",
        )
        .expect("ok");
        assert_eq!(again, 0, "the same commits are not announced twice");
        let pr = announce_pull(
            &db,
            &tracker,
            "demo",
            7,
            "merged",
            "Fix OLI-3",
            "",
            "https://build.example",
        )
        .expect("ok");
        assert_eq!(pr, 1);
        assert_eq!(
            announce_pull(
                &db,
                &tracker,
                "demo",
                7,
                "merged",
                "Fix OLI-3",
                "",
                "https://build.example"
            )
            .expect("ok"),
            0
        );
        assert_eq!(
            announce_pull(
                &db,
                &tracker,
                "demo",
                7,
                "opened",
                "Fix OLI-3",
                "",
                "https://build.example"
            )
            .expect("ok"),
            1,
            "another action is another link"
        );
        let none = NoTracker;
        assert_eq!(
            announce_pull(&db, &none, "demo", 8, "opened", "OLI-9", "", "x").expect("ok"),
            0,
            "no tracker: recorded, nothing posted"
        );
        assert!(
            !record_link(&db, "demo", "pr-8-opened", "OLI-9").expect("ok"),
            "but the link was recorded"
        );
    }

    #[test]
    fn pushed_commits_cover_the_range_or_the_new_ref() {
        let temp = tempfile::tempdir().expect("tempdir");
        let work = temp.path().join("w");
        std::fs::create_dir_all(&work).expect("mkdir");
        let git = |args: &[&str]| {
            let status = Command::new("git")
                .arg("-C")
                .arg(&work)
                .args(args)
                .status()
                .expect("git");
            assert!(status.success(), "git {args:?}");
        };
        git(&["init", "-q", "-b", "main"]);
        git(&["config", "user.name", "Ada"]);
        git(&["config", "user.email", "ada@example.invalid"]);
        git(&["commit", "-q", "--allow-empty", "-m", "one OLI-1"]);
        let first = git_process::output(&work, ["rev-parse", "HEAD"])
            .expect("sha")
            .trim()
            .to_string();
        git(&[
            "commit",
            "-q",
            "--allow-empty",
            "-m",
            "two",
            "-m",
            "fixes OLI-2",
        ]);
        let second = git_process::output(&work, ["rev-parse", "HEAD"])
            .expect("sha")
            .trim()
            .to_string();
        let range = pushed_commits(&work, &first, &second).expect("ok");
        assert_eq!(range.len(), 1);
        assert_eq!(
            (
                range[0].sha.as_str(),
                range[0].subject.as_str(),
                range[0].body.as_str()
            ),
            (second.as_str(), "two", "fixes OLI-2")
        );
        let created = pushed_commits(&work, &"0".repeat(40), &second).expect("ok");
        assert_eq!(created.len(), 2);
        assert!(
            pushed_commits(&work, &second, &"0".repeat(40))
                .expect("ok")
                .is_empty(),
            "deletions carry no commits"
        );
    }
}