AkurAI Build
Menu

Public repository / 0e01aafc5042febc63a50aafdb66008ea3187006

AkurAI-Build / src/server/pulls_api.rs

Latest change 0e01aafc5042febc63a50aafdb66008ea3187006 - Pull requests with reviews, comments and server-side merge/squash/rebase (OLI-267) by Ólafur Búi Ólafsson

//! `/api/repos/{repository}/pulls` handlers: pull requests, reviews, review
//! comments and the server-side merge on the hosted bare repository.

use std::path::PathBuf;

use super::*;
use crate::{
    history,
    merge::{self, Actor, Merge, MergeError, Method},
    pulls::{self, NewPullRequest, ReviewState},
};

const DEFAULT_ACTOR: &str = "AkurAI Build";
const ACTOR_EMAIL: &str = "build@akurai.invalid";

#[derive(Deserialize)]
pub(super) struct CreatePull {
    title: String,
    #[serde(default)]
    body: String,
    base: String,
    head: String,
    author: Option<String>,
}

#[derive(Deserialize)]
pub(super) struct ListPulls {
    state: Option<String>,
}

#[derive(Deserialize)]
pub(super) struct CreateReview {
    state: String,
    #[serde(default)]
    body: String,
    author: Option<String>,
}

#[derive(Deserialize)]
pub(super) struct CreateComment {
    body: String,
    path: Option<String>,
    line: Option<i64>,
    author: Option<String>,
}

#[derive(Deserialize)]
pub(super) struct MergePull {
    #[serde(default = "default_method")]
    method: String,
    message: Option<String>,
    author: Option<String>,
}

fn default_method() -> String {
    "merge".to_owned()
}

#[derive(Serialize)]
pub(super) struct PullDetail {
    #[serde(flatten)]
    pull: pulls::PullRequest,
    reviews: Vec<pulls::Review>,
    comments: Vec<pulls::ReviewComment>,
    comparison: Option<history::Comparison>,
}

fn hosted_path(state: &AppState, repository: &str) -> Result<PathBuf, ApiError> {
    config::validate_repo_name(repository).map_err(ApiError::bad)?;
    let path = state.hosted_root.join(format!("{repository}.git"));
    if path.is_dir() {
        Ok(path)
    } else {
        Err(ApiError::conflict(format!(
            "{repository} is not hosted on this forge, pull requests need a hosted repository"
        )))
    }
}

fn store_error(error: anyhow::Error) -> ApiError {
    let message = error.to_string();
    if message.starts_with("unknown pull request") || message.starts_with("unknown repository") {
        ApiError::not_found(message)
    } else if message.contains("not open")
        || message.contains("not closed")
        || message.contains("already exists")
    {
        ApiError::conflict(message)
    } else {
        ApiError::bad(message)
    }
}

fn merge_error(error: anyhow::Error) -> ApiError {
    match error.downcast_ref::<MergeError>() {
        Some(known) => ApiError::conflict(known.to_string()),
        None => ApiError::bad(error),
    }
}

fn actor_name(author: Option<String>) -> String {
    author.unwrap_or_else(|| DEFAULT_ACTOR.to_owned())
}

pub(super) async fn create_pull(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
    Json(request): Json<CreatePull>,
) -> Result<(StatusCode, Json<Envelope<pulls::PullRequest>>), ApiError> {
    authorize_mutation(&headers, &state)?;
    known_repository(&state, &repository)?;
    let repo = hosted_path(&state, &repository)?;
    let author = actor_name(request.author);
    let database = state.database.clone();
    let pull = state
        .blocking_git
        .run(move || {
            for branch in [&request.base, &request.head] {
                config::validate_ref(branch).map_err(ApiError::bad)?;
                let reference = format!("refs/heads/{branch}");
                git_process::output(&repo, ["rev-parse", "--verify", "--quiet", &reference])
                    .map_err(|_| ApiError::bad(format!("unknown branch {branch}")))?;
            }
            pulls::create(
                &database,
                &repository,
                &NewPullRequest {
                    title: &request.title,
                    body: &request.body,
                    author: &author,
                    base_ref: &request.base,
                    head_ref: &request.head,
                },
            )
            .map_err(store_error)
        })
        .await?;
    Ok((StatusCode::CREATED, ok(pull)))
}

pub(super) async fn list_pulls(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path(repository): Path<String>,
    Query(query): Query<ListPulls>,
) -> Result<Json<Envelope<Vec<pulls::PullRequest>>>, ApiError> {
    authorize(&headers, &state)?;
    known_repository(&state, &repository)?;
    if let Some(status) = query.state.as_deref()
        && !["open", "closed", "merged"].contains(&status)
    {
        return Err(ApiError::bad("state must be open, closed or merged"));
    }
    let list = pulls::list(&state.database, &repository, query.state.as_deref())
        .map_err(ApiError::internal)?;
    Ok(ok(list))
}

pub(super) async fn pull_detail(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path((repository, number)): Path<(String, i64)>,
) -> Result<Json<Envelope<PullDetail>>, ApiError> {
    authorize(&headers, &state)?;
    let database = state.database.clone();
    let repo = hosted_path(&state, &repository).ok();
    let detail = state
        .blocking_git
        .run(move || {
            let pull = pulls::get(&database, &repository, number).map_err(store_error)?;
            let reviews = pulls::reviews(&database, &repository, number).map_err(store_error)?;
            let comments = pulls::comments(&database, &repository, number).map_err(store_error)?;
            let comparison = repo
                .filter(|_| pull.status == "open")
                .and_then(|repo| history::compare(&repo, &pull.base_ref, &pull.head_ref).ok());
            Ok(PullDetail {
                pull,
                reviews,
                comments,
                comparison,
            })
        })
        .await?;
    Ok(ok(detail))
}

async fn set_open(
    state: AppState,
    headers: HeaderMap,
    repository: String,
    number: i64,
    open: bool,
) -> Result<Json<Envelope<pulls::PullRequest>>, ApiError> {
    authorize_mutation(&headers, &state)?;
    pulls::set_open(&state.database, &repository, number, open)
        .map(ok)
        .map_err(store_error)
}

pub(super) async fn close_pull(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path((repository, number)): Path<(String, i64)>,
) -> Result<Json<Envelope<pulls::PullRequest>>, ApiError> {
    set_open(state, headers, repository, number, false).await
}

pub(super) async fn reopen_pull(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path((repository, number)): Path<(String, i64)>,
) -> Result<Json<Envelope<pulls::PullRequest>>, ApiError> {
    set_open(state, headers, repository, number, true).await
}

pub(super) async fn create_review(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path((repository, number)): Path<(String, i64)>,
    Json(request): Json<CreateReview>,
) -> Result<(StatusCode, Json<Envelope<pulls::Review>>), ApiError> {
    authorize_mutation(&headers, &state)?;
    let review_state = ReviewState::parse(&request.state).map_err(ApiError::bad)?;
    let review = pulls::add_review(
        &state.database,
        &repository,
        number,
        &actor_name(request.author),
        review_state,
        &request.body,
    )
    .map_err(store_error)?;
    Ok((StatusCode::CREATED, ok(review)))
}

pub(super) async fn create_comment(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path((repository, number)): Path<(String, i64)>,
    Json(request): Json<CreateComment>,
) -> Result<(StatusCode, Json<Envelope<pulls::ReviewComment>>), ApiError> {
    authorize_mutation(&headers, &state)?;
    let comment = pulls::add_comment(
        &state.database,
        &repository,
        number,
        &actor_name(request.author),
        request.path.as_deref(),
        request.line,
        &request.body,
    )
    .map_err(store_error)?;
    Ok((StatusCode::CREATED, ok(comment)))
}

pub(super) async fn merge_pull(
    State(state): State<AppState>,
    headers: HeaderMap,
    Path((repository, number)): Path<(String, i64)>,
    Json(request): Json<MergePull>,
) -> Result<Json<Envelope<pulls::PullRequest>>, ApiError> {
    authorize_mutation(&headers, &state)?;
    let method = Method::parse(&request.method).map_err(ApiError::bad)?;
    let repo = hosted_path(&state, &repository)?;
    let database = state.database.clone();
    let actor = actor_name(request.author);
    let merged = state
        .blocking_git
        .run(move || {
            let pull = pulls::get(&database, &repository, number).map_err(store_error)?;
            if pull.status != "open" {
                return Err(ApiError::conflict(format!(
                    "pull request #{number} is not open"
                )));
            }
            let message = request.message.unwrap_or_else(|| match method {
                Method::Squash => format!("{} (#{number})", pull.title),
                _ => format!(
                    "Merge pull request #{number} from {}\n\n{}",
                    pull.head_ref, pull.title
                ),
            });
            let tip = merge::merge(
                &repo,
                &Merge {
                    base: &pull.base_ref,
                    head: &pull.head_ref,
                    method,
                    message: &message,
                    actor: Actor {
                        name: &actor,
                        email: ACTOR_EMAIL,
                    },
                },
            )
            .map_err(merge_error)?;
            pulls::record_merge(
                &database,
                &repository,
                number,
                &request.method,
                &tip,
                &actor,
            )
            .map_err(store_error)
        })
        .await?;
    Ok(ok(merged))
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::process::Command;
    use tempfile::tempdir;
    use tower::ServiceExt;

    fn git(dir: &std::path::Path, args: &[&str]) {
        let status = Command::new("git")
            .arg("-C")
            .arg(dir)
            .args(args)
            .status()
            .expect("git");
        assert!(status.success(), "git {args:?}");
    }

    fn commit_file(dir: &std::path::Path, file: &str, message: &str) {
        std::fs::write(dir.join(file), message).expect("write");
        git(dir, &["add", "."]);
        git(dir, &["commit", "-m", message]);
    }

    /// data root with hosted/demo.git holding main and a topic branch.
    fn hosted_fixture() -> (tempfile::TempDir, Router, Database) {
        let root = tempdir().expect("root");
        let work = root.path().join("work");
        std::fs::create_dir_all(&work).expect("work");
        git(&work, &["init", "-b", "main"]);
        git(&work, &["config", "user.name", "Ada"]);
        git(&work, &["config", "user.email", "ada@example.invalid"]);
        commit_file(&work, "base.txt", "base");
        git(&work, &["checkout", "-b", "topic"]);
        commit_file(&work, "feature.txt", "feature");
        git(&work, &["checkout", "main"]);
        let hosted = root.path().join("hosted");
        std::fs::create_dir_all(&hosted).expect("hosted");
        let bare = hosted.join("demo.git");
        git(
            &work,
            &["clone", "--bare", ".", bare.to_str().expect("utf8")],
        );
        let mut app_state = crate::server::tests::test_state("api-token-123", "webhook-secret");
        app_state.hosted_root = hosted;
        let database = app_state.database.clone();
        database
            .add_repository("demo", "https://example.com/demo.git", "main")
            .expect("repo");
        let router = Router::new()
            .route(
                "/api/repos/{repository}/pulls",
                get(list_pulls).post(create_pull),
            )
            .route("/api/repos/{repository}/pulls/{number}", get(pull_detail))
            .route(
                "/api/repos/{repository}/pulls/{number}/close",
                post(close_pull),
            )
            .route(
                "/api/repos/{repository}/pulls/{number}/reopen",
                post(reopen_pull),
            )
            .route(
                "/api/repos/{repository}/pulls/{number}/reviews",
                post(create_review),
            )
            .route(
                "/api/repos/{repository}/pulls/{number}/comments",
                post(create_comment),
            )
            .route(
                "/api/repos/{repository}/pulls/{number}/merge",
                post(merge_pull),
            )
            .with_state(app_state);
        (root, router, database)
    }

    async fn send(
        app: &Router,
        method: &str,
        uri: &str,
        body: serde_json::Value,
    ) -> (StatusCode, serde_json::Value) {
        let request = Request::builder()
            .method(method)
            .uri(uri)
            .header(header::AUTHORIZATION, "Bearer api-token-123")
            .header(header::CONTENT_TYPE, "application/json")
            .body(Body::from(body.to_string()))
            .expect("request");
        let response = app.clone().oneshot(request).await.expect("response");
        (
            response.status(),
            crate::server::tests::json_body(response).await,
        )
    }

    #[tokio::test]
    async fn pull_request_lifecycle_review_and_squash_merge() {
        let (_root, app, _database) = hosted_fixture();
        let (status, created) = send(
            &app,
            "POST",
            "/api/repos/demo/pulls",
            serde_json::json!({
            "title": "Add feature", "base": "main", "head": "topic", "author": "ada"}),
        )
        .await;
        assert_eq!(status, StatusCode::CREATED, "{created}");
        assert_eq!(created["data"]["number"], 1);

        let (status, duplicate) = send(
            &app,
            "POST",
            "/api/repos/demo/pulls",
            serde_json::json!({
            "title": "Again", "base": "main", "head": "topic"}),
        )
        .await;
        assert_eq!(status, StatusCode::CONFLICT, "{duplicate}");
        let (status, _) = send(
            &app,
            "POST",
            "/api/repos/demo/pulls",
            serde_json::json!({
            "title": "Ghost", "base": "main", "head": "ghost"}),
        )
        .await;
        assert_eq!(status, StatusCode::BAD_REQUEST);

        let (status, _) = send(
            &app,
            "POST",
            "/api/repos/demo/pulls/1/reviews",
            serde_json::json!({"state": "approved", "author": "bob"}),
        )
        .await;
        assert_eq!(status, StatusCode::CREATED);
        let (status, _) = send(
            &app,
            "POST",
            "/api/repos/demo/pulls/1/comments",
            serde_json::json!({"body": "nice", "path": "feature.txt", "line": 1}),
        )
        .await;
        assert_eq!(status, StatusCode::CREATED);

        let (_, detail) = send(
            &app,
            "GET",
            "/api/repos/demo/pulls/1",
            serde_json::json!(null),
        )
        .await;
        assert_eq!(detail["data"]["reviews"][0]["state"], "approved");
        assert_eq!(detail["data"]["comments"][0]["path"], "feature.txt");
        assert_eq!(
            detail["data"]["comparison"]["diff"]["files"][0]["path"],
            "feature.txt"
        );

        let (status, merged) = send(
            &app,
            "POST",
            "/api/repos/demo/pulls/1/merge",
            serde_json::json!({"method": "squash", "author": "ada"}),
        )
        .await;
        assert_eq!(status, StatusCode::OK, "{merged}");
        assert_eq!(merged["data"]["status"], "merged");
        assert_eq!(merged["data"]["merge_sha"].as_str().map(str::len), Some(40));

        let (status, again) = send(
            &app,
            "POST",
            "/api/repos/demo/pulls/1/merge",
            serde_json::json!({}),
        )
        .await;
        assert_eq!(status, StatusCode::CONFLICT, "{again}");
        let (_, merged_list) = send(
            &app,
            "GET",
            "/api/repos/demo/pulls?state=merged",
            serde_json::json!(null),
        )
        .await;
        assert_eq!(merged_list["data"].as_array().map(Vec::len), Some(1));
    }

    #[tokio::test]
    async fn closed_pulls_cannot_merge_and_conflicts_are_409() {
        let (root, app, _database) = hosted_fixture();
        send(
            &app,
            "POST",
            "/api/repos/demo/pulls",
            serde_json::json!({
            "title": "Add feature", "base": "main", "head": "topic"}),
        )
        .await;
        send(
            &app,
            "POST",
            "/api/repos/demo/pulls/1/close",
            serde_json::json!({}),
        )
        .await;
        let (status, _) = send(
            &app,
            "POST",
            "/api/repos/demo/pulls/1/merge",
            serde_json::json!({}),
        )
        .await;
        assert_eq!(status, StatusCode::CONFLICT);
        send(
            &app,
            "POST",
            "/api/repos/demo/pulls/1/reopen",
            serde_json::json!({}),
        )
        .await;

        let work = root.path().join("work");
        git(&work, &["checkout", "-q", "main"]);
        commit_file(&work, "feature.txt", "main side");
        git(
            &work,
            &[
                "push",
                "-q",
                root.path().join("hosted/demo.git").to_str().expect("utf8"),
                "main",
            ],
        );
        let (status, body) = send(
            &app,
            "POST",
            "/api/repos/demo/pulls/1/merge",
            serde_json::json!({"method": "merge"}),
        )
        .await;
        assert_eq!(status, StatusCode::CONFLICT, "{body}");
        let (_, detail) = send(
            &app,
            "GET",
            "/api/repos/demo/pulls/1",
            serde_json::json!(null),
        )
        .await;
        assert_eq!(detail["data"]["status"], "open");
        let (status, _) = send(
            &app,
            "POST",
            "/api/repos/demo/pulls/1/merge",
            serde_json::json!({"method": "octopus"}),
        )
        .await;
        assert_eq!(status, StatusCode::BAD_REQUEST);
    }
}