//! Pages: comparing two branches and opening a pull request from the comparison. //! //! - `GET /{o}/{r}/compare`: a plain GET picker for base and head. With both parameters it //! redirects to the comparison URL. //! - `GET /{o}/{r}/compare/{*spec}`: `base...head`, where head is `branch` or `owner:branch`. The //! head repo is this repo, one of its forks, or its parent, and the viewer must be able to Read //! it. Head commits from another repo are read through git's alternate object directory for //! this one read-only command. Nothing is copied into this repo, so a GET writes nothing. //! - `POST /{o}/{r}/pulls`: opens the pull request. Everything is re-resolved on the server; the //! hidden fields are only a request, never trusted. use std::path::PathBuf; use forge_core::{Action, Repo as RepoRecord, Session, authorize, validate_body, validate_title}; use forge_git::browse::{self, CommitInfo, RefKind}; use forge_git::command::ObjectSource; use forge_git::pulls; use forge_git::repo::repo_path; 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::{page, path_param, query_params}; use topcoat::view::{View, view}; use crate::auth::{check_csrf, require_user}; use crate::i18n; use crate::i18n::pulls::{self as g, K}; use crate::notify::{self, Activity}; use crate::pages::commits::{CommitRow, blocking, now_secs, url_path}; use crate::pages::repo::repo_head; use crate::pages::repo_ctx::{RepoCtx, load_repo}; use crate::state::AppState; use crate::templates; use crate::views::diff::{DiffLang, LIMITS, diff_section}; use crate::views::{alert, repo_href, shell, user_facing_message}; use crate::webhooks::emit::{Sender, emit_pull}; path_param!(owner); path_param!(repo); path_param!(*spec); /// Commits listed on a comparison. The newest ones are kept when there are more. const MAX_COMMITS: usize = 250; /// The head side of a comparison: the repo, its on-disk path and the branch. struct Head { repo: RepoRecord, path: PathBuf, branch: String, } /// A resolved comparison. `commits` is oldest first and empty when the histories are unrelated. struct Comparison { base: String, base_oid: String, head: Head, head_oid: String, merge_base: Option, commits: Vec, /// The head repo's object directory when the head lives in another repo, else `None`. head_objects: Option, } impl Comparison { /// Where the head commits can be read from, for read-only git calls on the base repo. fn objects(&self) -> ObjectSource<'_> { ObjectSource { extra: self.head_objects.as_deref(), } } } /// The head as a form value: a bare branch for this repo, `owner:branch` otherwise. fn head_value(ctx: &RepoCtx, head: &Head) -> String { if head.repo.id == ctx.repo.id { head.branch.clone() } else { format!("{}:{}", head.repo.owner_name, head.branch) } } /// The repo a head owner refers to: the parent of this repo, or one of its forks. async fn related_repo(state: &AppState, ctx: &RepoCtx, owner: &str) -> Result> { if let Some(parent_id) = ctx.repo.fork_of.as_deref() && let Some(parent) = state.store.repo_by_id(parent_id).await? && parent.owner_name == owner { return Ok(Some(parent)); } let forks = state.store.list_forks(&ctx.repo.id).await?; Ok(forks.into_iter().find(|fork| fork.owner_name == owner)) } /// Resolves `branch` or `owner:branch` to a head the viewer can Read. `None` when the spec is /// malformed, the repo is unrelated, or the viewer may not read it. Hidden repos look the same as /// missing ones. async fn resolve_head(state: &AppState, ctx: &RepoCtx, spec: &str) -> Result> { let (owner, branch) = match spec.split_once(':') { Some((owner, branch)) => (Some(owner), branch), None => (None, spec), }; if branch.is_empty() { return Ok(None); } let repo = match owner { None => ctx.repo.clone(), Some(owner) if owner == ctx.repo.owner_name => ctx.repo.clone(), Some(owner) => match related_repo(state, ctx, owner).await? { Some(repo) => repo, None => return Ok(None), }, }; let (role, org_role) = match &ctx.viewer { Some((user, _)) => ( state.store.collaborator_role(&repo.id, &user.id).await?, state .store .org_repo_role(&repo.owner_id, &user.id, &repo.id) .await?, ), None => (None, None), }; let actor = ctx.viewer.as_ref().map(|(user, _)| user); if authorize(actor, &repo, role, org_role, Action::Read).is_err() { return Ok(None); } let path = repo_path(&state.config.data_dir, &repo.id)?; Ok(Some(Head { repo, path, branch: branch.to_owned(), })) } /// Resolves both sides of a comparison. Head objects from a fork are read through an alternate /// object directory, so nothing is written to either repo. `None` when either branch does not exist. async fn resolve_comparison( state: &AppState, ctx: &RepoCtx, base: &str, head_spec: &str, ) -> Result> { let base_path = ctx.path.clone(); let base_name = base.to_owned(); let Some(base_oid) = blocking(move || browse::default_branch_commit(&base_path, &base_name)).await? else { return Ok(None); }; let Some(head) = resolve_head(state, ctx, head_spec).await? else { return Ok(None); }; let head_path = head.path.clone(); let head_branch = head.branch.clone(); let Some(head_oid) = blocking(move || browse::default_branch_commit(&head_path, &head_branch)).await? else { return Ok(None); }; let head_objects = (head.repo.id != ctx.repo.id).then(|| head.path.join("objects")); let src = ObjectSource { extra: head_objects.as_deref(), }; let merge_base = pulls::merge_base_with(&state.git, &ctx.path, src, &base_oid, &head_oid).await?; let commits = match merge_base { Some(_) => { pulls::range_log_with( &state.git, &ctx.path, src, &base_oid, &head_oid, MAX_COMMITS, ) .await? } None => Vec::new(), }; Ok(Some(Comparison { base: base.to_owned(), base_oid, head, head_oid, merge_base, commits, head_objects, })) } fn csrf_of(ctx: &RepoCtx) -> String { ctx.viewer .as_ref() .map(|(_, session)| session.csrf_token.clone()) .unwrap_or_default() } /// Write requests need an unarchived repo and a matching CSRF token. fn check_write(ctx: &RepoCtx, session: &Session, csrf: &str) -> Result<()> { if ctx.repo.archived || !check_csrf(session, csrf) { return Err(forbidden().into()); } Ok(()) } fn pull_href(ctx: &RepoCtx, number: i64) -> String { repo_href( &ctx.repo.owner_name, &ctx.repo.name, "pulls", &number.to_string(), ) } /// The body the pull request form starts with: the default branch's pull request template, or /// empty when there is none. async fn pull_template_body(ctx: &RepoCtx) -> Result { let template = templates::load_pull_template(ctx.path.clone(), ctx.repo.default_branch.clone()).await?; Ok(template.map(|t| t.body).unwrap_or_default()) } /// The comparison page body: header, commits, the diff and the pull request box. /// `draft` holds the title and body typed into the form; `None` prefills the title. async fn compare_view( cx: &Cx, ctx: RepoCtx, cmp: Comparison, draft: Option<(String, String)>, error: Option, ) -> Result { let state = app_context::(cx); let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); let owner = ctx.repo.owner_name.clone(); let name = ctx.repo.name.clone(); let tz = ctx.viewer_tz().to_owned(); let now = now_secs(); let title = format!("{owner}/{name}"); let repo_href_root = repo_href(&owner, &name, "", ""); let base = cmp.base.clone(); let head = head_value(&ctx, &cmp.head); let head_for_form = head.clone(); let rows: Vec = cmp .commits .iter() .map(|info| CommitRow::new(&owner, &name, info, now, &tz, lang)) .collect(); let related = cmp.merge_base.is_some(); let has_commits = !rows.is_empty(); let commits_label = g::commit_count(lang, rows.len()); let unrelated = !related; let nothing = related && !has_commits; let show_diff = related && has_commits; let parent = cmp.merge_base.clone(); let diff = if show_diff { Some( pulls::range_diff_files_with( &state.git, &ctx.path, cmp.objects(), &cmp.base_oid, &cmp.head_oid, LIMITS, ) .await?, ) } else { None }; let signed_in = ctx.viewer.is_some(); let archived = ctx.repo.archived; let existing = if show_diff && signed_in && !archived { state .store .find_open_pull(&ctx.repo.id, &cmp.base, &cmp.head.repo.id, &cmp.head.branch) .await? } else { None }; let has_existing = existing.is_some(); let existing_href = existing .map(|number| pull_href(&ctx, number)) .unwrap_or_default(); let show_form = show_diff && signed_in && !archived && !has_existing; let archived_note = show_diff && signed_in && archived; let default_title = match rows.len() { 1 => cmp .commits .first() .map(|c| c.summary.clone()) .unwrap_or_default(), _ => cmp.head.branch.clone(), }; let (form_title, form_body) = match draft { Some(draft) => draft, None => (default_title, pull_template_body(&ctx).await?), }; let has_error = error.is_some(); let error_text = error.unwrap_or_default(); let csrf = csrf_of(&ctx); let pulls_action = repo_href(&owner, &name, "pulls", ""); let heading_base = base.clone(); let heading_head = head.clone(); let header = ctx.head(state, "commits").await?; let __cx = cx; Ok(view! { shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header)

(g::t(lang, K::ComparingPrefix)) (heading_base.as_str()) "..." (heading_head.as_str())

(title.as_str())

if unrelated {

(g::t(lang, K::Unrelated))

} if nothing {

(g::t(lang, K::NothingToCompare))

} if has_commits {

(commits_label.as_str())

} if show_form {

(g::t(lang, K::OpenPullRequest))

if has_error { alert(message: error_text.as_str()) }

} if has_existing {

(g::t(lang, K::AlreadyOpen)) (g::t(lang, K::ViewIt))

} if archived_note {

(g::t(lang, K::ArchivedNoPulls))

} if show_diff { for d in diff.iter() { diff_section(diff: d, owner: owner.as_str(), repo: name.as_str(), oid: cmp.head_oid.as_str(), parent: parent.as_deref(), subject: g::t(lang, K::ThisComparison), lang: DiffLang(lang)) } } ) }) } /// The picker: base and head fields as a plain GET form, with the repo's branches suggested. async fn picker_view(cx: &Cx, ctx: RepoCtx, base: String, head: String) -> Result { let state = app_context::(cx); let lang = i18n::lang(cx, ctx.viewer.as_ref().map(|(user, _)| user)); let path = ctx.path.clone(); let refs = blocking(move || browse::list_refs(&path)).await?; let branches: Vec = refs .into_iter() .filter(|r| r.kind == RefKind::Branch) .map(|r| r.name) .collect(); let title = format!("{}/{}", ctx.repo.owner_name, ctx.repo.name); let action = repo_href(&ctx.repo.owner_name, &ctx.repo.name, "compare", ""); let header = ctx.head(state, "commits").await?; let __cx = cx; Ok(view! { shell(title: title.as_str(), viewer: ctx.viewer.as_ref(), repo_head(head: header)

(g::t(lang, K::CompareBranches))

for name in branches.iter() { }

) }) } #[query_params(error = bad_request)] struct PickerQuery { base: Option, head: Option, } /// Picker at `/{owner}/{repo}/compare`. Both fields filled in redirect to the comparison. #[page("/{owner}/{repo}/compare")] async fn compare_picker(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let query = query_params::(cx)?; let ctx = load_repo(cx, owner, name).await?; let base = query.base.clone().unwrap_or_default(); let head = query.head.clone().unwrap_or_default(); if base.is_empty() && head.is_empty() { let default_branch = ctx.repo.default_branch.clone(); return picker_view(cx, ctx, default_branch, String::new()).await; } if !base.is_empty() && !head.is_empty() { let target = format!( "/{}/{}/compare/{}", ctx.repo.owner_name, ctx.repo.name, url_path(&format!("{base}...{head}")) ); return Err(see_other(target).into()); } picker_view(cx, ctx, base, head).await } /// Comparison at `/{owner}/{repo}/compare/{base}...{head}`. #[page("/{owner}/{repo}/compare/{*spec}")] async fn compare_page(cx: &Cx) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let spec = path_param::(cx).collect::>().join("/"); let ctx = load_repo(cx, owner, name).await?; let state = app_context::(cx); let Some((base, head)) = spec.split_once("...") else { return Err(not_found().into()); }; if base.is_empty() { return Err(not_found().into()); } let Some(cmp) = resolve_comparison(state, &ctx, base, head).await? else { return Err(not_found().into()); }; compare_view(cx, ctx, cmp, None, None).await } #[derive(Deserialize)] struct PullForm { #[serde(default)] csrf: String, #[serde(default)] title: String, #[serde(default)] body: String, #[serde(default)] base: String, #[serde(default)] head: String, } /// Opens a pull request from a comparison: `POST /{owner}/{repo}/pulls`. #[page(POST "/{owner}/{repo}/pulls")] async fn pull_create(cx: &Cx, Form(form): Form) -> Result { let owner = path_param::(cx); let name = path_param::(cx); let ctx = load_repo(cx, owner, name).await?; let (user, session) = require_user(cx).await?; check_write(&ctx, &session, &form.csrf)?; let lang = i18n::lang(cx, Some(&user)); let state = app_context::(cx); let Some(cmp) = resolve_comparison(state, &ctx, &form.base, &form.head).await? else { return Err(bad_request(g::t(lang, K::UnknownBranches)).into()); }; if cmp.merge_base.is_none() { return Err(bad_request(g::t(lang, K::Unrelated)).into()); } if let Some(number) = state .store .find_open_pull(&ctx.repo.id, &cmp.base, &cmp.head.repo.id, &cmp.head.branch) .await? { return Err(see_other(pull_href(&ctx, number)).into()); } if cmp.commits.is_empty() { return Err(bad_request(g::t(lang, K::NothingToCompare)).into()); } let title = match validate_title(&form.title) { Ok(title) => title, Err(e) => { let message = user_facing_message(&e).unwrap_or_else(|| e.to_string()); return compare_view(cx, ctx, cmp, Some((form.title, form.body)), Some(message)).await; } }; if let Err(e) = validate_body(&form.body) { let message = user_facing_message(&e).unwrap_or_else(|| e.to_string()); return compare_view(cx, ctx, cmp, Some((title, form.body)), Some(message)).await; } let created = state .store .create_pull( &ctx.repo.id, &user.id, &title, &form.body, &cmp.base, &cmp.head.repo.id, &cmp.head.branch, &cmp.head_oid, &cmp.base_oid, ) .await; let pr = match created { Ok(pr) => pr, Err(e @ forge_core::Error::Conflict(_)) => { // Another request opened the pull first (or the number was taken). Send the viewer to // the open pull when there is one; otherwise report the original conflict. let open = state .store .find_open_pull(&ctx.repo.id, &cmp.base, &cmp.head.repo.id, &cmp.head.branch) .await?; return match open { Some(number) => Err(see_other(pull_href(&ctx, number)).into()), None => Err(e.into()), }; } Err(e) => return Err(e.into()), }; emit_pull(state, &ctx.repo, &Sender::of(&user), "opened", &pr).await; notify::dispatch( state, &ctx.repo, &pr.issue, &user.id, Activity::Opened, Some(&pr.issue.body), ) .await; let number = pr.issue.number; pulls::fetch_into_pull_ref(&state.git, &ctx.path, &cmp.head.path, &cmp.head_oid, number) .await?; // After the fetch: a fork's head commit is only in the base repo once refs/pull/N/head exists. crate::ci::on_pull_event(state, &ctx.repo, &pr, &user.id).await; Err(see_other(pull_href(&ctx, number)).into()) } #[cfg(test)] mod tests { use std::path::{Path, PathBuf}; use forge_core::{Store, Visibility}; use topcoat::router::request::Request; use topcoat::router::{Body, Router, to_bytes}; use crate::auth::SESSION_COOKIE; use crate::pages::commits::test_support::{get, git}; use crate::test_support::{app_router, test_state}; const OWNER: &str = "alice"; const REPO: &str = "demo"; const ALICE_CSRF: &str = "alice-csrf"; struct Fixture { router: Router, store: Store, dir: PathBuf, bare: PathBuf, feature_oid: String, _tmp: tempfile::TempDir, } impl Fixture { /// Alice's repo with `main` (a.txt) and `feature` (adds feature.txt). Alice's session is /// `alice-token` with CSRF `alice-csrf`. async fn new(visibility: Visibility) -> Self { let tmp = tempfile::tempdir().expect("tempdir"); let dir = tmp.path().to_path_buf(); let state = test_state(&dir).await; let store = state.store.clone(); let router = app_router(state); let alice = store .create_user(OWNER, None, "not-a-real-hash", false) .await .unwrap(); let repo = store .create_repo(&alice.id, REPO, "", visibility, "main") .await .unwrap(); let bare = forge_git::repo::repo_path(&dir, &repo.id).unwrap(); std::fs::create_dir_all(bare.parent().unwrap()).unwrap(); git( &dir, &[ "init", "-q", "--bare", "-b", "main", &bare.to_string_lossy(), ], ); let work = dir.join("work"); std::fs::create_dir_all(&work).unwrap(); git(&work, &["init", "-q", "-b", "main"]); std::fs::write(work.join("a.txt"), "one\n").unwrap(); git(&work, &["add", "-A"]); git(&work, &["commit", "-q", "-m", "root commit"]); git(&work, &["checkout", "-q", "-b", "feature"]); std::fs::write(work.join("feature.txt"), "feature\n").unwrap(); git(&work, &["add", "-A"]); git(&work, &["commit", "-q", "-m", "Add feature file"]); let feature_oid = git(&work, &["rev-parse", "HEAD"]); git(&work, &["checkout", "-q", "main"]); git( &work, &[ "push", "-q", &bare.to_string_lossy(), "main:refs/heads/main", "feature:refs/heads/feature", ], ); crate::test_support::create_session(&store, &alice.id, "alice-token", ALICE_CSRF).await; Self { router, store, dir, bare, feature_oid, _tmp: tmp, } } /// Gives bob a fork of alice's repo with a `bob-work` branch that alice's repo lacks. /// Returns bob's commit. async fn add_bob_fork(&self) -> String { let bob = self .store .create_user("bob", None, "not-a-real-hash", false) .await .unwrap(); crate::test_support::create_session(&self.store, &bob.id, "bob-token", "bob-csrf") .await; let source = self .store .repo_by_owner_and_name(OWNER, REPO) .await .unwrap() .unwrap(); let fork = self .store .create_fork(&source, &bob.id, REPO) .await .unwrap(); let fork_path = forge_git::repo::repo_path(&self.dir, &fork.id).unwrap(); std::fs::create_dir_all(fork_path.parent().unwrap()).unwrap(); git( &self.dir, &[ "clone", "-q", "--bare", "--local", "--no-checkout", &self.bare.to_string_lossy(), &fork_path.to_string_lossy(), ], ); let work = self.dir.join("work"); git(&work, &["checkout", "-q", "-b", "bob-work", "main"]); std::fs::write(work.join("bob.txt"), "bob\n").unwrap(); git(&work, &["add", "-A"]); git(&work, &["commit", "-q", "-m", "Add bob file"]); let bob_oid = git(&work, &["rev-parse", "HEAD"]); git( &work, &[ "push", "-q", &fork_path.to_string_lossy(), "bob-work:refs/heads/bob-work", ], ); git(&work, &["checkout", "-q", "main"]); bob_oid } } /// A request with an optional session cookie and a form body. Returns /// `(status, location header, body)`. async fn send( router: &Router, method: &str, uri: &str, token: Option<&str>, form: Option<&str>, ) -> (u16, Option, String) { let mut builder = Request::builder().method(method).uri(uri); if let Some(token) = token { builder = builder.header("cookie", format!("__Host-{SESSION_COOKIE}={token}")); } let body = match form { Some(form) => { builder = builder.header("content-type", "application/x-www-form-urlencoded"); Body::from(form.to_owned()) } None => Body::empty(), }; let res = router.handle(builder.body(body).unwrap()).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.unwrap(); ( status, location, String::from_utf8_lossy(&bytes).into_owned(), ) } fn rev(bare: &Path, name: &str) -> Option { let out = std::process::Command::new("git") .args(["rev-parse", "--verify", "-q", name]) .current_dir(bare) .output() .expect("git runs"); out.status .success() .then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()) } fn has_object(bare: &Path, oid: &str) -> bool { std::process::Command::new("git") .args(["cat-file", "-e", oid]) .current_dir(bare) .status() .expect("git runs") .success() } fn create_form(base: &str, head: &str, title: &str) -> String { format!("csrf={ALICE_CSRF}&base={base}&head={head}&title={title}&body=") } #[tokio::test] async fn same_repo_compare_lists_commit_and_file() { let fx = Fixture::new(Visibility::Public).await; let (status, _, body) = get(&fx.router, "/alice/demo/compare/main...feature").await; assert_eq!(status, 200); let html = String::from_utf8(body).unwrap(); assert!(html.contains("Comparing")); assert!(html.contains("Add feature file")); assert!(html.contains("feature.txt")); } #[tokio::test] async fn picker_redirects_to_comparison() { let fx = Fixture::new(Visibility::Public).await; let (status, location, _) = send( &fx.router, "GET", "/alice/demo/compare?base=main&head=feature", None, None, ) .await; assert_eq!(status, 303); assert_eq!( location.as_deref(), Some("/alice/demo/compare/main...feature") ); let (status, _, body) = send(&fx.router, "GET", "/alice/demo/compare", None, None).await; assert_eq!(status, 200); assert!(body.contains("Compare branches")); } #[tokio::test] async fn anonymous_compare_has_no_form() { let fx = Fixture::new(Visibility::Public).await; let (status, _, body) = send( &fx.router, "GET", "/alice/demo/compare/main...feature", None, None, ) .await; assert_eq!(status, 200); assert!(!body.contains("action=\"/alice/demo/pulls\"")); assert!(!body.contains("Open a pull request")); } #[tokio::test] async fn post_creates_pull_and_pull_ref() { let fx = Fixture::new(Visibility::Public).await; let form = create_form("main", "feature", "Add+feature+file"); let (status, location, _) = send( &fx.router, "POST", "/alice/demo/pulls", Some("alice-token"), Some(form.as_str()), ) .await; assert_eq!(status, 303); assert_eq!(location.as_deref(), Some("/alice/demo/pulls/1")); assert_eq!( rev(&fx.bare, "refs/pull/1/head").as_deref(), Some(fx.feature_oid.as_str()) ); } #[tokio::test] async fn duplicate_post_redirects_to_existing_pull() { let fx = Fixture::new(Visibility::Public).await; let form = create_form("main", "feature", "Add+feature+file"); let (status, _, _) = send( &fx.router, "POST", "/alice/demo/pulls", Some("alice-token"), Some(form.as_str()), ) .await; assert_eq!(status, 303); let (status, location, _) = send( &fx.router, "POST", "/alice/demo/pulls", Some("alice-token"), Some(form.as_str()), ) .await; assert_eq!(status, 303); assert_eq!(location.as_deref(), Some("/alice/demo/pulls/1")); let repo = fx .store .repo_by_owner_and_name(OWNER, REPO) .await .unwrap() .unwrap(); assert!( fx.store .pull_by_number(&repo.id, 2) .await .unwrap() .is_none() ); // The comparison now links to the open pull instead of offering the form. let (_, _, body) = send( &fx.router, "GET", "/alice/demo/compare/main...feature", Some("alice-token"), None, ) .await; let html = body; assert!(!html.contains("action=\"/alice/demo/pulls\"")); assert!(html.contains("/alice/demo/pulls/1")); } #[tokio::test] async fn signed_in_compare_shows_form() { let fx = Fixture::new(Visibility::Public).await; let (_, _, body) = send( &fx.router, "GET", "/alice/demo/compare/main...feature", Some("alice-token"), None, ) .await; let html = body; assert!(html.contains("action=\"/alice/demo/pulls\"")); assert!(html.contains("value=\"Add feature file\"")); } #[tokio::test] async fn fork_compare_reads_objects_without_writing_to_base() { let fx = Fixture::new(Visibility::Public).await; let bob_oid = fx.add_bob_fork().await; assert!(!has_object(&fx.bare, &bob_oid)); let (status, _, body) = send( &fx.router, "GET", "/alice/demo/compare/main...bob:bob-work", None, None, ) .await; assert_eq!(status, 200); let html = body; assert!(html.contains("Add bob file")); assert!(html.contains("bob.txt")); // A GET leaves the base repo untouched: no copied object, no ref. assert!(!has_object(&fx.bare, &bob_oid)); assert_eq!(rev(&fx.bare, "refs/pull/1/head"), None); let form = "csrf=bob-csrf&base=main&head=bob:bob-work&title=Add+bob+file&body="; let (status, location, _) = send( &fx.router, "POST", "/alice/demo/pulls", Some("bob-token"), Some(form), ) .await; assert_eq!(status, 303); assert_eq!(location.as_deref(), Some("/alice/demo/pulls/1")); // Opening the pull publishes the head commits to the base and points the pull ref at them. assert!(has_object(&fx.bare, &bob_oid)); assert_eq!( rev(&fx.bare, "refs/pull/1/head").as_deref(), Some(bob_oid.as_str()) ); } #[tokio::test] async fn private_base_is_404_for_anonymous() { let fx = Fixture::new(Visibility::Private).await; let (status, _, _) = send( &fx.router, "GET", "/alice/demo/compare/main...feature", None, None, ) .await; assert_eq!(status, 404); } #[tokio::test] async fn missing_or_wrong_csrf_is_rejected() { let fx = Fixture::new(Visibility::Public).await; let no_csrf = "base=main&head=feature&title=Add+feature+file&body="; let (status, _, _) = send( &fx.router, "POST", "/alice/demo/pulls", Some("alice-token"), Some(no_csrf), ) .await; assert_eq!(status, 403); let wrong = "csrf=nope&base=main&head=feature&title=Add+feature+file&body="; let (status, _, _) = send( &fx.router, "POST", "/alice/demo/pulls", Some("alice-token"), Some(wrong), ) .await; assert_eq!(status, 403); assert!(rev(&fx.bare, "refs/pull/1/head").is_none()); } #[tokio::test] async fn unrelated_head_is_404() { let fx = Fixture::new(Visibility::Public).await; let (status, _, _) = send( &fx.router, "GET", "/alice/demo/compare/main...mallory:feature", None, None, ) .await; assert_eq!(status, 404); let (status, _, _) = send( &fx.router, "GET", "/alice/demo/compare/main...missing", None, None, ) .await; assert_eq!(status, 404); } }