AkurAI Build
Menu

Public repository / 8d41f1e10aceed387d425b43038f2070e399a349

AkurAI-Build / src/server/pulls_api.rs

Latest change 3ad115721c03258019aba933eb0d641429b7951d - Fix pull requests resolving hosted repositories from the data root 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>,
    /// Emails asked to review; each gets a notification.
    #[serde(default)]
    reviewers: Vec<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,
    reviewers: Vec<String>,
    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("hosted")
        .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 repository_for_event = repository.clone();
    let reviewers_requested = request.reviewers.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?;
    super::webhooks_api::emit_quietly(
        &state.database,
        &repository_for_event,
        "pull_request",
        serde_json::json!({
            "action": "opened", "number": pull.number, "title": pull.title,
            "author": pull.author, "base": pull.base_ref, "head": pull.head_ref,
        }),
    );
    super::webhooks_api::link_pull(
        &state,
        &repository_for_event,
        pull.number,
        "opened",
        &pull.title,
        &pull.body,
    );
    let reviewers = pulls::set_reviewers(
        &state.database,
        &repository_for_event,
        pull.number,
        &reviewers_requested,
    )
    .map_err(store_error)?;
    let link = format!(
        "{}/app/repositories/{}/pulls/{}",
        state.public_origin.trim_end_matches('/'),
        repository_for_event,
        pull.number
    );
    let _ = crate::notifications::notify_all(
        &state.database,
        &reviewers,
        &crate::notifications::New {
            kind: "review_requested",
            repository: &repository_for_event,
            title: &format!("Review requested: {} (#{})", pull.title, pull.number),
            body: &format!(
                "{} asked you to review pull request #{} ({} -> {}).",
                pull.author, pull.number, pull.head_ref, pull.base_ref
            ),
            link: &link,
        },
    );
    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 reviewers =
                pulls::reviewers(&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,
                reviewers,
                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)?;
    let result = pulls::set_open(&state.database, &repository, number, open);
    if let Ok(pull) = &result {
        super::webhooks_api::emit_quietly(
            &state.database,
            &repository,
            "pull_request",
            serde_json::json!({
                "action": if open { "reopened" } else { "closed" },
                "number": pull.number, "title": pull.title,
                "base": pull.base_ref, "head": pull.head_ref,
            }),
        );
    }
    result.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)?;
    super::webhooks_api::emit_quietly(
        &state.database,
        &repository,
        "pull_request",
        serde_json::json!({
            "action": "reviewed", "number": number, "state": review.state,
            "author": review.author,
        }),
    );
    if let Ok(pull) = pulls::get(&state.database, &repository, number) {
        let link = format!(
            "{}/app/repositories/{}/pulls/{}",
            state.public_origin.trim_end_matches('/'),
            repository,
            number
        );
        let _ = crate::notifications::notify(
            &state.database,
            &pull.author,
            &crate::notifications::New {
                kind: "review_submitted",
                repository: &repository,
                title: &format!(
                    "{} {} #{}: {}",
                    review.author,
                    review.state.replace('_', " "),
                    number,
                    pull.title
                ),
                body: &review.body,
                link: &link,
            },
        );
    }
    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 repository_for_event = repository.clone();
    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 head_sha = crate::git_process::output(
                &repo,
                ["rev-parse", &format!("refs/heads/{}", pull.head_ref)],
            )
            .map_err(merge_error)?;
            let decision = crate::protection::check_merge(
                &database,
                &repository,
                &pull.base_ref,
                number,
                head_sha.trim(),
            )
            .map_err(store_error)?;
            if !decision.allowed {
                return Err(ApiError::conflict(decision.reason));
            }
            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?;
    super::webhooks_api::emit_quietly(
        &state.database,
        &repository_for_event,
        "pull_request",
        serde_json::json!({
            "action": "merged", "number": merged.number, "title": merged.title,
            "merge_sha": merged.merge_sha, "merged_by": merged.merged_by,
            "base": merged.base_ref, "head": merged.head_ref,
        }),
    );
    super::webhooks_api::link_pull(
        &state,
        &repository_for_event,
        merged.number,
        "merged",
        &merged.title,
        &merged.body,
    );
    let mut told =
        pulls::reviewers(&state.database, &repository_for_event, merged.number).unwrap_or_default();
    told.push(merged.author.clone());
    let link = format!(
        "{}/app/repositories/{}/pulls/{}",
        state.public_origin.trim_end_matches('/'),
        repository_for_event,
        merged.number
    );
    let _ = crate::notifications::notify_all(
        &state.database,
        &told,
        &crate::notifications::New {
            kind: "pull_merged",
            repository: &repository_for_event,
            title: &format!("Merged #{}: {}", merged.number, merged.title),
            body: &format!(
                "Merged by {} into {}.",
                merged.merged_by.clone().unwrap_or_default(),
                merged.base_ref
            ),
            link: &link,
        },
    );
    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");
        // Like production: the state carries the data root, hosted repositories live under `hosted/`.
        app_state.hosted_root = root.path().to_owned();
        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 reviewers_are_notified_on_request_review_and_merge() {
        let (_root, app, database) = hosted_fixture();
        let (status, created) = send(
            &app,
            "POST",
            "/api/repos/demo/pulls",
            serde_json::json!({
                "title": "feature", "base": "main", "head": "topic", "author": "ada",
                "reviewers": ["Grace@Example.is", "grace@example.is", "bob@example.is"]
            }),
        )
        .await;
        assert_eq!(status, StatusCode::CREATED, "{created}");
        let (_, detail) = send(
            &app,
            "GET",
            "/api/repos/demo/pulls/1",
            serde_json::json!(null),
        )
        .await;
        assert_eq!(
            detail["data"]["reviewers"],
            serde_json::json!(["bob@example.is", "grace@example.is"])
        );
        let grace = crate::notifications::feed(&database, "grace@example.is", false).expect("feed");
        assert_eq!(grace.len(), 1);
        assert_eq!(grace[0].kind, "review_requested");
        assert!(
            grace[0].title.contains("feature")
                && grace[0].link.ends_with("/app/repositories/demo/pulls/1")
        );

        let (status, _) = send(
            &app,
            "POST",
            "/api/repos/demo/pulls/1/reviews",
            serde_json::json!({"state": "approved", "author": "grace@example.is", "body": "ship it"}),
        )
        .await;
        assert_eq!(status, StatusCode::CREATED);
        let ada = crate::notifications::feed(&database, "ada", false).expect("feed");
        assert_eq!(ada.len(), 1);
        assert_eq!(ada[0].kind, "review_submitted");
        assert!(ada[0].title.contains("approved") && ada[0].body == "ship it");

        let (status, merged) = send(
            &app,
            "POST",
            "/api/repos/demo/pulls/1/merge",
            serde_json::json!({"method": "merge", "author": "ada"}),
        )
        .await;
        assert_eq!(status, StatusCode::OK, "{merged}");
        let bob = crate::notifications::feed(&database, "bob@example.is", false).expect("feed");
        assert_eq!(bob.iter().filter(|n| n.kind == "pull_merged").count(), 1);
        let ada = crate::notifications::feed(&database, "ada", false).expect("feed");
        assert_eq!(
            ada.iter().filter(|n| n.kind == "pull_merged").count(),
            1,
            "the author hears about the merge too"
        );
    }

    #[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);
    }
}