//! Merging a pull request: `POST /{owner}/{repo}/pulls/{number}/merge`. //! //! The viewer must be signed in, send a valid CSRF token and hold Write on the base repo (404 when //! they cannot read it at all, 403 when they can read but not write). The base repo must not be //! archived and the pull request must be open with its head repo still present. //! //! The merge is computed against the current tip of the base branch. Three styles are supported: //! a merge commit, a squash commit authored by the pull request author, and a fast-forward. The //! base ref is moved with a compare-and-swap, so a base branch that moved meanwhile is refused //! with 409 instead of being overwritten. A merge is never a force push, so the pre-receive hook //! does not run for it. After the ref moves, the pull request is marked merged and the same //! post-update work a push gets runs (closing keywords, maintenance, mergeability of other pulls). use forge_core::protection::{Protection, effective}; use forge_core::{Action, IssueState, MergeStyle, PullRequest, ReviewState, Role, User, authorize}; use forge_git::browse::{CommitInfo, default_branch_commit}; use forge_git::pulls::{ MergeCheck, Signature, check_merge, commit_tree, is_ancestor, range_log, update_ref_cas, }; use forge_git::repo::repo_path; use http::HeaderValue; use http::header::CONTENT_TYPE; use serde::Deserialize; use topcoat::Result; use topcoat::context::{Cx, app_context}; use topcoat::router::content::Form; use topcoat::router::error::{bad_request, forbidden, not_found, see_other}; use topcoat::router::response::Response; use topcoat::router::{Body, StatusCode, path_param, route}; use crate::auth::require_csrf; use crate::internal::trigger_maintenance; use crate::notify::{self, Activity}; use crate::pages::issue_parts::parse_number; use crate::pages::repo_ctx::load_repo; use crate::post_update::after_ref_update; use crate::state::AppState; use crate::views::{now_unix, repo_href}; use crate::webhooks::emit::{Sender, emit_item_action}; path_param!(owner); path_param!(repo); path_param!(number); /// Largest accepted merge message, in bytes. const MAX_MESSAGE_BYTES: usize = 64 * 1024; /// Commits listed in a squash message. const MAX_SQUASH_COMMITS: usize = 250; /// Refusal codes, sent as `?merge_error=` on the pull request page. The page owns the /// wording for each code; a code outside this set is never echoed back. pub const ERR_CONFLICT: &str = "conflict"; pub const ERR_UP_TO_DATE: &str = "up_to_date"; pub const ERR_FF_IMPOSSIBLE: &str = "ff_impossible"; pub const ERR_BASE_MOVED: &str = "base_moved"; pub const ERR_ALREADY_MERGED: &str = "already_merged"; pub const ERR_NOT_OPEN: &str = "not_open"; pub const ERR_HEAD_MISSING: &str = "head_missing"; pub const ERR_BASE_MISSING: &str = "base_missing"; pub const ERR_MESSAGE_INVALID: &str = "message_invalid"; pub const ERR_NEEDS_APPROVALS: &str = "needs_approvals"; pub const ERR_CHANGES_REQUESTED: &str = "changes_requested"; pub const ERR_NOT_UP_TO_DATE: &str = "not_up_to_date"; const CONFLICTS_MSG: &str = "This branch has conflicts that must be resolved"; const MOVED_MSG: &str = "The base branch moved; reload and try again."; const UP_TO_DATE_MSG: &str = "Nothing to merge: the head is already contained in the base branch."; const FF_MSG: &str = "fast-forward not possible: the base branch has commits the head does not contain"; const ALREADY_MERGED_MSG: &str = "This pull request is already merged."; const CLOSED_MSG: &str = "This pull request is closed."; const HEAD_MISSING_MSG: &str = "The head repository was deleted, so this pull request cannot be merged."; const BASE_MISSING_MSG: &str = "The base branch does not exist."; #[derive(Deserialize)] struct MergeForm { #[serde(default)] csrf: String, /// `merge`, `squash` or `ff`. Empty means `merge`. #[serde(default)] style: String, /// Replaces the default commit message when not blank. #[serde(default)] message: String, } /// What a merge attempt did: the base branch now points at `commit`, or the attempt was refused /// with an HTTP status and a message for the viewer. #[derive(Debug, PartialEq, Eq)] pub enum MergeOutcome { Merged { commit: String }, Refused { status: StatusCode, message: String }, } #[route(POST "/{owner}/{repo}/pulls/{number}/merge")] async fn merge_post(cx: &Cx, Form(form): Form) -> Result { let number = parse_number(path_param::(cx))?; let ctx = load_repo(cx, path_param::(cx), path_param::(cx)).await?; let (user, _) = require_csrf(cx, &form.csrf).await?; if ctx.repo.archived || authorize( Some(&user), &ctx.repo, ctx.role, ctx.org_role, Action::Write, ) .is_err() { return Err(forbidden().into()); } let state = app_context::(cx); let Some(pr) = state.store.pull_by_number(&ctx.repo.id, number).await? else { return Err(not_found().into()); }; let pull_href = repo_href( &ctx.repo.owner_name, &ctx.repo.name, "pulls", &number.to_string(), ); if pr.merged_at.is_some() { return back_to_pull(&pull_href, ERR_ALREADY_MERGED); } if pr.issue.state != IssueState::Open { return back_to_pull(&pull_href, ERR_NOT_OPEN); } if pr.head_repo_id.is_none() { return back_to_pull(&pull_href, ERR_HEAD_MISSING); } if let Some(code) = merge_gate(state, &ctx.repo, &pr).await?.blocked() { return back_to_pull(&pull_href, code); } let style = if form.style.is_empty() { MergeStyle::Merge } else { form.style .parse::() .map_err(|e| bad_request(e.to_string()))? }; let message = match clean_message(&form.message) { Ok(message) => message, Err(_) => return back_to_pull(&pull_href, ERR_MESSAGE_INVALID), }; match perform_merge( state, &ctx.repo, &pr, &user, style, message.as_deref(), None, ) .await? { MergeOutcome::Merged { .. } => Err(see_other(pull_href).into()), MergeOutcome::Refused { status, message } => match refusal_code(&message) { Some(code) => back_to_pull(&pull_href, code), None => Ok(refusal(status, &message)), }, } } /// Sends the viewer back to the pull request with a refusal code in the query. fn back_to_pull(pull_href: &str, code: &str) -> Result { Err(see_other(format!("{pull_href}?merge_error={code}")).into()) } /// The refusal code for a message `perform_merge` returns, if it has one. fn refusal_code(message: &str) -> Option<&'static str> { Some(match message { CONFLICTS_MSG => ERR_CONFLICT, UP_TO_DATE_MSG => ERR_UP_TO_DATE, FF_MSG => ERR_FF_IMPOSSIBLE, MOVED_MSG => ERR_BASE_MOVED, ALREADY_MERGED_MSG => ERR_ALREADY_MERGED, CLOSED_MSG => ERR_NOT_OPEN, HEAD_MISSING_MSG => ERR_HEAD_MISSING, BASE_MISSING_MSG => ERR_BASE_MISSING, _ => return None, }) } /// A blank message means "use the default". Otherwise CRLF becomes LF. A message over 64 KiB or /// with a NUL byte is rejected with the reason. fn clean_message(raw: &str) -> std::result::Result, &'static str> { if raw.trim().is_empty() { return Ok(None); } if raw.len() > MAX_MESSAGE_BYTES { return Err("the merge message is longer than 64 KiB"); } if raw.contains('\0') { return Err("the merge message contains a NUL byte"); } Ok(Some(raw.replace("\r\n", "\n"))) } /// A plain-text response carrying a refusal message. fn refusal(status: StatusCode, message: &str) -> Response { let mut response = Response::new(Body::from(message.to_owned())); *response.status_mut() = status; response.headers_mut().insert( CONTENT_TYPE, HeaderValue::from_static("text/plain; charset=utf-8"), ); response } fn refused(status: StatusCode, message: &str) -> MergeOutcome { MergeOutcome::Refused { status, message: message.to_owned(), } } /// Whether `user` is a reviewer whose approvals and change requests count under merge rules: /// the repo owner, an instance admin, or a collaborator with Write, Maintain or Admin. pub fn counts_for_merge_rules(repo: &forge_core::Repo, user: &User, role: Option) -> bool { user.is_admin || user.id == repo.owner_id || matches!(role, Some(Role::Write | Role::Maintain | Role::Admin)) } /// The base branch's merge rule for a pull request, with the facts that rule checks. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct MergeGate { pub rule: Protection, /// Distinct counting reviewers whose latest submitted review is an approval. pub approvals: u32, /// Some counting reviewer's latest submitted review requests changes. pub changes_requested: bool, /// The head contains the current base tip. Always true when the rule does not require it. pub up_to_date: bool, } impl MergeGate { /// The refusal code for the first rule that blocks the merge, if any. pub fn blocked(&self) -> Option<&'static str> { if self.approvals < self.rule.required_approvals { return Some(ERR_NEEDS_APPROVALS); } if self.rule.block_on_changes_requested && self.changes_requested { return Some(ERR_CHANGES_REQUESTED); } if self.rule.require_up_to_date && !self.up_to_date { return Some(ERR_NOT_UP_TO_DATE); } None } } /// Evaluates the effective rule for the pull request's base branch. Approvals and change requests /// count only from `counts_for_merge_rules` reviewers, using each reviewer's latest review. /// Repo admins are not exempt. A base branch that does not exist counts as up to date; the merge /// itself reports that. pub async fn merge_gate( state: &AppState, repo: &forge_core::Repo, pr: &PullRequest, ) -> forge_core::Result { let rules = state.store.list_branch_rules(&repo.id).await?; let rule = effective(&rules, &repo.default_branch, &pr.base_branch); let mut approvals = 0u32; let mut changes_requested = false; for (author_id, _, review) in state.store.latest_review_states(&pr.issue.id).await? { let Some(reviewer) = state.store.user_by_id(&author_id).await? else { continue; }; let collaborator = state.store.collaborator_role(&repo.id, &author_id).await?; let org = state .store .org_repo_role(&repo.owner_id, &author_id, &repo.id) .await?; let role = forge_core::authz::max_role(collaborator, org); if !counts_for_merge_rules(repo, &reviewer, role) { continue; } match review { ReviewState::Approved => approvals = approvals.saturating_add(1), _ => changes_requested = true, } } let up_to_date = if rule.require_up_to_date { let base_path = repo_path(&state.git.data_dir, &repo.id)?; match default_branch_commit(&base_path, &pr.base_branch)? { Some(tip) => is_ancestor(&state.git, &base_path, &tip, &pr.head_oid).await?, None => true, } } else { true }; Ok(MergeGate { rule, approvals, changes_requested, up_to_date, }) } /// Merges `pr` into its base branch in `repo` as `viewer`. /// /// The base tip is `expected_base` when given, otherwise the current tip of the base branch. The /// merge is checked and built against that tip, and the ref moves only if it still points there. /// `message` replaces the default commit message (not used by fast-forward). Refusals that the /// viewer can act on come back as `MergeOutcome::Refused`; git and database failures are errors. pub async fn perform_merge( state: &AppState, repo: &forge_core::Repo, pr: &PullRequest, viewer: &User, style: MergeStyle, message: Option<&str>, expected_base: Option<&str>, ) -> forge_core::Result { let base_path = repo_path(&state.git.data_dir, &repo.id)?; let refname = format!("refs/heads/{}", pr.base_branch); let base_tip = match expected_base { Some(oid) => oid.to_owned(), None => match default_branch_commit(&base_path, &pr.base_branch)? { Some(oid) => oid, None => { return Ok(refused(StatusCode::CONFLICT, BASE_MISSING_MSG)); } }, }; let head = pr.head_oid.as_str(); let tree = match check_merge(&state.git, &base_path, &base_tip, head).await? { MergeCheck::Clean { tree } => tree, MergeCheck::Conflict { .. } => return Ok(refused(StatusCode::CONFLICT, CONFLICTS_MSG)), MergeCheck::UpToDate => return Ok(refused(StatusCode::CONFLICT, UP_TO_DATE_MSG)), }; let now = now_unix(); let committer = Signature { name: viewer.username.clone(), email: contact_email(&state.config.base_url, viewer), time_unix: now, }; let new = match style { MergeStyle::FastForward => { if !is_ancestor(&state.git, &base_path, &base_tip, head).await? { return Ok(refused(StatusCode::CONFLICT, FF_MSG)); } head.to_owned() } MergeStyle::Merge => { let text = match message { Some(text) => text.to_owned(), None => default_merge_message(state, pr).await?, }; commit_tree( &state.git, &base_path, &tree, &[base_tip.as_str(), head], &text, &committer, &committer, ) .await? } MergeStyle::Squash => { let author = match state.store.user_by_id(&pr.issue.author_id).await? { Some(author) => Signature { name: author.username.clone(), email: contact_email(&state.config.base_url, &author), time_unix: now, }, None => committer.clone(), }; let text = match message { Some(text) => text.to_owned(), None => { let commits = range_log(&state.git, &base_path, &base_tip, head, MAX_SQUASH_COMMITS) .await?; squash_message(pr, &commits) } }; commit_tree( &state.git, &base_path, &tree, &[base_tip.as_str()], &text, &author, &committer, ) .await? } }; match update_ref_cas(&state.git, &base_path, &refname, &new, &base_tip).await { Ok(()) => {} Err(forge_git::error::Error::RefMoved(_)) => { return Ok(refused(StatusCode::CONFLICT, MOVED_MSG)); } Err(e) => return Err(e.into()), } // The ref has moved, so the merge happened. Post-merge steps that fail are logged, not // reported: the viewer's merge is already in place. match state .store .mark_merged(&pr.issue.id, &viewer.id, &new) .await { Ok(()) => { let sender = Sender::of(viewer); emit_item_action(state, repo, &sender, true, pr.issue.number, "merged").await; notify::dispatch(state, repo, &pr.issue, &viewer.id, Activity::Merged, None).await; } Err(e) => { tracing::error!(error = %e, repo_id = %repo.id, number = pr.issue.number, "merge: mark_merged failed"); } } trigger_maintenance(&state.git, &repo.id); if let Err(e) = after_ref_update(state, repo, &viewer.id, &refname, &base_tip, &new).await { tracing::error!(error = %e, repo_id = %repo.id, ref_name = %refname, "merge: post-update failed"); } Ok(MergeOutcome::Merged { commit: new }) } /// The address written into a commit for `user`: their email, or a noreply address on this /// instance's host when they have none. fn contact_email(base_url: &str, user: &User) -> String { match user .email .as_deref() .map(str::trim) .filter(|e| !e.is_empty()) { Some(email) => email.to_owned(), None => format!( "{}@users.noreply.{}", user.username, base_url_host(base_url) ), } } fn base_url_host(base_url: &str) -> String { base_url .parse::() .ok() .and_then(|uri| uri.host().map(str::to_owned)) .unwrap_or_else(|| "localhost".to_owned()) } /// `Merge pull request #N from owner/branch`, a blank line, then the pull request title. async fn default_merge_message(state: &AppState, pr: &PullRequest) -> forge_core::Result { let head_owner = match pr.head_repo_id.as_deref() { Some(id) => state .store .repo_by_id(id) .await? .map(|head| head.owner_name), None => None, }; let head = match head_owner { Some(owner) => format!("{owner}/{}", pr.head_branch), None => pr.head_branch.clone(), }; Ok(format!( "Merge pull request #{} from {head}\n\n{}", pr.issue.number, pr.issue.title )) } /// ` (#N)`, a blank line, then one `- <summary>` line per commit, oldest first. fn squash_message(pr: &PullRequest, commits: &[CommitInfo]) -> String { let mut text = format!("{} (#{})\n\n", pr.issue.title, pr.issue.number); for commit in commits { text.push_str("- "); text.push_str(&commit.summary); text.push('\n'); } text } #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; use std::time::Duration; use forge_core::{IssueState, Repo, Role, Store, Visibility}; use forge_git::repo::{init_bare, repo_path}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use super::*; use crate::auth::SESSION_COOKIE; use crate::test_support::{app_router, create_session, test_state}; const CSRF: &str = "merge-test-csrf"; fn git(dir: &Path, args: &[&str]) -> String { let out = std::process::Command::new("git") .arg("-C") .arg(dir) .args(args) .env("GIT_AUTHOR_NAME", "test") .env("GIT_AUTHOR_EMAIL", "test@example.com") .env("GIT_COMMITTER_NAME", "test") .env("GIT_COMMITTER_EMAIL", "test@example.com") .output() .expect("git runs"); assert!( out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr) ); String::from_utf8_lossy(&out.stdout).trim().to_string() } /// Writes `contents` to `file` in the work tree, commits it with `contents` as the message. fn commit(work: &Path, file: &str, contents: &str) -> String { std::fs::write(work.join(file), contents).expect("write file"); git(work, &["add", file]); git(work, &["commit", "-m", contents]); git(work, &["rev-parse", "HEAD"]) } fn tip(dir: &Path, rev: &str) -> String { git(dir, &["rev-parse", rev]) } struct Fixture { _tmp: tempfile::TempDir, state: AppState, app: Router, repo: Repo, alice: User, bob: User, alice_cookie: String, bare: PathBuf, work: PathBuf, } /// A private repo `alice/proj` with `main` holding `file.txt`, a bare copy on disk and a work /// tree that pushes to it. Bob is a user with no role on the repo. async fn fixture() -> Fixture { let tmp = tempfile::tempdir().expect("tempdir"); let state = test_state(tmp.path()).await; let store: Store = state.store.clone(); let alice = store .create_user("alice", None, "unused", false) .await .expect("alice"); let bob = store .create_user("bob", None, "unused", false) .await .expect("bob"); let repo = store .create_repo(&alice.id, "proj", "", Visibility::Private, "main") .await .expect("repo"); let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path"); init_bare(&state.git, &bare, "main") .await .expect("init bare"); let work = tmp.path().join("work"); git(tmp.path(), &["init", "-b", "main", "work"]); commit(&work, "file.txt", "one"); git(&work, &["push", bare.to_str().expect("utf8"), "main"]); create_session(&store, &alice.id, "alice-token", CSRF).await; let alice_cookie = format!("__Host-{SESSION_COOKIE}=alice-token"); let app = app_router(state.clone()); Fixture { _tmp: tmp, state, app, repo, alice, bob, alice_cookie, bare, work, } } impl Fixture { fn bare_str(&self) -> &str { self.bare.to_str().expect("utf8 path") } fn main_tip(&self) -> String { tip(&self.bare, "refs/heads/main") } /// Pushes `main` (or any branch) from the work tree to the bare repo. fn push(&self, branch: &str) { git(&self.work, &["push", self.bare_str(), branch]); } async fn pull(&self, number: i64) -> PullRequest { self.state .store .pull_by_number(&self.repo.id, number) .await .expect("lookup") .expect("pull exists") } /// Commits `contents` to `file` on a branch `branch` cut from main, pushes it and opens a /// pull request from it, authored by `author`. Returns the pull request number. async fn open_feature( &self, branch: &str, file: &str, contents: &str, author: &User, ) -> i64 { git(&self.work, &["checkout", "main"]); git(&self.work, &["checkout", "-B", branch]); commit(&self.work, file, contents); self.push(branch); let base = self.main_tip(); let head = tip(&self.bare, &format!("refs/heads/{branch}")); let pr = self .state .store .create_pull( &self.repo.id, &author.id, &format!("Add {branch}"), "", "main", &self.repo.id, branch, &head, &base, ) .await .expect("create pull"); pr.issue.number } /// Adds a commit with `file` to main and pushes it. fn advance_main(&self, file: &str, contents: &str) { git(&self.work, &["checkout", "main"]); commit(&self.work, file, contents); self.push("main"); } async fn post(&self, path: &str, cookie: Option<&str>, body: String) -> Reply { let mut builder = Request::builder() .method("POST") .uri(path) .header("content-type", "application/x-www-form-urlencoded"); if let Some(cookie) = cookie { builder = builder.header("cookie", cookie); } let res = self .app .handle(builder.body(Body::from(body)).expect("request")) .await; let status = res.status().as_u16(); let location = res .headers() .get("location") .and_then(|v| v.to_str().ok()) .map(str::to_owned); let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body"); Reply { status, location, body: String::from_utf8_lossy(&bytes).into_owned(), } } /// Signs `user` in with a fresh session and returns the cookie header value. async fn sign_in(&self, user: &User, token: &str) -> String { create_session(&self.state.store, &user.id, token, CSRF).await; format!("__Host-{SESSION_COOKIE}={token}") } async fn merge(&self, number: i64, style: &str) -> Reply { self.post( &format!("/alice/proj/pulls/{number}/merge"), Some(&self.alice_cookie), merge_form(style, None), ) .await } } struct Reply { status: u16, location: Option<String>, body: String, } /// A refusal redirects (303) back to the pull request with `merge_error=<code>`. fn assert_refused(reply: &Reply, number: i64, code: &str) { assert_eq!(reply.status, 303, "body: {}", reply.body); assert_eq!( reply.location.as_deref(), Some(format!("/alice/proj/pulls/{number}?merge_error={code}").as_str()) ); } fn merge_form(style: &str, message: Option<&str>) -> String { let mut form = form_urlencoded::Serializer::new(String::new()); form.append_pair("csrf", CSRF).append_pair("style", style); if let Some(message) = message { form.append_pair("message", message); } form.finish() } #[tokio::test] async fn merge_commit_joins_both_histories() { let fx = fixture().await; let number = fx .open_feature("feature", "feature.txt", "f1", &fx.bob) .await; let before = fx.main_tip(); let head = tip(&fx.bare, "refs/heads/feature"); let reply = fx.merge(number, "merge").await; assert_eq!(reply.status, 303, "body: {}", reply.body); assert_eq!( reply.location.as_deref(), Some(format!("/alice/proj/pulls/{number}").as_str()) ); let merged = fx.main_tip(); assert_ne!(merged, before); let parents = git( &fx.bare, &["rev-list", "--parents", "-n", "1", "refs/heads/main"], ); assert_eq!( parents.split_whitespace().count(), 3, "two parents plus the commit" ); assert!(parents.contains(&before) && parents.contains(&head)); assert_eq!( git(&fx.bare, &["log", "-1", "--format=%s", "refs/heads/main"]), format!("Merge pull request #{number} from alice/feature") ); assert_eq!( git( &fx.bare, &["log", "-1", "--format=%an|%cn|%ce", "refs/heads/main"] ), "alice|alice|alice@users.noreply.forge.test" ); assert_eq!( git(&fx.bare, &["show", "refs/heads/main:feature.txt"]), "f1" ); let pr = fx.pull(number).await; assert!(pr.merged_at.is_some()); assert_eq!(pr.merge_commit.as_deref(), Some(merged.as_str())); assert_eq!(pr.issue.state, IssueState::Closed); let events = fx .state .store .list_events(&pr.issue.id) .await .expect("events"); assert!(events.iter().any(|e| e.kind == "merged")); } #[tokio::test] async fn squash_merge_is_authored_by_pull_request_author() { let fx = fixture().await; let number = fx .open_feature("feature", "feature.txt", "f1", &fx.bob) .await; let reply = fx.merge(number, "squash").await; assert_eq!(reply.status, 303, "body: {}", reply.body); let parents = git( &fx.bare, &["rev-list", "--parents", "-n", "1", "refs/heads/main"], ); assert_eq!( parents.split_whitespace().count(), 2, "one parent plus the commit" ); assert_eq!( git( &fx.bare, &["log", "-1", "--format=%an|%cn", "refs/heads/main"] ), "bob|alice" ); let subject = git(&fx.bare, &["log", "-1", "--format=%s", "refs/heads/main"]); assert_eq!(subject, format!("Add feature (#{number})")); let pr = fx.pull(number).await; assert!(pr.merged_at.is_some()); } #[tokio::test] async fn fast_forward_moves_base_to_head() { let fx = fixture().await; let number = fx .open_feature("feature", "feature.txt", "f1", &fx.bob) .await; let head = tip(&fx.bare, "refs/heads/feature"); let reply = fx.merge(number, "ff").await; assert_eq!(reply.status, 303, "body: {}", reply.body); assert_eq!(fx.main_tip(), head); assert!(fx.pull(number).await.merged_at.is_some()); } #[tokio::test] async fn fast_forward_is_refused_when_base_has_diverged() { let fx = fixture().await; let number = fx .open_feature("feature", "feature.txt", "f1", &fx.bob) .await; fx.advance_main("other.txt", "other"); let before = fx.main_tip(); let reply = fx.merge(number, "ff").await; assert_refused(&reply, number, ERR_FF_IMPOSSIBLE); assert_eq!(fx.main_tip(), before); assert!(fx.pull(number).await.merged_at.is_none()); } #[tokio::test] async fn conflicting_pull_request_is_refused_and_base_unchanged() { let fx = fixture().await; let number = fx .open_feature("feature", "file.txt", "feature", &fx.bob) .await; fx.advance_main("file.txt", "main"); let before = fx.main_tip(); let reply = fx.merge(number, "merge").await; assert_refused(&reply, number, ERR_CONFLICT); assert_eq!(fx.main_tip(), before); assert!(fx.pull(number).await.merged_at.is_none()); } #[tokio::test] async fn head_already_in_base_is_refused_as_up_to_date() { let fx = fixture().await; let number = fx .open_feature("feature", "feature.txt", "f1", &fx.bob) .await; // Move main onto the feature head, so the head is already contained in the base. fx.push("feature:main"); let before = fx.main_tip(); let reply = fx.merge(number, "merge").await; assert_refused(&reply, number, ERR_UP_TO_DATE); assert_eq!(fx.main_tip(), before); } #[tokio::test] async fn invalid_message_is_refused_and_base_unchanged() { let fx = fixture().await; let number = fx .open_feature("feature", "feature.txt", "f1", &fx.bob) .await; let before = fx.main_tip(); let long = "x".repeat(MAX_MESSAGE_BYTES + 1); let reply = fx .post( &format!("/alice/proj/pulls/{number}/merge"), Some(&fx.alice_cookie), merge_form("merge", Some(&long)), ) .await; assert_refused(&reply, number, ERR_MESSAGE_INVALID); let reply = fx .post( &format!("/alice/proj/pulls/{number}/merge"), Some(&fx.alice_cookie), merge_form("merge", Some("a\0b")), ) .await; assert_refused(&reply, number, ERR_MESSAGE_INVALID); assert_eq!(fx.main_tip(), before); assert!(fx.pull(number).await.merged_at.is_none()); } fn rule( required_approvals: u32, block_on_changes_requested: bool, require_up_to_date: bool, ) -> forge_core::protection::RuleFlags { forge_core::protection::RuleFlags { required_approvals, block_on_changes_requested, require_up_to_date, ..Default::default() } } #[tokio::test] async fn merge_waits_for_write_approvals_and_ignores_read_approvals() { let fx = fixture().await; let number = fx .open_feature("feature", "feature.txt", "f1", &fx.bob) .await; let pr = fx.pull(number).await; fx.state .store .upsert_branch_rule(&fx.repo.id, "main", rule(1, false, false)) .await .expect("rule"); let carol = fx .state .store .create_user("carol", None, "unused", false) .await .expect("carol"); let dave = fx .state .store .create_user("dave", None, "unused", false) .await .expect("dave"); fx.state .store .set_collaborator(&fx.repo.id, &carol.id, Role::Write) .await .expect("carol role"); fx.state .store .set_collaborator(&fx.repo.id, &dave.id, Role::Read) .await .expect("dave role"); let before = fx.main_tip(); // No approvals yet. assert_refused(&fx.merge(number, "merge").await, number, "needs_approvals"); assert_eq!(fx.main_tip(), before); // A Read collaborator's approval does not count. fx.state .store .submit_review( &pr.issue.id, &dave.id, ReviewState::Approved, "", &pr.head_oid, ) .await .expect("dave approves"); assert_refused(&fx.merge(number, "merge").await, number, "needs_approvals"); assert_eq!(fx.main_tip(), before); // A Write collaborator's approval does. fx.state .store .submit_review( &pr.issue.id, &carol.id, ReviewState::Approved, "", &pr.head_oid, ) .await .expect("carol approves"); let reply = fx.merge(number, "merge").await; assert_eq!(reply.status, 303, "body: {}", reply.body); assert_eq!( reply.location.as_deref(), Some(format!("/alice/proj/pulls/{number}").as_str()) ); assert_ne!(fx.main_tip(), before); } #[tokio::test] async fn changes_requested_blocks_only_when_the_rule_says_so() { let fx = fixture().await; let number = fx .open_feature("feature", "feature.txt", "f1", &fx.bob) .await; let pr = fx.pull(number).await; let carol = fx .state .store .create_user("carol", None, "unused", false) .await .expect("carol"); fx.state .store .set_collaborator(&fx.repo.id, &carol.id, Role::Maintain) .await .expect("carol role"); fx.state .store .submit_review( &pr.issue.id, &carol.id, ReviewState::ChangesRequested, "", &pr.head_oid, ) .await .expect("carol requests changes"); let before = fx.main_tip(); fx.state .store .upsert_branch_rule(&fx.repo.id, "main", rule(0, true, false)) .await .expect("rule"); assert_refused( &fx.merge(number, "merge").await, number, "changes_requested", ); assert_eq!(fx.main_tip(), before); // Without the block flag the same change request does not stop the merge. fx.state .store .upsert_branch_rule(&fx.repo.id, "main", rule(0, false, false)) .await .expect("rule"); let reply = fx.merge(number, "merge").await; assert_eq!(reply.status, 303, "body: {}", reply.body); assert_ne!(fx.main_tip(), before); } #[tokio::test] async fn require_up_to_date_blocks_until_head_contains_base() { let fx = fixture().await; let number = fx .open_feature("feature", "feature.txt", "f1", &fx.bob) .await; fx.state .store .upsert_branch_rule(&fx.repo.id, "main", rule(0, false, true)) .await .expect("rule"); fx.advance_main("other.txt", "moved"); assert_refused(&fx.merge(number, "merge").await, number, "not_up_to_date"); // Merging the new base into the feature branch and pushing it brings the head up to date. git(&fx.work, &["checkout", "feature"]); git(&fx.work, &["merge", "--no-edit", "main"]); fx.push("feature"); let head = tip(&fx.bare, "refs/heads/feature"); let pr = fx.pull(number).await; fx.state .store .update_pull_head(&pr.issue.id, &head) .await .expect("head moved"); let reply = fx.merge(number, "merge").await; assert_eq!(reply.status, 303, "body: {}", reply.body); assert_eq!( reply.location.as_deref(), Some(format!("/alice/proj/pulls/{number}").as_str()) ); } #[tokio::test] async fn read_only_collaborator_gets_403() { let fx = fixture().await; let number = fx .open_feature("feature", "feature.txt", "f1", &fx.bob) .await; fx.state .store .set_collaborator(&fx.repo.id, &fx.bob.id, Role::Read) .await .expect("collaborator"); let bob_cookie = fx.sign_in(&fx.bob, "bob-token").await; let before = fx.main_tip(); let reply = fx .post( &format!("/alice/proj/pulls/{number}/merge"), Some(&bob_cookie), merge_form("merge", None), ) .await; assert_eq!(reply.status, 403); assert_eq!(fx.main_tip(), before); } #[tokio::test] async fn anonymous_viewer_of_private_repo_gets_404() { let fx = fixture().await; let number = fx .open_feature("feature", "feature.txt", "f1", &fx.bob) .await; let reply = fx .post( &format!("/alice/proj/pulls/{number}/merge"), None, merge_form("merge", None), ) .await; assert_eq!(reply.status, 404); } #[tokio::test] async fn missing_csrf_is_rejected() { let fx = fixture().await; let number = fx .open_feature("feature", "feature.txt", "f1", &fx.bob) .await; let before = fx.main_tip(); let reply = fx .post( &format!("/alice/proj/pulls/{number}/merge"), Some(&fx.alice_cookie), "style=merge".to_owned(), ) .await; assert_eq!(reply.status, 403); assert_eq!(fx.main_tip(), before); } #[tokio::test] async fn already_merged_pull_request_is_rejected() { let fx = fixture().await; let number = fx .open_feature("feature", "feature.txt", "f1", &fx.bob) .await; assert_eq!(fx.merge(number, "merge").await.status, 303); let after_first = fx.main_tip(); let reply = fx.merge(number, "merge").await; assert_refused(&reply, number, ERR_ALREADY_MERGED); assert_eq!(fx.main_tip(), after_first); } #[tokio::test] async fn closing_keyword_in_merged_commit_closes_issue() { let fx = fixture().await; let issue = fx .state .store .create_issue(&fx.repo.id, &fx.alice.id, "Bug", "") .await .expect("issue"); assert_eq!(issue.number, 1); let number = fx .open_feature("feature", "feature.txt", "Fixes #1", &fx.bob) .await; assert_eq!(fx.merge(number, "merge").await.status, 303); // Closing keywords run in a spawned task after the merge, so poll for the result. let mut closed = false; for _ in 0..200 { let current = fx .state .store .issue_by_number(&fx.repo.id, 1) .await .expect("lookup") .expect("issue exists"); if current.state == IssueState::Closed { closed = true; break; } tokio::time::sleep(Duration::from_millis(10)).await; } assert!(closed, "issue 1 should be closed by the merged commit"); } #[tokio::test] async fn base_moved_after_load_is_refused_by_compare_and_swap() { let fx = fixture().await; let number = fx .open_feature("feature", "feature.txt", "f1", &fx.bob) .await; let pr = fx.pull(number).await; let stale_base = fx.main_tip(); fx.advance_main("other.txt", "other"); let moved = fx.main_tip(); let alice = fx .state .store .user_by_id(&fx.alice.id) .await .expect("lookup") .expect("alice"); let outcome = perform_merge( &fx.state, &fx.repo, &pr, &alice, MergeStyle::Merge, None, Some(&stale_base), ) .await .expect("perform_merge"); assert_eq!( outcome, MergeOutcome::Refused { status: StatusCode::CONFLICT, message: MOVED_MSG.to_owned(), } ); assert_eq!(fx.main_tip(), moved); assert!(fx.pull(number).await.merged_at.is_none()); } #[test] fn clean_message_normalizes_and_limits() { assert_eq!(clean_message(" \r\n ").expect("blank"), None); assert_eq!( clean_message("a\r\nb").expect("text"), Some("a\nb".to_owned()) ); assert!(clean_message(&"x".repeat(MAX_MESSAGE_BYTES + 1)).is_err()); assert!(clean_message("a\0b").is_err()); } }