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