| 1 | //! How a pull request merges: by which method (the repository's settings, |
| 2 | //! narrowed by the rules for the branch it merges into), with what commit |
| 3 | //! message (merge_message.rs), what happens when its base has moved, and |
| 4 | //! its branch afterwards, deleted and restored. |
| 5 | //! |
| 6 | //! `merge_pull` (lib.rs) asks these questions; `merge_choices` answers them |
| 7 | //! for the merge box before anyone clicks. |
| 8 | |
| 9 | use g1t_contracts::access::{self, Capability}; |
| 10 | use g1t_contracts::events::PullEvent; |
| 11 | use g1t_contracts::repos::{CreateBranchArgs, HeadArgs, RemoveBranchArgs, Repo, RepoPath}; |
| 12 | use g1t_contracts::rules::{InspectCommitsArgs, InspectedCommits, MergeMethod}; |
| 13 | use g1t_contracts::work::*; |
| 14 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, User}; |
| 15 | use g1t_rules::merge::{Requirements, method_label}; |
| 16 | use worker::Result; |
| 17 | |
| 18 | use crate::merge_message::{self, CommitText, MessageFacts}; |
| 19 | use crate::retired::writable; |
| 20 | use crate::{Work, allowed, no_pull}; |
| 21 | |
| 22 | /// Unwraps an `Outcome`, returning its failure from the enclosing method. |
| 23 | macro_rules! check { |
| 24 | ($outcome:expr) => { |
| 25 | match $outcome { |
| 26 | Outcome::Ok(value) => value, |
| 27 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 28 | } |
| 29 | }; |
| 30 | } |
| 31 | |
| 32 | /// How many of a pull request's commits its default messages read. |
| 33 | const MESSAGE_COMMITS: u32 = 250; |
| 34 | |
| 35 | /// Methods named for people, joined: "squash or rebase". |
| 36 | fn labels(methods: &[MergeMethod]) -> String { |
| 37 | let named: Vec<&str> = methods.iter().map(|method| method_label(*method)).collect(); |
| 38 | match named.as_slice() { |
| 39 | [] => "no method".to_owned(), |
| 40 | [one] => (*one).to_owned(), |
| 41 | [rest @ .., last] => format!("{} or {last}", rest.join(", ")), |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | /// The methods a pull request may merge by: those the repository allows, |
| 46 | /// as the rules for its base allow them, in the order offered. |
| 47 | pub(crate) fn offered(settings: &RepoSettings, requirements: &Requirements) -> Vec<MergeMethod> { |
| 48 | settings.allowed_methods().into_iter().filter(|method| requirements.allows_method(*method)).collect() |
| 49 | } |
| 50 | |
| 51 | /// The method a merge uses: the one asked for, if the repository and the |
| 52 | /// rules for `base` allow it; with none asked for, the repository's |
| 53 | /// default where it may be used, else the first that may. Why not, if |
| 54 | /// none can. |
| 55 | pub(crate) fn choose_method( |
| 56 | asked: Option<MergeMethod>, |
| 57 | settings: &RepoSettings, |
| 58 | requirements: &Requirements, |
| 59 | base: &str, |
| 60 | ) -> std::result::Result<MergeMethod, String> { |
| 61 | let offered = offered(settings, requirements); |
| 62 | let Some(method) = asked else { |
| 63 | return if offered.contains(&settings.default_merge_method) { |
| 64 | Ok(settings.default_merge_method) |
| 65 | } else { |
| 66 | offered.first().copied().ok_or_else(|| { |
| 67 | format!( |
| 68 | "No merge method this repository allows ({}) is allowed by the rules for {base}. Its admins can allow another in Settings.", |
| 69 | labels(&settings.allowed_methods()) |
| 70 | ) |
| 71 | }) |
| 72 | }; |
| 73 | }; |
| 74 | if !settings.allows(method) { |
| 75 | return Err(format!( |
| 76 | "This repository does not allow merging by {}. It allows {}.", |
| 77 | method_label(method), |
| 78 | labels(&settings.allowed_methods()) |
| 79 | )); |
| 80 | } |
| 81 | if requirements.linear_history && !method.is_linear() { |
| 82 | return Err(format!( |
| 83 | "{base} requires linear history, so a merge commit cannot land there. Merge by {} instead.", |
| 84 | labels(&offered) |
| 85 | )); |
| 86 | } |
| 87 | if !requirements.allows_method(method) { |
| 88 | return Err(format!( |
| 89 | "The rules for {base} allow merging only by {}, not by {}.", |
| 90 | labels(requirements.allowed_merge_methods.as_deref().unwrap_or_default()), |
| 91 | method_label(method) |
| 92 | )); |
| 93 | } |
| 94 | Ok(method) |
| 95 | } |
| 96 | |
| 97 | /// What a merge does about its base having moved without it. |
| 98 | #[derive(Debug, PartialEq, Eq)] |
| 99 | pub(crate) enum WhenBehind { |
| 100 | /// Land it now: it is not behind, or the method makes new commits on |
| 101 | /// the base's head anyway. |
| 102 | Land, |
| 103 | /// Bring it up to date first, and land it once that push arrives: a |
| 104 | /// fast-forward needs the base's head in it. |
| 105 | CatchUpFirst, |
| 106 | /// Refuse, saying why. |
| 107 | Refuse(String), |
| 108 | } |
| 109 | |
| 110 | /// What to do when `behind`, for a merge by `method`. |
| 111 | pub(crate) fn when_behind(method: MergeMethod, behind: bool, require_up_to_date: bool, base: &str) -> WhenBehind { |
| 112 | if !behind { |
| 113 | return WhenBehind::Land; |
| 114 | } |
| 115 | if require_up_to_date { |
| 116 | return WhenBehind::Refuse(format!( |
| 117 | "{base} has moved since this pull request was made, and this repository requires pull requests to be up to date before they merge. Catch up with {base} first." |
| 118 | )); |
| 119 | } |
| 120 | match method { |
| 121 | MergeMethod::FastForward => WhenBehind::CatchUpFirst, |
| 122 | MergeMethod::Merge | MergeMethod::Squash | MergeMethod::Rebase => WhenBehind::Land, |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | /// Why a fast-forward cannot catch up first: whoever merges cannot push |
| 127 | /// to where the change is. |
| 128 | pub(crate) fn cannot_fast_forward(base: &str, source: &str) -> String { |
| 129 | format!( |
| 130 | "This pull request can only fast-forward once it is up to date with {base}, and its branch is in {source}, which you cannot push to. Merge it another way, or ask its author to catch it up with {base} first." |
| 131 | ) |
| 132 | } |
| 133 | |
| 134 | /// Who a squash commit is by: the person the pull request is theirs (who |
| 135 | /// asked g1t for it, or its author). The merger, for an agent's or g1t's |
| 136 | /// own, and for any other method. |
| 137 | pub(crate) fn squash_author(pull: &Pull, method: MergeMethod) -> Option<User> { |
| 138 | let owner = pull.owner(); |
| 139 | (method == MergeMethod::Squash && owner.kind == PrincipalKind::User && !g1t_contracts::rules::is_agent(owner)).then(|| owner.clone()) |
| 140 | } |
| 141 | |
| 142 | /// The branch a pull request's merge or close may delete, and where it is: |
| 143 | /// a branch of the repository, never its default branch nor the branch it |
| 144 | /// merges into, or a branch of a fork. Never a working copy's, which repos |
| 145 | /// retires with it. |
| 146 | pub(crate) fn deletable_branch(pull: &Pull, repo: &Repo) -> Option<(RepoPath, String)> { |
| 147 | if pull.fork_repo_id.is_some() { |
| 148 | return None; |
| 149 | } |
| 150 | let branch = pull.branch.as_deref().map(str::trim).filter(|branch| !branch.is_empty())?; |
| 151 | if pull.from_head_repo() { |
| 152 | return Some((pull.head_repo.clone()?, branch.to_owned())); |
| 153 | } |
| 154 | if branch == repo.default_branch || branch == pull.base_branch(&repo.default_branch) { |
| 155 | return None; |
| 156 | } |
| 157 | Some((RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }, branch.to_owned())) |
| 158 | } |
| 159 | |
| 160 | /// What the timeline says a merge by `method` did. |
| 161 | pub(crate) fn merged_note(method: MergeMethod) -> &'static str { |
| 162 | match method { |
| 163 | MergeMethod::Squash => "squashed and merged this", |
| 164 | MergeMethod::Rebase => "rebased and merged this", |
| 165 | MergeMethod::Merge | MergeMethod::FastForward => "merged this", |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | /// The issues a merge into the default branch closes, where the repository |
| 170 | /// closes them: its own and those it names (closing.rs). None into another |
| 171 | /// branch, or when told to keep them open. |
| 172 | pub(crate) fn issues_to_close(pull: &Pull, repo: &Repo, settings: &RepoSettings, keep_issue_open: bool) -> Vec<u32> { |
| 173 | if keep_issue_open || !settings.auto_close_issues || !pull.targets_default(&repo.default_branch) { |
| 174 | return Vec::new(); |
| 175 | } |
| 176 | let path = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }; |
| 177 | crate::closing::closes(pull.issue, &pull.title, pull.body.as_deref(), &path) |
| 178 | } |
| 179 | |
| 180 | impl Work { |
| 181 | /// A pull request's commits as its default messages read them, oldest |
| 182 | /// first, merge commits left out. None when they cannot be read. |
| 183 | async fn commit_texts(&self, repo: &Repo, pull: &Pull) -> Result<Vec<CommitText>> { |
| 184 | let Some(head) = pull.head_commit.clone() else { |
| 185 | return Ok(Vec::new()); |
| 186 | }; |
| 187 | let found: Outcome<InspectedCommits> = g1t_kit::call( |
| 188 | &self.repos, |
| 189 | "inspect_commits", |
| 190 | &InspectCommitsArgs { |
| 191 | source_id: pull.source_id().to_owned(), |
| 192 | head, |
| 193 | target_id: repo.id.clone(), |
| 194 | base_branch: pull.base_branch(&repo.default_branch).to_owned(), |
| 195 | limit: Some(MESSAGE_COMMITS), |
| 196 | }, |
| 197 | ) |
| 198 | .await?; |
| 199 | let Outcome::Ok(inspected) = found else { |
| 200 | return Ok(Vec::new()); |
| 201 | }; |
| 202 | // Read newest first. |
| 203 | Ok(inspected |
| 204 | .commits |
| 205 | .into_iter() |
| 206 | .rev() |
| 207 | .filter(|commit| commit.parents <= 1) |
| 208 | .map(|commit| CommitText { message: commit.message, author_email: commit.author_email }) |
| 209 | .collect()) |
| 210 | } |
| 211 | |
| 212 | /// The default title and body of the commit each of `methods` makes |
| 213 | /// for `pull`, as the repository's settings write them: `None` for one |
| 214 | /// that makes no commit of its own. |
| 215 | pub(crate) async fn default_messages( |
| 216 | &self, |
| 217 | repo: &Repo, |
| 218 | pull: &Pull, |
| 219 | settings: &RepoSettings, |
| 220 | methods: &[MergeMethod], |
| 221 | ) -> Result<Vec<(MergeMethod, Option<(String, String)>)>> { |
| 222 | let commits = if methods.iter().any(|method| merge_message::needs_commits(*method, settings)) { |
| 223 | self.commit_texts(repo, pull).await? |
| 224 | } else { |
| 225 | Vec::new() |
| 226 | }; |
| 227 | let owner = pull.owner().username.clone(); |
| 228 | let (head_owner, head_repo) = match (&pull.fork, &pull.head_repo) { |
| 229 | // A working copy is whoever asked for the change's. |
| 230 | (Some(copy), _) => (owner.clone(), format!("{}/{}", copy.namespace, copy.name)), |
| 231 | (None, Some(fork)) => (fork.namespace.clone(), format!("{}/{}", fork.namespace, fork.name)), |
| 232 | (None, None) => (repo.namespace.clone(), format!("{}/{}", repo.namespace, repo.name)), |
| 233 | }; |
| 234 | let branch = pull |
| 235 | .branch |
| 236 | .clone() |
| 237 | .or_else(|| pull.fork.as_ref().map(|copy| copy.name.clone())) |
| 238 | .unwrap_or_default(); |
| 239 | let issues = issues_to_close(pull, repo, settings, false); |
| 240 | let facts = MessageFacts { |
| 241 | number: pull.number, |
| 242 | title: &pull.title, |
| 243 | body: pull.body.as_deref().unwrap_or_default(), |
| 244 | branch: &branch, |
| 245 | head_owner: &head_owner, |
| 246 | head_repo: &head_repo, |
| 247 | base: pull.base_branch(&repo.default_branch), |
| 248 | author: &owner, |
| 249 | commits: &commits, |
| 250 | issues: &issues, |
| 251 | }; |
| 252 | Ok(methods.iter().map(|method| (*method, merge_message::default_message(*method, settings, &facts))).collect()) |
| 253 | } |
| 254 | |
| 255 | /// The whole message of the commit a merge by `method` makes: what the |
| 256 | /// merger wrote, over the repository's default. `None` for a rebase or |
| 257 | /// fast-forward. Or why what they wrote cannot be used. |
| 258 | pub(crate) async fn merge_message_for( |
| 259 | &self, |
| 260 | repo: &Repo, |
| 261 | pull: &Pull, |
| 262 | settings: &RepoSettings, |
| 263 | method: MergeMethod, |
| 264 | title: Option<&str>, |
| 265 | body: Option<&str>, |
| 266 | ) -> Result<std::result::Result<Option<String>, &'static str>> { |
| 267 | if !matches!(method, MergeMethod::Merge | MergeMethod::Squash) { |
| 268 | return Ok(Ok(None)); |
| 269 | } |
| 270 | // Nothing of the default is used when the merger wrote both. |
| 271 | let default = if title.is_some() && body.is_some() { |
| 272 | Some((String::new(), String::new())) |
| 273 | } else { |
| 274 | self.default_messages(repo, pull, settings, &[method]).await?.pop().and_then(|(_, message)| message) |
| 275 | }; |
| 276 | Ok(merge_message::chosen(default, title, body)) |
| 277 | } |
| 278 | |
| 279 | /// `merge_choices`: how a pull request may merge now, for the merge box. |
| 280 | pub(crate) async fn merge_choices(&self, a: ViewArgs) -> Result<Outcome<MergeChoices>> { |
| 281 | let (repo, mut pull) = check!(self.pull_at(&a.repo, a.number, &a.viewer).await?); |
| 282 | crate::fill_base(&mut pull, &repo); |
| 283 | let base = pull.base_branch(&repo.default_branch).to_owned(); |
| 284 | let (settings, requirements) = self.settings_and_requirements(&repo, &pull).await?; |
| 285 | let behind = if pull.status.is_active() { self.is_behind(&pull).await? } else { false }; |
| 286 | let methods = offered(&settings, &requirements); |
| 287 | let default_method = choose_method(None, &settings, &requirements, &base).ok(); |
| 288 | let viewer = a.viewer.as_ref(); |
| 289 | // Whoever looks may catch it up to fast-forward it: they could push |
| 290 | // the merge themselves (catch_up_pull). |
| 291 | let can_catch_up = viewer.is_some_and(|viewer| match (&pull.fork_repo_id, pull.from_head_repo()) { |
| 292 | (Some(_), _) => pull.is_owned_by(&viewer.id), |
| 293 | // A fork's branch takes pushes from whoever repos lets push |
| 294 | // there, which is not known here: offered, and refused clearly. |
| 295 | (None, true) => true, |
| 296 | (None, false) => access::can(Some(viewer), &repo, Capability::Push), |
| 297 | }); |
| 298 | let messages = self.default_messages(&repo, &pull, &settings, &methods).await?; |
| 299 | let options = messages |
| 300 | .into_iter() |
| 301 | .map(|(method, message)| { |
| 302 | let unavailable = if behind && settings.require_up_to_date { |
| 303 | Some(format!("This repository requires it to be up to date with {base} before it merges.")) |
| 304 | } else { |
| 305 | (method == MergeMethod::FastForward && behind && !can_catch_up) |
| 306 | .then(|| format!("It can only fast-forward once it is up to date with {base}.")) |
| 307 | }; |
| 308 | let (commit_title, commit_message) = match message { |
| 309 | Some((title, body)) => (Some(title), Some(body)), |
| 310 | None => (None, None), |
| 311 | }; |
| 312 | MergeChoice { method, commit_title, commit_message, unavailable } |
| 313 | }) |
| 314 | .collect(); |
| 315 | let can_delete_branch = deletable_branch(&pull, &repo).is_some(); |
| 316 | let auto_merge_allowed = settings.allow_auto_merge |
| 317 | && pull.status.is_active() |
| 318 | && viewer.is_some_and(|viewer| access::can(Some(viewer), &repo, Capability::Merge)); |
| 319 | Ok(Outcome::Ok(MergeChoices { |
| 320 | methods, |
| 321 | default_method, |
| 322 | options, |
| 323 | behind, |
| 324 | can_fast_forward: !behind, |
| 325 | suggest_update: behind && (settings.suggest_updating_branches || settings.require_up_to_date), |
| 326 | require_up_to_date: settings.require_up_to_date, |
| 327 | merge_queue: settings.merge_queue, |
| 328 | auto_merge_allowed, |
| 329 | delete_branch_default: settings.delete_branch_on_merge && can_delete_branch, |
| 330 | can_delete_branch, |
| 331 | sign_off: settings.require_web_commit_sign_off, |
| 332 | })) |
| 333 | } |
| 334 | |
| 335 | /// Whether another open pull request still needs `branch` of |
| 336 | /// `source_id`: one merging into it, or one opened from it into |
| 337 | /// another branch. |
| 338 | async fn branch_in_use(&self, pull: &Pull, branch: &str) -> Result<bool> { |
| 339 | Ok(self |
| 340 | .db |
| 341 | .prepare( |
| 342 | "SELECT 1 AS value FROM pulls |
| 343 | WHERE id <> ?1 AND status IN ('draft', 'open') AND fork_repo_id IS NULL |
| 344 | AND ((repo_id = ?2 AND base_branch = ?3 AND ?4 IS NULL) |
| 345 | OR (source_branch = ?3 AND COALESCE(head_repo_id, repo_id) = ?5)) |
| 346 | LIMIT 1", |
| 347 | ) |
| 348 | .bind(&[ |
| 349 | pull.id.as_str().into(), |
| 350 | pull.repo_id.as_str().into(), |
| 351 | branch.into(), |
| 352 | crate::optional(&pull.head_repo_id), |
| 353 | pull.source_id().into(), |
| 354 | ])? |
| 355 | .first::<u32>(Some("value")) |
| 356 | .await? |
| 357 | .is_some()) |
| 358 | } |
| 359 | |
| 360 | /// Deletes a pull request's branch as `actor`, if it may be deleted and |
| 361 | /// still points at `head`, and says so. Repos decides whether `actor` |
| 362 | /// may push there and whether the rules for the branch let it go. |
| 363 | /// Whether it was deleted, or why not. |
| 364 | async fn remove_head_branch(&self, repo: &Repo, pull: &Pull, actor: &User, head: Option<String>) -> Result<Outcome<()>> { |
| 365 | let Some((path, branch)) = deletable_branch(pull, repo) else { |
| 366 | return Ok(Outcome::fail( |
| 367 | FailureCode::Invalid, |
| 368 | if pull.fork_repo_id.is_some() { |
| 369 | "This pull request's change is in a working copy made for it, which goes by itself.".to_owned() |
| 370 | } else { |
| 371 | "This pull request's branch cannot be deleted here.".to_owned() |
| 372 | }, |
| 373 | )); |
| 374 | }; |
| 375 | if self.branch_in_use(pull, &branch).await? { |
| 376 | return Ok(Outcome::fail( |
| 377 | FailureCode::Conflict, |
| 378 | format!("Another open pull request still uses {branch}, so it stays."), |
| 379 | )); |
| 380 | } |
| 381 | let removed: Outcome<g1t_contracts::repos::Branch> = g1t_kit::call( |
| 382 | &self.repos, |
| 383 | "remove_branch", |
| 384 | &RemoveBranchArgs { actor: actor.clone(), path, branch: branch.clone(), head, surface: None }, |
| 385 | ) |
| 386 | .await?; |
| 387 | check!(removed); |
| 388 | self.db |
| 389 | .prepare("UPDATE pulls SET head_branch_deleted = 1 WHERE id = ?") |
| 390 | .bind(&[pull.id.as_str().into()])? |
| 391 | .run() |
| 392 | .await?; |
| 393 | self.note(&pull.repo_id, pull.number, (&actor.id, &actor.username), &format!("deleted the `{branch}` branch")) |
| 394 | .await?; |
| 395 | self.publish( |
| 396 | "pull.head_branch_deleted", |
| 397 | &pull.repo_id, |
| 398 | actor, |
| 399 | PullEvent { detail: Some(branch), ..Self::pull_event(pull) }, |
| 400 | ) |
| 401 | .await?; |
| 402 | Ok(Outcome::Ok(())) |
| 403 | } |
| 404 | |
| 405 | /// After a merge: deletes its branch where the merger asked for it, or |
| 406 | /// the repository does on merge. A branch that cannot go stays, quietly: |
| 407 | /// the merge itself succeeded. |
| 408 | pub(crate) async fn delete_branch_after_merge( |
| 409 | &self, |
| 410 | repo: &Repo, |
| 411 | pull: &Pull, |
| 412 | actor: &User, |
| 413 | asked: Option<bool>, |
| 414 | head: Option<String>, |
| 415 | ) -> Result<()> { |
| 416 | if !asked.unwrap_or(self.settings(&repo.id).await?.delete_branch_on_merge) || deletable_branch(pull, repo).is_none() { |
| 417 | return Ok(()); |
| 418 | } |
| 419 | // A branch that moved since it merged keeps the new commits. |
| 420 | if head.is_none() { |
| 421 | return Ok(()); |
| 422 | } |
| 423 | if let Outcome::Fail(failure) = self.remove_head_branch(repo, pull, actor, head).await? { |
| 424 | worker::console_warn!("branch of {} kept after merging: {}", pull.id, failure.message); |
| 425 | } |
| 426 | Ok(()) |
| 427 | } |
| 428 | |
| 429 | /// `delete_head_branch`: deletes a merged or closed pull request's |
| 430 | /// branch, if it still points where the pull request left it. |
| 431 | pub(crate) async fn delete_head_branch(&self, a: PullActionArgs) -> Result<Outcome<Pull>> { |
| 432 | if !a.actor.verified { |
| 433 | return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED)); |
| 434 | } |
| 435 | let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &Some(a.actor.clone())).await?); |
| 436 | check!(writable(&repo)); |
| 437 | if pull.status.is_active() { |
| 438 | return Ok(Outcome::fail( |
| 439 | FailureCode::Conflict, |
| 440 | "This pull request is still open. Its branch can be deleted once it is merged or closed.", |
| 441 | )); |
| 442 | } |
| 443 | if pull.head_branch_deleted { |
| 444 | return Ok(Outcome::fail(FailureCode::Conflict, "Its branch is already deleted.")); |
| 445 | } |
| 446 | if !pull.from_head_repo() { |
| 447 | check!(allowed(Some(&a.actor), &repo, Capability::Push)); |
| 448 | } |
| 449 | check!(self.remove_head_branch(&repo, &pull, &a.actor, pull.head_commit.clone()).await?); |
| 450 | Ok(match self.pull(&repo.id, pull.number).await? { |
| 451 | Some(pull) => Outcome::Ok(pull), |
| 452 | None => no_pull(), |
| 453 | }) |
| 454 | } |
| 455 | |
| 456 | /// `restore_head_branch`: makes a merged or closed pull request's |
| 457 | /// branch again, at the commit it was left at. |
| 458 | pub(crate) async fn restore_head_branch(&self, a: PullActionArgs) -> Result<Outcome<Pull>> { |
| 459 | if !a.actor.verified { |
| 460 | return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED)); |
| 461 | } |
| 462 | let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &Some(a.actor.clone())).await?); |
| 463 | check!(writable(&repo)); |
| 464 | if pull.status.is_active() { |
| 465 | return Ok(Outcome::fail(FailureCode::Conflict, "This pull request is still open, and so is its branch.")); |
| 466 | } |
| 467 | let (Some((path, branch)), Some(head)) = (deletable_branch(&pull, &repo), pull.head_commit.clone()) else { |
| 468 | return Ok(Outcome::fail(FailureCode::Invalid, "This pull request has no branch that can be restored.")); |
| 469 | }; |
| 470 | let exists: Option<String> = g1t_kit::call( |
| 471 | &self.repos, |
| 472 | "head", |
| 473 | &HeadArgs { repo_id: pull.source_id().to_owned(), branch: branch.clone() }, |
| 474 | ) |
| 475 | .await?; |
| 476 | if exists.is_some() { |
| 477 | return Ok(Outcome::fail(FailureCode::Conflict, format!("{branch} exists already."))); |
| 478 | } |
| 479 | let created: Outcome<g1t_contracts::repos::Branch> = g1t_kit::call( |
| 480 | &self.repos, |
| 481 | "create_branch", |
| 482 | &CreateBranchArgs { actor: a.actor.clone(), path, name: branch.clone(), from: head, surface: None }, |
| 483 | ) |
| 484 | .await?; |
| 485 | check!(created); |
| 486 | self.db |
| 487 | .prepare("UPDATE pulls SET head_branch_deleted = 0 WHERE id = ?") |
| 488 | .bind(&[pull.id.as_str().into()])? |
| 489 | .run() |
| 490 | .await?; |
| 491 | self.note(&pull.repo_id, pull.number, (&a.actor.id, &a.actor.username), &format!("restored the `{branch}` branch")) |
| 492 | .await?; |
| 493 | self.publish( |
| 494 | "pull.head_branch_restored", |
| 495 | &pull.repo_id, |
| 496 | &a.actor, |
| 497 | PullEvent { detail: Some(branch), ..Self::pull_event(&pull) }, |
| 498 | ) |
| 499 | .await?; |
| 500 | Ok(match self.pull(&repo.id, pull.number).await? { |
| 501 | Some(pull) => Outcome::Ok(pull), |
| 502 | None => no_pull(), |
| 503 | }) |
| 504 | } |
| 505 | } |
| 506 | |
| 507 | #[cfg(test)] |
| 508 | mod tests { |
| 509 | use super::*; |
| 510 | use crate::rows::stored::{ASKER, G1T, pull}; |
| 511 | |
| 512 | fn rules(allowed: Option<Vec<MergeMethod>>, linear: bool) -> Requirements { |
| 513 | Requirements { allowed_merge_methods: allowed, linear_history: linear, ..Requirements::default() } |
| 514 | } |
| 515 | |
| 516 | #[test] |
| 517 | fn the_method_is_the_one_asked_for_if_allowed() { |
| 518 | let settings = RepoSettings::default(); |
| 519 | let open = rules(None, false); |
| 520 | assert_eq!(choose_method(Some(MergeMethod::Squash), &settings, &open, "main"), Ok(MergeMethod::Squash)); |
| 521 | assert_eq!(choose_method(None, &settings, &open, "main"), Ok(MergeMethod::Merge)); |
| 522 | // Fast-forward is off unless the repository turns it on. |
| 523 | let refused = choose_method(Some(MergeMethod::FastForward), &settings, &open, "main").unwrap_err(); |
| 524 | assert_eq!(refused, "This repository does not allow merging by fast-forward. It allows merge commit, squash or rebase."); |
| 525 | let ff = RepoSettings { allow_fast_forward: true, ..RepoSettings::default() }; |
| 526 | assert_eq!(choose_method(Some(MergeMethod::FastForward), &ff, &open, "main"), Ok(MergeMethod::FastForward)); |
| 527 | } |
| 528 | |
| 529 | #[test] |
| 530 | fn linear_history_refuses_a_merge_commit_and_moves_the_default_on() { |
| 531 | let settings = RepoSettings::default(); |
| 532 | let linear = rules(None, true); |
| 533 | let refused = choose_method(Some(MergeMethod::Merge), &settings, &linear, "main").unwrap_err(); |
| 534 | assert_eq!(refused, "main requires linear history, so a merge commit cannot land there. Merge by squash or rebase instead."); |
| 535 | // Unasked, the default (merge) gives way to the first that may. |
| 536 | assert_eq!(choose_method(None, &settings, &linear, "main"), Ok(MergeMethod::Squash)); |
| 537 | assert_eq!(offered(&settings, &linear), vec![MergeMethod::Squash, MergeMethod::Rebase]); |
| 538 | } |
| 539 | |
| 540 | #[test] |
| 541 | fn rules_narrow_the_methods() { |
| 542 | let settings = RepoSettings { default_merge_method: MergeMethod::Squash, ..RepoSettings::default() }; |
| 543 | let rebase_only = rules(Some(vec![MergeMethod::Rebase]), false); |
| 544 | assert_eq!(choose_method(None, &settings, &rebase_only, "main"), Ok(MergeMethod::Rebase)); |
| 545 | let refused = choose_method(Some(MergeMethod::Squash), &settings, &rebase_only, "release").unwrap_err(); |
| 546 | assert_eq!(refused, "The rules for release allow merging only by rebase, not by squash."); |
| 547 | // Nothing both allow. |
| 548 | let ff_only = rules(Some(vec![MergeMethod::FastForward]), false); |
| 549 | let none = choose_method(None, &settings, &ff_only, "main").unwrap_err(); |
| 550 | assert!(none.starts_with("No merge method this repository allows (merge commit, squash or rebase)"), "{none}"); |
| 551 | assert!(offered(&settings, &ff_only).is_empty()); |
| 552 | } |
| 553 | |
| 554 | #[test] |
| 555 | fn behind_is_no_problem_unless_it_must_be_up_to_date_or_fast_forward() { |
| 556 | assert_eq!(when_behind(MergeMethod::Merge, false, true, "main"), WhenBehind::Land); |
| 557 | for method in [MergeMethod::Merge, MergeMethod::Squash, MergeMethod::Rebase] { |
| 558 | assert_eq!(when_behind(method, true, false, "main"), WhenBehind::Land, "{method:?}"); |
| 559 | assert!(matches!(when_behind(method, true, true, "main"), WhenBehind::Refuse(_))); |
| 560 | } |
| 561 | assert_eq!(when_behind(MergeMethod::FastForward, true, false, "main"), WhenBehind::CatchUpFirst); |
| 562 | assert_eq!(when_behind(MergeMethod::FastForward, false, false, "main"), WhenBehind::Land); |
| 563 | assert!(matches!(when_behind(MergeMethod::FastForward, true, true, "main"), WhenBehind::Refuse(_))); |
| 564 | } |
| 565 | |
| 566 | fn repo() -> Repo { |
| 567 | serde_json::from_value(serde_json::json!({ |
| 568 | "id": "rep_1", "namespace": "flagon", "name": "g1t", "description": null, "isPrivate": false, |
| 569 | "ownerId": "usr_1", "defaultBranch": "main", "forkOf": null, "protected": false, |
| 570 | "createdAt": "", "updatedAt": "" |
| 571 | })) |
| 572 | .unwrap() |
| 573 | } |
| 574 | |
| 575 | fn branch_pull(branch: &str) -> Pull { |
| 576 | let mut made = pull(ASKER, None); |
| 577 | made.fork = None; |
| 578 | made.fork_repo_id = None; |
| 579 | made.branch = Some(branch.into()); |
| 580 | made |
| 581 | } |
| 582 | |
| 583 | #[test] |
| 584 | fn only_a_branch_that_is_not_the_base_or_a_working_copy_is_deleted() { |
| 585 | let repo = repo(); |
| 586 | let feature = branch_pull("feature"); |
| 587 | assert_eq!( |
| 588 | deletable_branch(&feature, &repo), |
| 589 | Some((RepoPath { namespace: "flagon".into(), name: "g1t".into() }, "feature".into())) |
| 590 | ); |
| 591 | assert!(deletable_branch(&branch_pull("main"), &repo).is_none(), "the default branch stays"); |
| 592 | let mut into_release = branch_pull("release"); |
| 593 | into_release.base = Some("release".into()); |
| 594 | assert!(deletable_branch(&into_release, &repo).is_none()); |
| 595 | // A working copy goes by itself. |
| 596 | assert!(deletable_branch(&pull(G1T, Some(ASKER)), &repo).is_none()); |
| 597 | // A fork's branch, even one named like the base. |
| 598 | let mut forked = branch_pull("main"); |
| 599 | forked.head_repo = Some(RepoPath { namespace: "ana".into(), name: "g1t".into() }); |
| 600 | forked.head_repo_id = Some("rep_2".into()); |
| 601 | assert_eq!(deletable_branch(&forked, &repo).map(|(path, branch)| (path.namespace, branch)), Some(("ana".into(), "main".into()))); |
| 602 | } |
| 603 | |
| 604 | #[test] |
| 605 | fn a_squash_is_by_the_person_whose_change_it_is() { |
| 606 | let own = branch_pull("feature"); |
| 607 | assert_eq!(squash_author(&own, MergeMethod::Squash).map(|user| user.id), Some(ASKER.0.to_owned())); |
| 608 | assert!(squash_author(&own, MergeMethod::Merge).is_none()); |
| 609 | // What g1t made for someone is theirs. |
| 610 | assert_eq!(squash_author(&pull(G1T, Some(ASKER)), MergeMethod::Squash).map(|user| user.id), Some(ASKER.0.to_owned())); |
| 611 | // g1t's own work is the merger's. |
| 612 | assert!(squash_author(&pull(("g1t", "g1t"), None), MergeMethod::Squash).is_none()); |
| 613 | } |
| 614 | |
| 615 | #[test] |
| 616 | fn issues_close_only_into_the_default_branch_where_the_repository_says() { |
| 617 | let repo = repo(); |
| 618 | let mut fixes = branch_pull("feature"); |
| 619 | fixes.issue = Some(12); |
| 620 | fixes.body = Some("Fixes #3. See `closes #4`.".into()); |
| 621 | let settings = RepoSettings::default(); |
| 622 | assert_eq!(issues_to_close(&fixes, &repo, &settings, false), vec![12, 3]); |
| 623 | assert!(issues_to_close(&fixes, &repo, &settings, true).is_empty()); |
| 624 | let off = RepoSettings { auto_close_issues: false, ..RepoSettings::default() }; |
| 625 | assert!(issues_to_close(&fixes, &repo, &off, false).is_empty()); |
| 626 | fixes.base = Some("release".into()); |
| 627 | assert!(issues_to_close(&fixes, &repo, &settings, false).is_empty()); |
| 628 | } |
| 629 | |
| 630 | #[test] |
| 631 | fn the_timeline_says_how_it_merged() { |
| 632 | assert_eq!(merged_note(MergeMethod::Squash), "squashed and merged this"); |
| 633 | assert_eq!(merged_note(MergeMethod::FastForward), "merged this"); |
| 634 | assert_eq!(labels(&[MergeMethod::Squash, MergeMethod::Rebase, MergeMethod::FastForward]), "squash, rebase or fast-forward"); |
| 635 | } |
| 636 | } |