Menu
Public repository / 8d41f1e10aceed387d425b43038f2070e399a349
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"
);
}
}