Menu
Public repository / main
AkurAI-Build / src/pulls.rs
Latest change b3f9a218e1ff51ec87a0dd7c07ff6b98fdb47905 - OLI-275: notifications feed, page and mail for reviews, merges and failed runs by Ólafur Búi Ólafsson
//! Pull requests, reviews and review comments. Rows only; the diff comes from
//! `history::compare` and merging from `merge::merge`.
use anyhow::{Result, bail, ensure};
use rusqlite::{OptionalExtension, Row, TransactionBehavior, params};
use serde::Serialize;
use crate::{config::validate_ref, db::Database};
const MAX_TITLE: usize = 256;
const MAX_BODY: usize = 65_536;
const MAX_AUTHOR: usize = 128;
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ReviewState {
Approved,
ChangesRequested,
Commented,
}
impl ReviewState {
pub fn parse(value: &str) -> Result<Self> {
match value {
"approved" => Ok(Self::Approved),
"changes_requested" => Ok(Self::ChangesRequested),
"commented" => Ok(Self::Commented),
other => bail!("unknown review state {other:?}"),
}
}
fn as_str(self) -> &'static str {
match self {
Self::Approved => "approved",
Self::ChangesRequested => "changes_requested",
Self::Commented => "commented",
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct PullRequest {
pub number: i64,
pub title: String,
pub body: String,
pub author: String,
pub base_ref: String,
pub head_ref: String,
pub status: String,
pub merge_method: Option<String>,
pub merge_sha: Option<String>,
pub merged_by: Option<String>,
pub created_at: i64,
pub updated_at: i64,
pub merged_at: Option<i64>,
}
#[derive(Clone, Debug, Serialize)]
pub struct Review {
pub id: i64,
pub author: String,
pub state: String,
pub body: String,
pub created_at: i64,
}
#[derive(Clone, Debug, Serialize)]
pub struct ReviewComment {
pub id: i64,
pub review_id: Option<i64>,
pub author: String,
pub path: Option<String>,
pub line: Option<i64>,
pub body: String,
pub created_at: i64,
}
pub struct NewPullRequest<'a> {
pub title: &'a str,
pub body: &'a str,
pub author: &'a str,
pub base_ref: &'a str,
pub head_ref: &'a str,
}
const COLUMNS: &str = "p.number, p.title, p.body, p.author, p.base_ref, p.head_ref, p.status, \
p.merge_method, p.merge_sha, p.merged_by, p.created_at, p.updated_at, p.merged_at";
const FROM_REPOSITORY: &str =
"FROM pull_requests p JOIN repositories r ON r.id = p.repository_id WHERE r.name = ?1";
fn map_pull(row: &Row<'_>) -> rusqlite::Result<PullRequest> {
Ok(PullRequest {
number: row.get(0)?,
title: row.get(1)?,
body: row.get(2)?,
author: row.get(3)?,
base_ref: row.get(4)?,
head_ref: row.get(5)?,
status: row.get(6)?,
merge_method: row.get(7)?,
merge_sha: row.get(8)?,
merged_by: row.get(9)?,
created_at: row.get(10)?,
updated_at: row.get(11)?,
merged_at: row.get(12)?,
})
}
fn validate_text(label: &str, value: &str, max: usize) -> Result<()> {
ensure!(
!value.trim().is_empty() && value.len() <= max,
"{label} must be 1 to {max} bytes"
);
Ok(())
}
pub fn create(
database: &Database,
repository: &str,
new: &NewPullRequest<'_>,
) -> Result<PullRequest> {
validate_text("title", new.title, MAX_TITLE)?;
validate_text("author", new.author, MAX_AUTHOR)?;
ensure!(new.body.len() <= MAX_BODY, "body is too long");
validate_ref(new.base_ref)?;
validate_ref(new.head_ref)?;
ensure!(new.base_ref != new.head_ref, "base and head must differ");
let mut connection = database.connection()?;
let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?;
let repository_id: i64 = transaction
.query_row(
"SELECT id FROM repositories WHERE name = ?1",
[repository],
|row| row.get(0),
)
.optional()?
.ok_or_else(|| anyhow::anyhow!("unknown repository {repository}"))?;
let duplicate: bool = transaction.query_row(
"SELECT EXISTS(SELECT 1 FROM pull_requests WHERE repository_id = ?1 AND status = 'open' \
AND base_ref = ?2 AND head_ref = ?3)",
params![repository_id, new.base_ref, new.head_ref],
|row| row.get(0),
)?;
ensure!(
!duplicate,
"an open pull request for these branches already exists"
);
let number: i64 = transaction.query_row(
"SELECT COALESCE(MAX(number), 0) + 1 FROM pull_requests WHERE repository_id = ?1",
[repository_id],
|row| row.get(0),
)?;
transaction.execute(
"INSERT INTO pull_requests(repository_id, number, title, body, author, base_ref, head_ref) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![
repository_id,
number,
new.title,
new.body,
new.author,
new.base_ref,
new.head_ref
],
)?;
transaction.commit()?;
drop(connection);
get(database, repository, number)
}
pub fn get(database: &Database, repository: &str, number: i64) -> Result<PullRequest> {
database
.connection()?
.query_row(
&format!("SELECT {COLUMNS} {FROM_REPOSITORY} AND p.number = ?2"),
params![repository, number],
map_pull,
)
.optional()?
.ok_or_else(|| anyhow::anyhow!("unknown pull request #{number}"))
}
pub fn list(
database: &Database,
repository: &str,
status: Option<&str>,
) -> Result<Vec<PullRequest>> {
let connection = database.connection()?;
let mut statement = connection.prepare(&format!(
"SELECT {COLUMNS} {FROM_REPOSITORY} AND (?2 IS NULL OR p.status = ?2) ORDER BY p.number DESC"
))?;
Ok(statement
.query_map(params![repository, status], map_pull)?
.collect::<rusqlite::Result<_>>()?)
}
/// Moves an open pull request to `closed`, or a closed one back to `open`.
pub fn set_open(
database: &Database,
repository: &str,
number: i64,
open: bool,
) -> Result<PullRequest> {
let (from, to) = if open {
("closed", "open")
} else {
("open", "closed")
};
let changed = database.connection()?.execute(
"UPDATE pull_requests SET status = ?3, updated_at = unixepoch() WHERE number = ?2 \
AND status = ?4 AND repository_id = (SELECT id FROM repositories WHERE name = ?1)",
params![repository, number, to, from],
)?;
ensure!(changed == 1, "pull request #{number} is not {from}");
get(database, repository, number)
}
pub fn record_merge(
database: &Database,
repository: &str,
number: i64,
method: &str,
merge_sha: &str,
merged_by: &str,
) -> Result<PullRequest> {
let changed = database.connection()?.execute(
"UPDATE pull_requests SET status = 'merged', merge_method = ?3, merge_sha = ?4, \
merged_by = ?5, merged_at = unixepoch(), updated_at = unixepoch() WHERE number = ?2 \
AND status = 'open' AND repository_id = (SELECT id FROM repositories WHERE name = ?1)",
params![repository, number, method, merge_sha, merged_by],
)?;
ensure!(changed == 1, "pull request #{number} is not open");
get(database, repository, number)
}
fn pull_id(connection: &rusqlite::Connection, repository: &str, number: i64) -> Result<i64> {
connection
.query_row(
"SELECT p.id FROM pull_requests p JOIN repositories r ON r.id = p.repository_id \
WHERE r.name = ?1 AND p.number = ?2",
params![repository, number],
|row| row.get(0),
)
.optional()?
.ok_or_else(|| anyhow::anyhow!("unknown pull request #{number}"))
}
pub fn add_review(
database: &Database,
repository: &str,
number: i64,
author: &str,
state: ReviewState,
body: &str,
) -> Result<Review> {
validate_text("author", author, MAX_AUTHOR)?;
ensure!(body.len() <= MAX_BODY, "body is too long");
let connection = database.connection()?;
let pull = pull_id(&connection, repository, number)?;
connection.execute(
"INSERT INTO reviews(pull_request_id, author, state, body) VALUES (?1, ?2, ?3, ?4)",
params![pull, author, state.as_str(), body],
)?;
let id = connection.last_insert_rowid();
Ok(connection.query_row(
"SELECT id, author, state, body, created_at FROM reviews WHERE id = ?1",
[id],
|row| {
Ok(Review {
id: row.get(0)?,
author: row.get(1)?,
state: row.get(2)?,
body: row.get(3)?,
created_at: row.get(4)?,
})
},
)?)
}
/// Replace the pull request's requested reviewers (emails, lower-cased, unique).
pub fn set_reviewers(
database: &Database,
repository: &str,
number: i64,
reviewers: &[String],
) -> Result<Vec<String>> {
let connection = database.connection()?;
let id = pull_id(&connection, repository, number)?;
connection.execute(
"DELETE FROM pull_request_reviewers WHERE pull_request_id = ?1",
[id],
)?;
let mut stored: Vec<String> = Vec::new();
for reviewer in reviewers {
let subject = reviewer.trim().to_lowercase();
if subject.is_empty() || stored.contains(&subject) {
continue;
}
validate_text("reviewer", &subject, MAX_AUTHOR)?;
connection.execute(
"INSERT INTO pull_request_reviewers (pull_request_id, subject) VALUES (?1, ?2)",
params![id, subject],
)?;
stored.push(subject);
}
Ok(stored)
}
pub fn reviewers(database: &Database, repository: &str, number: i64) -> Result<Vec<String>> {
let connection = database.connection()?;
let id = pull_id(&connection, repository, number)?;
let mut statement = connection.prepare(
"SELECT subject FROM pull_request_reviewers WHERE pull_request_id = ?1 ORDER BY subject",
)?;
let rows = statement.query_map([id], |row| row.get::<_, String>(0))?;
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
}
pub fn reviews(database: &Database, repository: &str, number: i64) -> Result<Vec<Review>> {
let connection = database.connection()?;
let pull = pull_id(&connection, repository, number)?;
let mut statement = connection
.prepare("SELECT id, author, state, body, created_at FROM reviews WHERE pull_request_id = ?1 ORDER BY id")?;
Ok(statement
.query_map([pull], |row| {
Ok(Review {
id: row.get(0)?,
author: row.get(1)?,
state: row.get(2)?,
body: row.get(3)?,
created_at: row.get(4)?,
})
})?
.collect::<rusqlite::Result<_>>()?)
}
pub fn add_comment(
database: &Database,
repository: &str,
number: i64,
author: &str,
path: Option<&str>,
line: Option<i64>,
body: &str,
) -> Result<ReviewComment> {
validate_text("author", author, MAX_AUTHOR)?;
validate_text("body", body, MAX_BODY)?;
ensure!(line.is_none_or(|line| line > 0), "line must be positive");
ensure!(
line.is_none() || path.is_some(),
"a line comment needs a path"
);
if let Some(path) = path {
crate::tree::validate_code_path(path)?;
}
let connection = database.connection()?;
let pull = pull_id(&connection, repository, number)?;
connection.execute(
"INSERT INTO review_comments(pull_request_id, author, path, line, body) VALUES (?1, ?2, ?3, ?4, ?5)",
params![pull, author, path, line, body],
)?;
let id = connection.last_insert_rowid();
Ok(connection.query_row(
"SELECT id, review_id, author, path, line, body, created_at FROM review_comments WHERE id = ?1",
[id],
map_comment,
)?)
}
fn map_comment(row: &Row<'_>) -> rusqlite::Result<ReviewComment> {
Ok(ReviewComment {
id: row.get(0)?,
review_id: row.get(1)?,
author: row.get(2)?,
path: row.get(3)?,
line: row.get(4)?,
body: row.get(5)?,
created_at: row.get(6)?,
})
}
pub fn comments(database: &Database, repository: &str, number: i64) -> Result<Vec<ReviewComment>> {
let connection = database.connection()?;
let pull = pull_id(&connection, repository, number)?;
let mut statement = connection.prepare(
"SELECT id, review_id, author, path, line, body, created_at FROM review_comments \
WHERE pull_request_id = ?1 ORDER BY id",
)?;
Ok(statement
.query_map([pull], map_comment)?
.collect::<rusqlite::Result<_>>()?)
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
fn database() -> Database {
let database = Database::memory(KEY).expect("database");
database
.add_repository("demo", "https://example.com/demo.git", "main")
.expect("repo");
database
.add_repository("other", "https://example.com/other.git", "main")
.expect("repo");
database
}
fn new<'a>(head: &'a str) -> NewPullRequest<'a> {
NewPullRequest {
title: "Add feature",
body: "why",
author: "ada",
base_ref: "main",
head_ref: head,
}
}
#[test]
fn numbers_are_per_repository_and_duplicates_are_refused() {
let db = database();
assert_eq!(create(&db, "demo", &new("a")).expect("fixture").number, 1);
assert_eq!(create(&db, "demo", &new("b")).expect("fixture").number, 2);
assert_eq!(create(&db, "other", &new("a")).expect("fixture").number, 1);
let error = create(&db, "demo", &new("a")).expect_err("duplicate");
assert!(error.to_string().contains("already exists"));
assert!(
create(
&db,
"demo",
&NewPullRequest {
head_ref: "main",
..new("main")
}
)
.is_err()
);
assert!(
create(
&db,
"demo",
&NewPullRequest {
title: " ",
..new("c")
}
)
.is_err()
);
assert!(create(&db, "demo", &new("-bad")).is_err());
assert!(create(&db, "missing", &new("a")).is_err());
}
#[test]
fn close_reopen_and_merge_follow_the_status_machine() {
let db = database();
create(&db, "demo", &new("a")).expect("fixture");
assert_eq!(
set_open(&db, "demo", 1, false).expect("fixture").status,
"closed"
);
assert!(set_open(&db, "demo", 1, false).is_err());
assert!(record_merge(&db, "demo", 1, "merge", &"a".repeat(40), "ada").is_err());
assert_eq!(
set_open(&db, "demo", 1, true).expect("fixture").status,
"open"
);
let merged =
record_merge(&db, "demo", 1, "squash", &"a".repeat(40), "ada").expect("fixture");
assert_eq!(
(merged.status.as_str(), merged.merge_method.as_deref()),
("merged", Some("squash"))
);
assert!(merged.merged_at.is_some());
assert!(set_open(&db, "demo", 1, true).is_err(), "merged is final");
assert_eq!(list(&db, "demo", Some("open")).expect("fixture").len(), 0);
assert_eq!(list(&db, "demo", None).expect("fixture").len(), 1);
}
#[test]
fn reviews_and_comments_attach_to_one_pull_and_cascade_on_repo_delete() {
let db = database();
create(&db, "demo", &new("a")).expect("fixture");
create(&db, "other", &new("a")).expect("fixture");
add_review(
&db,
"demo",
1,
"bob",
ReviewState::ChangesRequested,
"fix it",
)
.expect("fixture");
add_review(&db, "demo", 1, "bob", ReviewState::Approved, "").expect("fixture");
add_comment(&db, "demo", 1, "bob", Some("src/a.rs"), Some(3), "nit").expect("fixture");
assert!(add_comment(&db, "demo", 1, "bob", None, Some(3), "no path").is_err());
assert!(add_comment(&db, "demo", 1, "bob", Some("../x"), None, "bad path").is_err());
assert!(add_comment(&db, "demo", 1, "bob", None, None, "").is_err());
assert!(add_review(&db, "demo", 9, "bob", ReviewState::Commented, "").is_err());
assert_eq!(reviews(&db, "demo", 1).expect("fixture").len(), 2);
assert_eq!(comments(&db, "demo", 1).expect("fixture")[0].line, Some(3));
assert!(reviews(&db, "other", 1).expect("fixture").is_empty());
db.delete_repository("demo").expect("fixture");
let rows: i64 = db.connection().expect("fixture").query_row(
"SELECT (SELECT count(*) FROM reviews) + (SELECT count(*) FROM review_comments) + (SELECT count(*) FROM pull_requests)",
[], |row| row.get(0)).expect("fixture");
assert_eq!(rows, 1, "only other's pull request remains");
assert!(ReviewState::parse("lgtm").is_err());
}
}