forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
41.6 KiB
1//! Merging a pull request: `POST /{owner}/{repo}/pulls/{number}/merge`.
2//!
3//! The viewer must be signed in, send a valid CSRF token and hold Write on the base repo (404 when
4//! they cannot read it at all, 403 when they can read but not write). The base repo must not be
5//! archived and the pull request must be open with its head repo still present.
6//!
7//! The merge is computed against the current tip of the base branch. Three styles are supported:
8//! a merge commit, a squash commit authored by the pull request author, and a fast-forward. The
9//! base ref is moved with a compare-and-swap, so a base branch that moved meanwhile is refused
10//! with 409 instead of being overwritten. A merge is never a force push, so the pre-receive hook
11//! does not run for it. After the ref moves, the pull request is marked merged and the same
12//! post-update work a push gets runs (closing keywords, maintenance, mergeability of other pulls).
13
14use forge_core::protection::{Protection, effective};
15use forge_core::{Action, IssueState, MergeStyle, PullRequest, ReviewState, Role, User, authorize};
16use forge_git::browse::{CommitInfo, default_branch_commit};
17use forge_git::pulls::{
18 MergeCheck, Signature, check_merge, commit_tree, is_ancestor, range_log, update_ref_cas,
19};
20use forge_git::repo::repo_path;
21use http::HeaderValue;
22use http::header::CONTENT_TYPE;
23use serde::Deserialize;
24use topcoat::Result;
25use topcoat::context::{Cx, app_context};
26use topcoat::router::content::Form;
27use topcoat::router::error::{bad_request, forbidden, not_found, see_other};
28use topcoat::router::response::Response;
29use topcoat::router::{Body, StatusCode, path_param, route};
30
31use crate::auth::require_csrf;
32use crate::internal::trigger_maintenance;
33use crate::notify::{self, Activity};
34use crate::pages::issue_parts::parse_number;
35use crate::pages::repo_ctx::load_repo;
36use crate::post_update::after_ref_update;
37use crate::state::AppState;
38use crate::views::{now_unix, repo_href};
39use crate::webhooks::emit::{Sender, emit_item_action};
40
41path_param!(owner);
42path_param!(repo);
43path_param!(number);
44
45/// Largest accepted merge message, in bytes.
46const MAX_MESSAGE_BYTES: usize = 64 * 1024;
47/// Commits listed in a squash message.
48const MAX_SQUASH_COMMITS: usize = 250;
49
50/// Refusal codes, sent as `?merge_error=<code>` on the pull request page. The page owns the
51/// wording for each code; a code outside this set is never echoed back.
52pub const ERR_CONFLICT: &str = "conflict";
53pub const ERR_UP_TO_DATE: &str = "up_to_date";
54pub const ERR_FF_IMPOSSIBLE: &str = "ff_impossible";
55pub const ERR_BASE_MOVED: &str = "base_moved";
56pub const ERR_ALREADY_MERGED: &str = "already_merged";
57pub const ERR_NOT_OPEN: &str = "not_open";
58pub const ERR_HEAD_MISSING: &str = "head_missing";
59pub const ERR_BASE_MISSING: &str = "base_missing";
60pub const ERR_MESSAGE_INVALID: &str = "message_invalid";
61pub const ERR_NEEDS_APPROVALS: &str = "needs_approvals";
62pub const ERR_CHANGES_REQUESTED: &str = "changes_requested";
63pub const ERR_NOT_UP_TO_DATE: &str = "not_up_to_date";
64
65const CONFLICTS_MSG: &str = "This branch has conflicts that must be resolved";
66const MOVED_MSG: &str = "The base branch moved; reload and try again.";
67const UP_TO_DATE_MSG: &str = "Nothing to merge: the head is already contained in the base branch.";
68const FF_MSG: &str =
69 "fast-forward not possible: the base branch has commits the head does not contain";
70const ALREADY_MERGED_MSG: &str = "This pull request is already merged.";
71const CLOSED_MSG: &str = "This pull request is closed.";
72const HEAD_MISSING_MSG: &str =
73 "The head repository was deleted, so this pull request cannot be merged.";
74const BASE_MISSING_MSG: &str = "The base branch does not exist.";
75
76#[derive(Deserialize)]
77struct MergeForm {
78 #[serde(default)]
79 csrf: String,
80 /// `merge`, `squash` or `ff`. Empty means `merge`.
81 #[serde(default)]
82 style: String,
83 /// Replaces the default commit message when not blank.
84 #[serde(default)]
85 message: String,
86}
87
88/// What a merge attempt did: the base branch now points at `commit`, or the attempt was refused
89/// with an HTTP status and a message for the viewer.
90#[derive(Debug, PartialEq, Eq)]
91pub enum MergeOutcome {
92 Merged { commit: String },
93 Refused { status: StatusCode, message: String },
94}
95
96#[route(POST "/{owner}/{repo}/pulls/{number}/merge")]
97async fn merge_post(cx: &Cx, Form(form): Form<MergeForm>) -> Result<Response> {
98 let number = parse_number(path_param::<Number>(cx))?;
99 let ctx = load_repo(cx, path_param::<Owner>(cx), path_param::<Repo>(cx)).await?;
100 let (user, _) = require_csrf(cx, &form.csrf).await?;
101 if ctx.repo.archived
102 || authorize(
103 Some(&user),
104 &ctx.repo,
105 ctx.role,
106 ctx.org_role,
107 Action::Write,
108 )
109 .is_err()
110 {
111 return Err(forbidden().into());
112 }
113 let state = app_context::<AppState>(cx);
114 let Some(pr) = state.store.pull_by_number(&ctx.repo.id, number).await? else {
115 return Err(not_found().into());
116 };
117 let pull_href = repo_href(
118 &ctx.repo.owner_name,
119 &ctx.repo.name,
120 "pulls",
121 &number.to_string(),
122 );
123 if pr.merged_at.is_some() {
124 return back_to_pull(&pull_href, ERR_ALREADY_MERGED);
125 }
126 if pr.issue.state != IssueState::Open {
127 return back_to_pull(&pull_href, ERR_NOT_OPEN);
128 }
129 if pr.head_repo_id.is_none() {
130 return back_to_pull(&pull_href, ERR_HEAD_MISSING);
131 }
132 if let Some(code) = merge_gate(state, &ctx.repo, &pr).await?.blocked() {
133 return back_to_pull(&pull_href, code);
134 }
135 let style = if form.style.is_empty() {
136 MergeStyle::Merge
137 } else {
138 form.style
139 .parse::<MergeStyle>()
140 .map_err(|e| bad_request(e.to_string()))?
141 };
142 let message = match clean_message(&form.message) {
143 Ok(message) => message,
144 Err(_) => return back_to_pull(&pull_href, ERR_MESSAGE_INVALID),
145 };
146
147 match perform_merge(
148 state,
149 &ctx.repo,
150 &pr,
151 &user,
152 style,
153 message.as_deref(),
154 None,
155 )
156 .await?
157 {
158 MergeOutcome::Merged { .. } => Err(see_other(pull_href).into()),
159 MergeOutcome::Refused { status, message } => match refusal_code(&message) {
160 Some(code) => back_to_pull(&pull_href, code),
161 None => Ok(refusal(status, &message)),
162 },
163 }
164}
165
166/// Sends the viewer back to the pull request with a refusal code in the query.
167fn back_to_pull(pull_href: &str, code: &str) -> Result<Response> {
168 Err(see_other(format!("{pull_href}?merge_error={code}")).into())
169}
170
171/// The refusal code for a message `perform_merge` returns, if it has one.
172fn refusal_code(message: &str) -> Option<&'static str> {
173 Some(match message {
174 CONFLICTS_MSG => ERR_CONFLICT,
175 UP_TO_DATE_MSG => ERR_UP_TO_DATE,
176 FF_MSG => ERR_FF_IMPOSSIBLE,
177 MOVED_MSG => ERR_BASE_MOVED,
178 ALREADY_MERGED_MSG => ERR_ALREADY_MERGED,
179 CLOSED_MSG => ERR_NOT_OPEN,
180 HEAD_MISSING_MSG => ERR_HEAD_MISSING,
181 BASE_MISSING_MSG => ERR_BASE_MISSING,
182 _ => return None,
183 })
184}
185
186/// A blank message means "use the default". Otherwise CRLF becomes LF. A message over 64 KiB or
187/// with a NUL byte is rejected with the reason.
188fn clean_message(raw: &str) -> std::result::Result<Option<String>, &'static str> {
189 if raw.trim().is_empty() {
190 return Ok(None);
191 }
192 if raw.len() > MAX_MESSAGE_BYTES {
193 return Err("the merge message is longer than 64 KiB");
194 }
195 if raw.contains('\0') {
196 return Err("the merge message contains a NUL byte");
197 }
198 Ok(Some(raw.replace("\r\n", "\n")))
199}
200
201/// A plain-text response carrying a refusal message.
202fn refusal(status: StatusCode, message: &str) -> Response {
203 let mut response = Response::new(Body::from(message.to_owned()));
204 *response.status_mut() = status;
205 response.headers_mut().insert(
206 CONTENT_TYPE,
207 HeaderValue::from_static("text/plain; charset=utf-8"),
208 );
209 response
210}
211
212fn refused(status: StatusCode, message: &str) -> MergeOutcome {
213 MergeOutcome::Refused {
214 status,
215 message: message.to_owned(),
216 }
217}
218
219/// Whether `user` is a reviewer whose approvals and change requests count under merge rules:
220/// the repo owner, an instance admin, or a collaborator with Write, Maintain or Admin.
221pub fn counts_for_merge_rules(repo: &forge_core::Repo, user: &User, role: Option<Role>) -> bool {
222 user.is_admin
223 || user.id == repo.owner_id
224 || matches!(role, Some(Role::Write | Role::Maintain | Role::Admin))
225}
226
227/// The base branch's merge rule for a pull request, with the facts that rule checks.
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub struct MergeGate {
230 pub rule: Protection,
231 /// Distinct counting reviewers whose latest submitted review is an approval.
232 pub approvals: u32,
233 /// Some counting reviewer's latest submitted review requests changes.
234 pub changes_requested: bool,
235 /// The head contains the current base tip. Always true when the rule does not require it.
236 pub up_to_date: bool,
237}
238
239impl MergeGate {
240 /// The refusal code for the first rule that blocks the merge, if any.
241 pub fn blocked(&self) -> Option<&'static str> {
242 if self.approvals < self.rule.required_approvals {
243 return Some(ERR_NEEDS_APPROVALS);
244 }
245 if self.rule.block_on_changes_requested && self.changes_requested {
246 return Some(ERR_CHANGES_REQUESTED);
247 }
248 if self.rule.require_up_to_date && !self.up_to_date {
249 return Some(ERR_NOT_UP_TO_DATE);
250 }
251 None
252 }
253}
254
255/// Evaluates the effective rule for the pull request's base branch. Approvals and change requests
256/// count only from `counts_for_merge_rules` reviewers, using each reviewer's latest review.
257/// Repo admins are not exempt. A base branch that does not exist counts as up to date; the merge
258/// itself reports that.
259pub async fn merge_gate(
260 state: &AppState,
261 repo: &forge_core::Repo,
262 pr: &PullRequest,
263) -> forge_core::Result<MergeGate> {
264 let rules = state.store.list_branch_rules(&repo.id).await?;
265 let rule = effective(&rules, &repo.default_branch, &pr.base_branch);
266 let mut approvals = 0u32;
267 let mut changes_requested = false;
268 for (author_id, _, review) in state.store.latest_review_states(&pr.issue.id).await? {
269 let Some(reviewer) = state.store.user_by_id(&author_id).await? else {
270 continue;
271 };
272 let collaborator = state.store.collaborator_role(&repo.id, &author_id).await?;
273 let org = state
274 .store
275 .org_repo_role(&repo.owner_id, &author_id, &repo.id)
276 .await?;
277 let role = forge_core::authz::max_role(collaborator, org);
278 if !counts_for_merge_rules(repo, &reviewer, role) {
279 continue;
280 }
281 match review {
282 ReviewState::Approved => approvals = approvals.saturating_add(1),
283 _ => changes_requested = true,
284 }
285 }
286 let up_to_date = if rule.require_up_to_date {
287 let base_path = repo_path(&state.git.data_dir, &repo.id)?;
288 match default_branch_commit(&base_path, &pr.base_branch)? {
289 Some(tip) => is_ancestor(&state.git, &base_path, &tip, &pr.head_oid).await?,
290 None => true,
291 }
292 } else {
293 true
294 };
295 Ok(MergeGate {
296 rule,
297 approvals,
298 changes_requested,
299 up_to_date,
300 })
301}
302
303/// Merges `pr` into its base branch in `repo` as `viewer`.
304///
305/// The base tip is `expected_base` when given, otherwise the current tip of the base branch. The
306/// merge is checked and built against that tip, and the ref moves only if it still points there.
307/// `message` replaces the default commit message (not used by fast-forward). Refusals that the
308/// viewer can act on come back as `MergeOutcome::Refused`; git and database failures are errors.
309pub async fn perform_merge(
310 state: &AppState,
311 repo: &forge_core::Repo,
312 pr: &PullRequest,
313 viewer: &User,
314 style: MergeStyle,
315 message: Option<&str>,
316 expected_base: Option<&str>,
317) -> forge_core::Result<MergeOutcome> {
318 let base_path = repo_path(&state.git.data_dir, &repo.id)?;
319 let refname = format!("refs/heads/{}", pr.base_branch);
320 let base_tip = match expected_base {
321 Some(oid) => oid.to_owned(),
322 None => match default_branch_commit(&base_path, &pr.base_branch)? {
323 Some(oid) => oid,
324 None => {
325 return Ok(refused(StatusCode::CONFLICT, BASE_MISSING_MSG));
326 }
327 },
328 };
329 let head = pr.head_oid.as_str();
330 let tree = match check_merge(&state.git, &base_path, &base_tip, head).await? {
331 MergeCheck::Clean { tree } => tree,
332 MergeCheck::Conflict { .. } => return Ok(refused(StatusCode::CONFLICT, CONFLICTS_MSG)),
333 MergeCheck::UpToDate => return Ok(refused(StatusCode::CONFLICT, UP_TO_DATE_MSG)),
334 };
335
336 let now = now_unix();
337 let committer = Signature {
338 name: viewer.username.clone(),
339 email: contact_email(&state.config.base_url, viewer),
340 time_unix: now,
341 };
342 let new = match style {
343 MergeStyle::FastForward => {
344 if !is_ancestor(&state.git, &base_path, &base_tip, head).await? {
345 return Ok(refused(StatusCode::CONFLICT, FF_MSG));
346 }
347 head.to_owned()
348 }
349 MergeStyle::Merge => {
350 let text = match message {
351 Some(text) => text.to_owned(),
352 None => default_merge_message(state, pr).await?,
353 };
354 commit_tree(
355 &state.git,
356 &base_path,
357 &tree,
358 &[base_tip.as_str(), head],
359 &text,
360 &committer,
361 &committer,
362 )
363 .await?
364 }
365 MergeStyle::Squash => {
366 let author = match state.store.user_by_id(&pr.issue.author_id).await? {
367 Some(author) => Signature {
368 name: author.username.clone(),
369 email: contact_email(&state.config.base_url, &author),
370 time_unix: now,
371 },
372 None => committer.clone(),
373 };
374 let text = match message {
375 Some(text) => text.to_owned(),
376 None => {
377 let commits =
378 range_log(&state.git, &base_path, &base_tip, head, MAX_SQUASH_COMMITS)
379 .await?;
380 squash_message(pr, &commits)
381 }
382 };
383 commit_tree(
384 &state.git,
385 &base_path,
386 &tree,
387 &[base_tip.as_str()],
388 &text,
389 &author,
390 &committer,
391 )
392 .await?
393 }
394 };
395
396 match update_ref_cas(&state.git, &base_path, &refname, &new, &base_tip).await {
397 Ok(()) => {}
398 Err(forge_git::error::Error::RefMoved(_)) => {
399 return Ok(refused(StatusCode::CONFLICT, MOVED_MSG));
400 }
401 Err(e) => return Err(e.into()),
402 }
403
404 // The ref has moved, so the merge happened. Post-merge steps that fail are logged, not
405 // reported: the viewer's merge is already in place.
406 match state
407 .store
408 .mark_merged(&pr.issue.id, &viewer.id, &new)
409 .await
410 {
411 Ok(()) => {
412 let sender = Sender::of(viewer);
413 emit_item_action(state, repo, &sender, true, pr.issue.number, "merged").await;
414 notify::dispatch(state, repo, &pr.issue, &viewer.id, Activity::Merged, None).await;
415 }
416 Err(e) => {
417 tracing::error!(error = %e, repo_id = %repo.id, number = pr.issue.number, "merge: mark_merged failed");
418 }
419 }
420 trigger_maintenance(&state.git, &repo.id);
421 if let Err(e) = after_ref_update(state, repo, &viewer.id, &refname, &base_tip, &new).await {
422 tracing::error!(error = %e, repo_id = %repo.id, ref_name = %refname, "merge: post-update failed");
423 }
424 Ok(MergeOutcome::Merged { commit: new })
425}
426
427/// The address written into a commit for `user`: their email, or a noreply address on this
428/// instance's host when they have none.
429fn contact_email(base_url: &str, user: &User) -> String {
430 match user
431 .email
432 .as_deref()
433 .map(str::trim)
434 .filter(|e| !e.is_empty())
435 {
436 Some(email) => email.to_owned(),
437 None => format!(
438 "{}@users.noreply.{}",
439 user.username,
440 base_url_host(base_url)
441 ),
442 }
443}
444
445fn base_url_host(base_url: &str) -> String {
446 base_url
447 .parse::<http::Uri>()
448 .ok()
449 .and_then(|uri| uri.host().map(str::to_owned))
450 .unwrap_or_else(|| "localhost".to_owned())
451}
452
453/// `Merge pull request #N from owner/branch`, a blank line, then the pull request title.
454async fn default_merge_message(state: &AppState, pr: &PullRequest) -> forge_core::Result<String> {
455 let head_owner = match pr.head_repo_id.as_deref() {
456 Some(id) => state
457 .store
458 .repo_by_id(id)
459 .await?
460 .map(|head| head.owner_name),
461 None => None,
462 };
463 let head = match head_owner {
464 Some(owner) => format!("{owner}/{}", pr.head_branch),
465 None => pr.head_branch.clone(),
466 };
467 Ok(format!(
468 "Merge pull request #{} from {head}\n\n{}",
469 pr.issue.number, pr.issue.title
470 ))
471}
472
473/// `<title> (#N)`, a blank line, then one `- <summary>` line per commit, oldest first.
474fn squash_message(pr: &PullRequest, commits: &[CommitInfo]) -> String {
475 let mut text = format!("{} (#{})\n\n", pr.issue.title, pr.issue.number);
476 for commit in commits {
477 text.push_str("- ");
478 text.push_str(&commit.summary);
479 text.push('\n');
480 }
481 text
482}
483
484#[cfg(test)]
485mod tests {
486 use std::path::{Path, PathBuf};
487 use std::time::Duration;
488
489 use forge_core::{IssueState, Repo, Role, Store, Visibility};
490 use forge_git::repo::{init_bare, repo_path};
491 use topcoat::router::request::Request;
492 use topcoat::router::{Body, Router, to_bytes};
493
494 use super::*;
495 use crate::auth::SESSION_COOKIE;
496 use crate::test_support::{app_router, create_session, test_state};
497
498 const CSRF: &str = "merge-test-csrf";
499
500 fn git(dir: &Path, args: &[&str]) -> String {
501 let out = std::process::Command::new("git")
502 .arg("-C")
503 .arg(dir)
504 .args(args)
505 .env("GIT_AUTHOR_NAME", "test")
506 .env("GIT_AUTHOR_EMAIL", "test@example.com")
507 .env("GIT_COMMITTER_NAME", "test")
508 .env("GIT_COMMITTER_EMAIL", "test@example.com")
509 .output()
510 .expect("git runs");
511 assert!(
512 out.status.success(),
513 "git {args:?} failed: {}",
514 String::from_utf8_lossy(&out.stderr)
515 );
516 String::from_utf8_lossy(&out.stdout).trim().to_string()
517 }
518
519 /// Writes `contents` to `file` in the work tree, commits it with `contents` as the message.
520 fn commit(work: &Path, file: &str, contents: &str) -> String {
521 std::fs::write(work.join(file), contents).expect("write file");
522 git(work, &["add", file]);
523 git(work, &["commit", "-m", contents]);
524 git(work, &["rev-parse", "HEAD"])
525 }
526
527 fn tip(dir: &Path, rev: &str) -> String {
528 git(dir, &["rev-parse", rev])
529 }
530
531 struct Fixture {
532 _tmp: tempfile::TempDir,
533 state: AppState,
534 app: Router,
535 repo: Repo,
536 alice: User,
537 bob: User,
538 alice_cookie: String,
539 bare: PathBuf,
540 work: PathBuf,
541 }
542
543 /// A private repo `alice/proj` with `main` holding `file.txt`, a bare copy on disk and a work
544 /// tree that pushes to it. Bob is a user with no role on the repo.
545 async fn fixture() -> Fixture {
546 let tmp = tempfile::tempdir().expect("tempdir");
547 let state = test_state(tmp.path()).await;
548 let store: Store = state.store.clone();
549 let alice = store
550 .create_user("alice", None, "unused", false)
551 .await
552 .expect("alice");
553 let bob = store
554 .create_user("bob", None, "unused", false)
555 .await
556 .expect("bob");
557 let repo = store
558 .create_repo(&alice.id, "proj", "", Visibility::Private, "main")
559 .await
560 .expect("repo");
561 let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path");
562 init_bare(&state.git, &bare, "main")
563 .await
564 .expect("init bare");
565 let work = tmp.path().join("work");
566 git(tmp.path(), &["init", "-b", "main", "work"]);
567 commit(&work, "file.txt", "one");
568 git(&work, &["push", bare.to_str().expect("utf8"), "main"]);
569
570 create_session(&store, &alice.id, "alice-token", CSRF).await;
571 let alice_cookie = format!("__Host-{SESSION_COOKIE}=alice-token");
572 let app = app_router(state.clone());
573 Fixture {
574 _tmp: tmp,
575 state,
576 app,
577 repo,
578 alice,
579 bob,
580 alice_cookie,
581 bare,
582 work,
583 }
584 }
585
586 impl Fixture {
587 fn bare_str(&self) -> &str {
588 self.bare.to_str().expect("utf8 path")
589 }
590
591 fn main_tip(&self) -> String {
592 tip(&self.bare, "refs/heads/main")
593 }
594
595 /// Pushes `main` (or any branch) from the work tree to the bare repo.
596 fn push(&self, branch: &str) {
597 git(&self.work, &["push", self.bare_str(), branch]);
598 }
599
600 async fn pull(&self, number: i64) -> PullRequest {
601 self.state
602 .store
603 .pull_by_number(&self.repo.id, number)
604 .await
605 .expect("lookup")
606 .expect("pull exists")
607 }
608
609 /// Commits `contents` to `file` on a branch `branch` cut from main, pushes it and opens a
610 /// pull request from it, authored by `author`. Returns the pull request number.
611 async fn open_feature(
612 &self,
613 branch: &str,
614 file: &str,
615 contents: &str,
616 author: &User,
617 ) -> i64 {
618 git(&self.work, &["checkout", "main"]);
619 git(&self.work, &["checkout", "-B", branch]);
620 commit(&self.work, file, contents);
621 self.push(branch);
622 let base = self.main_tip();
623 let head = tip(&self.bare, &format!("refs/heads/{branch}"));
624 let pr = self
625 .state
626 .store
627 .create_pull(
628 &self.repo.id,
629 &author.id,
630 &format!("Add {branch}"),
631 "",
632 "main",
633 &self.repo.id,
634 branch,
635 &head,
636 &base,
637 )
638 .await
639 .expect("create pull");
640 pr.issue.number
641 }
642
643 /// Adds a commit with `file` to main and pushes it.
644 fn advance_main(&self, file: &str, contents: &str) {
645 git(&self.work, &["checkout", "main"]);
646 commit(&self.work, file, contents);
647 self.push("main");
648 }
649
650 async fn post(&self, path: &str, cookie: Option<&str>, body: String) -> Reply {
651 let mut builder = Request::builder()
652 .method("POST")
653 .uri(path)
654 .header("content-type", "application/x-www-form-urlencoded");
655 if let Some(cookie) = cookie {
656 builder = builder.header("cookie", cookie);
657 }
658 let res = self
659 .app
660 .handle(builder.body(Body::from(body)).expect("request"))
661 .await;
662 let status = res.status().as_u16();
663 let location = res
664 .headers()
665 .get("location")
666 .and_then(|v| v.to_str().ok())
667 .map(str::to_owned);
668 let bytes = to_bytes(res.into_body(), usize::MAX).await.expect("body");
669 Reply {
670 status,
671 location,
672 body: String::from_utf8_lossy(&bytes).into_owned(),
673 }
674 }
675
676 /// Signs `user` in with a fresh session and returns the cookie header value.
677 async fn sign_in(&self, user: &User, token: &str) -> String {
678 create_session(&self.state.store, &user.id, token, CSRF).await;
679 format!("__Host-{SESSION_COOKIE}={token}")
680 }
681
682 async fn merge(&self, number: i64, style: &str) -> Reply {
683 self.post(
684 &format!("/alice/proj/pulls/{number}/merge"),
685 Some(&self.alice_cookie),
686 merge_form(style, None),
687 )
688 .await
689 }
690 }
691
692 struct Reply {
693 status: u16,
694 location: Option<String>,
695 body: String,
696 }
697
698 /// A refusal redirects (303) back to the pull request with `merge_error=<code>`.
699 fn assert_refused(reply: &Reply, number: i64, code: &str) {
700 assert_eq!(reply.status, 303, "body: {}", reply.body);
701 assert_eq!(
702 reply.location.as_deref(),
703 Some(format!("/alice/proj/pulls/{number}?merge_error={code}").as_str())
704 );
705 }
706
707 fn merge_form(style: &str, message: Option<&str>) -> String {
708 let mut form = form_urlencoded::Serializer::new(String::new());
709 form.append_pair("csrf", CSRF).append_pair("style", style);
710 if let Some(message) = message {
711 form.append_pair("message", message);
712 }
713 form.finish()
714 }
715
716 #[tokio::test]
717 async fn merge_commit_joins_both_histories() {
718 let fx = fixture().await;
719 let number = fx
720 .open_feature("feature", "feature.txt", "f1", &fx.bob)
721 .await;
722 let before = fx.main_tip();
723 let head = tip(&fx.bare, "refs/heads/feature");
724
725 let reply = fx.merge(number, "merge").await;
726 assert_eq!(reply.status, 303, "body: {}", reply.body);
727 assert_eq!(
728 reply.location.as_deref(),
729 Some(format!("/alice/proj/pulls/{number}").as_str())
730 );
731
732 let merged = fx.main_tip();
733 assert_ne!(merged, before);
734 let parents = git(
735 &fx.bare,
736 &["rev-list", "--parents", "-n", "1", "refs/heads/main"],
737 );
738 assert_eq!(
739 parents.split_whitespace().count(),
740 3,
741 "two parents plus the commit"
742 );
743 assert!(parents.contains(&before) && parents.contains(&head));
744 assert_eq!(
745 git(&fx.bare, &["log", "-1", "--format=%s", "refs/heads/main"]),
746 format!("Merge pull request #{number} from alice/feature")
747 );
748 assert_eq!(
749 git(
750 &fx.bare,
751 &["log", "-1", "--format=%an|%cn|%ce", "refs/heads/main"]
752 ),
753 "alice|alice|alice@users.noreply.forge.test"
754 );
755 assert_eq!(
756 git(&fx.bare, &["show", "refs/heads/main:feature.txt"]),
757 "f1"
758 );
759
760 let pr = fx.pull(number).await;
761 assert!(pr.merged_at.is_some());
762 assert_eq!(pr.merge_commit.as_deref(), Some(merged.as_str()));
763 assert_eq!(pr.issue.state, IssueState::Closed);
764 let events = fx
765 .state
766 .store
767 .list_events(&pr.issue.id)
768 .await
769 .expect("events");
770 assert!(events.iter().any(|e| e.kind == "merged"));
771 }
772
773 #[tokio::test]
774 async fn squash_merge_is_authored_by_pull_request_author() {
775 let fx = fixture().await;
776 let number = fx
777 .open_feature("feature", "feature.txt", "f1", &fx.bob)
778 .await;
779
780 let reply = fx.merge(number, "squash").await;
781 assert_eq!(reply.status, 303, "body: {}", reply.body);
782
783 let parents = git(
784 &fx.bare,
785 &["rev-list", "--parents", "-n", "1", "refs/heads/main"],
786 );
787 assert_eq!(
788 parents.split_whitespace().count(),
789 2,
790 "one parent plus the commit"
791 );
792 assert_eq!(
793 git(
794 &fx.bare,
795 &["log", "-1", "--format=%an|%cn", "refs/heads/main"]
796 ),
797 "bob|alice"
798 );
799 let subject = git(&fx.bare, &["log", "-1", "--format=%s", "refs/heads/main"]);
800 assert_eq!(subject, format!("Add feature (#{number})"));
801 let pr = fx.pull(number).await;
802 assert!(pr.merged_at.is_some());
803 }
804
805 #[tokio::test]
806 async fn fast_forward_moves_base_to_head() {
807 let fx = fixture().await;
808 let number = fx
809 .open_feature("feature", "feature.txt", "f1", &fx.bob)
810 .await;
811 let head = tip(&fx.bare, "refs/heads/feature");
812
813 let reply = fx.merge(number, "ff").await;
814 assert_eq!(reply.status, 303, "body: {}", reply.body);
815 assert_eq!(fx.main_tip(), head);
816 assert!(fx.pull(number).await.merged_at.is_some());
817 }
818
819 #[tokio::test]
820 async fn fast_forward_is_refused_when_base_has_diverged() {
821 let fx = fixture().await;
822 let number = fx
823 .open_feature("feature", "feature.txt", "f1", &fx.bob)
824 .await;
825 fx.advance_main("other.txt", "other");
826 let before = fx.main_tip();
827
828 let reply = fx.merge(number, "ff").await;
829 assert_refused(&reply, number, ERR_FF_IMPOSSIBLE);
830 assert_eq!(fx.main_tip(), before);
831 assert!(fx.pull(number).await.merged_at.is_none());
832 }
833
834 #[tokio::test]
835 async fn conflicting_pull_request_is_refused_and_base_unchanged() {
836 let fx = fixture().await;
837 let number = fx
838 .open_feature("feature", "file.txt", "feature", &fx.bob)
839 .await;
840 fx.advance_main("file.txt", "main");
841 let before = fx.main_tip();
842
843 let reply = fx.merge(number, "merge").await;
844 assert_refused(&reply, number, ERR_CONFLICT);
845 assert_eq!(fx.main_tip(), before);
846 assert!(fx.pull(number).await.merged_at.is_none());
847 }
848
849 #[tokio::test]
850 async fn head_already_in_base_is_refused_as_up_to_date() {
851 let fx = fixture().await;
852 let number = fx
853 .open_feature("feature", "feature.txt", "f1", &fx.bob)
854 .await;
855 // Move main onto the feature head, so the head is already contained in the base.
856 fx.push("feature:main");
857 let before = fx.main_tip();
858
859 let reply = fx.merge(number, "merge").await;
860 assert_refused(&reply, number, ERR_UP_TO_DATE);
861 assert_eq!(fx.main_tip(), before);
862 }
863
864 #[tokio::test]
865 async fn invalid_message_is_refused_and_base_unchanged() {
866 let fx = fixture().await;
867 let number = fx
868 .open_feature("feature", "feature.txt", "f1", &fx.bob)
869 .await;
870 let before = fx.main_tip();
871
872 let long = "x".repeat(MAX_MESSAGE_BYTES + 1);
873 let reply = fx
874 .post(
875 &format!("/alice/proj/pulls/{number}/merge"),
876 Some(&fx.alice_cookie),
877 merge_form("merge", Some(&long)),
878 )
879 .await;
880 assert_refused(&reply, number, ERR_MESSAGE_INVALID);
881
882 let reply = fx
883 .post(
884 &format!("/alice/proj/pulls/{number}/merge"),
885 Some(&fx.alice_cookie),
886 merge_form("merge", Some("a\0b")),
887 )
888 .await;
889 assert_refused(&reply, number, ERR_MESSAGE_INVALID);
890 assert_eq!(fx.main_tip(), before);
891 assert!(fx.pull(number).await.merged_at.is_none());
892 }
893
894 fn rule(
895 required_approvals: u32,
896 block_on_changes_requested: bool,
897 require_up_to_date: bool,
898 ) -> forge_core::protection::RuleFlags {
899 forge_core::protection::RuleFlags {
900 required_approvals,
901 block_on_changes_requested,
902 require_up_to_date,
903 ..Default::default()
904 }
905 }
906
907 #[tokio::test]
908 async fn merge_waits_for_write_approvals_and_ignores_read_approvals() {
909 let fx = fixture().await;
910 let number = fx
911 .open_feature("feature", "feature.txt", "f1", &fx.bob)
912 .await;
913 let pr = fx.pull(number).await;
914 fx.state
915 .store
916 .upsert_branch_rule(&fx.repo.id, "main", rule(1, false, false))
917 .await
918 .expect("rule");
919 let carol = fx
920 .state
921 .store
922 .create_user("carol", None, "unused", false)
923 .await
924 .expect("carol");
925 let dave = fx
926 .state
927 .store
928 .create_user("dave", None, "unused", false)
929 .await
930 .expect("dave");
931 fx.state
932 .store
933 .set_collaborator(&fx.repo.id, &carol.id, Role::Write)
934 .await
935 .expect("carol role");
936 fx.state
937 .store
938 .set_collaborator(&fx.repo.id, &dave.id, Role::Read)
939 .await
940 .expect("dave role");
941 let before = fx.main_tip();
942
943 // No approvals yet.
944 assert_refused(&fx.merge(number, "merge").await, number, "needs_approvals");
945 assert_eq!(fx.main_tip(), before);
946
947 // A Read collaborator's approval does not count.
948 fx.state
949 .store
950 .submit_review(
951 &pr.issue.id,
952 &dave.id,
953 ReviewState::Approved,
954 "",
955 &pr.head_oid,
956 )
957 .await
958 .expect("dave approves");
959 assert_refused(&fx.merge(number, "merge").await, number, "needs_approvals");
960 assert_eq!(fx.main_tip(), before);
961
962 // A Write collaborator's approval does.
963 fx.state
964 .store
965 .submit_review(
966 &pr.issue.id,
967 &carol.id,
968 ReviewState::Approved,
969 "",
970 &pr.head_oid,
971 )
972 .await
973 .expect("carol approves");
974 let reply = fx.merge(number, "merge").await;
975 assert_eq!(reply.status, 303, "body: {}", reply.body);
976 assert_eq!(
977 reply.location.as_deref(),
978 Some(format!("/alice/proj/pulls/{number}").as_str())
979 );
980 assert_ne!(fx.main_tip(), before);
981 }
982
983 #[tokio::test]
984 async fn changes_requested_blocks_only_when_the_rule_says_so() {
985 let fx = fixture().await;
986 let number = fx
987 .open_feature("feature", "feature.txt", "f1", &fx.bob)
988 .await;
989 let pr = fx.pull(number).await;
990 let carol = fx
991 .state
992 .store
993 .create_user("carol", None, "unused", false)
994 .await
995 .expect("carol");
996 fx.state
997 .store
998 .set_collaborator(&fx.repo.id, &carol.id, Role::Maintain)
999 .await
1000 .expect("carol role");
1001 fx.state
1002 .store
1003 .submit_review(
1004 &pr.issue.id,
1005 &carol.id,
1006 ReviewState::ChangesRequested,
1007 "",
1008 &pr.head_oid,
1009 )
1010 .await
1011 .expect("carol requests changes");
1012 let before = fx.main_tip();
1013
1014 fx.state
1015 .store
1016 .upsert_branch_rule(&fx.repo.id, "main", rule(0, true, false))
1017 .await
1018 .expect("rule");
1019 assert_refused(
1020 &fx.merge(number, "merge").await,
1021 number,
1022 "changes_requested",
1023 );
1024 assert_eq!(fx.main_tip(), before);
1025
1026 // Without the block flag the same change request does not stop the merge.
1027 fx.state
1028 .store
1029 .upsert_branch_rule(&fx.repo.id, "main", rule(0, false, false))
1030 .await
1031 .expect("rule");
1032 let reply = fx.merge(number, "merge").await;
1033 assert_eq!(reply.status, 303, "body: {}", reply.body);
1034 assert_ne!(fx.main_tip(), before);
1035 }
1036
1037 #[tokio::test]
1038 async fn require_up_to_date_blocks_until_head_contains_base() {
1039 let fx = fixture().await;
1040 let number = fx
1041 .open_feature("feature", "feature.txt", "f1", &fx.bob)
1042 .await;
1043 fx.state
1044 .store
1045 .upsert_branch_rule(&fx.repo.id, "main", rule(0, false, true))
1046 .await
1047 .expect("rule");
1048 fx.advance_main("other.txt", "moved");
1049 assert_refused(&fx.merge(number, "merge").await, number, "not_up_to_date");
1050
1051 // Merging the new base into the feature branch and pushing it brings the head up to date.
1052 git(&fx.work, &["checkout", "feature"]);
1053 git(&fx.work, &["merge", "--no-edit", "main"]);
1054 fx.push("feature");
1055 let head = tip(&fx.bare, "refs/heads/feature");
1056 let pr = fx.pull(number).await;
1057 fx.state
1058 .store
1059 .update_pull_head(&pr.issue.id, &head)
1060 .await
1061 .expect("head moved");
1062
1063 let reply = fx.merge(number, "merge").await;
1064 assert_eq!(reply.status, 303, "body: {}", reply.body);
1065 assert_eq!(
1066 reply.location.as_deref(),
1067 Some(format!("/alice/proj/pulls/{number}").as_str())
1068 );
1069 }
1070
1071 #[tokio::test]
1072 async fn read_only_collaborator_gets_403() {
1073 let fx = fixture().await;
1074 let number = fx
1075 .open_feature("feature", "feature.txt", "f1", &fx.bob)
1076 .await;
1077 fx.state
1078 .store
1079 .set_collaborator(&fx.repo.id, &fx.bob.id, Role::Read)
1080 .await
1081 .expect("collaborator");
1082 let bob_cookie = fx.sign_in(&fx.bob, "bob-token").await;
1083 let before = fx.main_tip();
1084
1085 let reply = fx
1086 .post(
1087 &format!("/alice/proj/pulls/{number}/merge"),
1088 Some(&bob_cookie),
1089 merge_form("merge", None),
1090 )
1091 .await;
1092 assert_eq!(reply.status, 403);
1093 assert_eq!(fx.main_tip(), before);
1094 }
1095
1096 #[tokio::test]
1097 async fn anonymous_viewer_of_private_repo_gets_404() {
1098 let fx = fixture().await;
1099 let number = fx
1100 .open_feature("feature", "feature.txt", "f1", &fx.bob)
1101 .await;
1102
1103 let reply = fx
1104 .post(
1105 &format!("/alice/proj/pulls/{number}/merge"),
1106 None,
1107 merge_form("merge", None),
1108 )
1109 .await;
1110 assert_eq!(reply.status, 404);
1111 }
1112
1113 #[tokio::test]
1114 async fn missing_csrf_is_rejected() {
1115 let fx = fixture().await;
1116 let number = fx
1117 .open_feature("feature", "feature.txt", "f1", &fx.bob)
1118 .await;
1119 let before = fx.main_tip();
1120
1121 let reply = fx
1122 .post(
1123 &format!("/alice/proj/pulls/{number}/merge"),
1124 Some(&fx.alice_cookie),
1125 "style=merge".to_owned(),
1126 )
1127 .await;
1128 assert_eq!(reply.status, 403);
1129 assert_eq!(fx.main_tip(), before);
1130 }
1131
1132 #[tokio::test]
1133 async fn already_merged_pull_request_is_rejected() {
1134 let fx = fixture().await;
1135 let number = fx
1136 .open_feature("feature", "feature.txt", "f1", &fx.bob)
1137 .await;
1138 assert_eq!(fx.merge(number, "merge").await.status, 303);
1139 let after_first = fx.main_tip();
1140
1141 let reply = fx.merge(number, "merge").await;
1142 assert_refused(&reply, number, ERR_ALREADY_MERGED);
1143 assert_eq!(fx.main_tip(), after_first);
1144 }
1145
1146 #[tokio::test]
1147 async fn closing_keyword_in_merged_commit_closes_issue() {
1148 let fx = fixture().await;
1149 let issue = fx
1150 .state
1151 .store
1152 .create_issue(&fx.repo.id, &fx.alice.id, "Bug", "")
1153 .await
1154 .expect("issue");
1155 assert_eq!(issue.number, 1);
1156 let number = fx
1157 .open_feature("feature", "feature.txt", "Fixes #1", &fx.bob)
1158 .await;
1159
1160 assert_eq!(fx.merge(number, "merge").await.status, 303);
1161
1162 // Closing keywords run in a spawned task after the merge, so poll for the result.
1163 let mut closed = false;
1164 for _ in 0..200 {
1165 let current = fx
1166 .state
1167 .store
1168 .issue_by_number(&fx.repo.id, 1)
1169 .await
1170 .expect("lookup")
1171 .expect("issue exists");
1172 if current.state == IssueState::Closed {
1173 closed = true;
1174 break;
1175 }
1176 tokio::time::sleep(Duration::from_millis(10)).await;
1177 }
1178 assert!(closed, "issue 1 should be closed by the merged commit");
1179 }
1180
1181 #[tokio::test]
1182 async fn base_moved_after_load_is_refused_by_compare_and_swap() {
1183 let fx = fixture().await;
1184 let number = fx
1185 .open_feature("feature", "feature.txt", "f1", &fx.bob)
1186 .await;
1187 let pr = fx.pull(number).await;
1188 let stale_base = fx.main_tip();
1189 fx.advance_main("other.txt", "other");
1190 let moved = fx.main_tip();
1191 let alice = fx
1192 .state
1193 .store
1194 .user_by_id(&fx.alice.id)
1195 .await
1196 .expect("lookup")
1197 .expect("alice");
1198
1199 let outcome = perform_merge(
1200 &fx.state,
1201 &fx.repo,
1202 &pr,
1203 &alice,
1204 MergeStyle::Merge,
1205 None,
1206 Some(&stale_base),
1207 )
1208 .await
1209 .expect("perform_merge");
1210 assert_eq!(
1211 outcome,
1212 MergeOutcome::Refused {
1213 status: StatusCode::CONFLICT,
1214 message: MOVED_MSG.to_owned(),
1215 }
1216 );
1217 assert_eq!(fx.main_tip(), moved);
1218 assert!(fx.pull(number).await.merged_at.is_none());
1219 }
1220
1221 #[test]
1222 fn clean_message_normalizes_and_limits() {
1223 assert_eq!(clean_message(" \r\n ").expect("blank"), None);
1224 assert_eq!(
1225 clean_message("a\r\nb").expect("text"),
1226 Some("a\nb".to_owned())
1227 );
1228 assert!(clean_message(&"x".repeat(MAX_MESSAGE_BYTES + 1)).is_err());
1229 assert!(clean_message("a\0b").is_err());
1230 }
1231}