AkurAI Build
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());
    }
}