| 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, Divergence, 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_kit::now_ms; |
| 16 | use g1t_rules::merge::{Requirements, method_label}; |
| 17 | use serde::Deserialize; |
| 18 | use worker::Result; |
| 19 | |
| 20 | use crate::merge_message::{self, CommitText, MessageFacts}; |
| 21 | use crate::retired::writable; |
| 22 | use crate::{Work, allowed, no_pull}; |
| 23 | |
| 24 | /// Unwraps an `Outcome`, returning its failure from the enclosing method. |
| 25 | macro_rules! check { |
| 26 | ($outcome:expr) => { |
| 27 | match $outcome { |
| 28 | Outcome::Ok(value) => value, |
| 29 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 30 | } |
| 31 | }; |
| 32 | } |
| 33 | |
| 34 | /// How many of a pull request's commits its default messages read. |
| 35 | const MESSAGE_COMMITS: u32 = 250; |
| 36 | /// The most JSON of commits kept for one pull request (D1 holds 2 MB to a |
| 37 | /// row); a pull request with more has them read each time. |
| 38 | const MAX_KEPT_COMMITS: usize = 1_000_000; |
| 39 | |
| 40 | /// The commits kept for a pull request's default messages |
| 41 | /// (`pull_message_commits`), with its mergeability's pair beside them. |
| 42 | #[derive(Deserialize)] |
| 43 | struct KeptCommitsRow { |
| 44 | #[serde(default)] |
| 45 | head: Option<String>, |
| 46 | #[serde(default)] |
| 47 | pair: Option<String>, |
| 48 | #[serde(default)] |
| 49 | commits: Option<String>, |
| 50 | #[serde(default)] |
| 51 | mergeable_key: Option<String>, |
| 52 | } |
| 53 | |
| 54 | /// Whether commits kept for `kept_head`, last confirmed for the pair of |
| 55 | /// commits `kept_pair`, are a pull request's now: its head is `kept_head` |
| 56 | /// and its mergeability was last worked out for that same pair. A push to |
| 57 | /// either side moves `mergeable_key` before they are kept again, so a list |
| 58 | /// read for another head or another base is never used. |
| 59 | pub(crate) fn kept_is_current(kept_head: Option<&str>, kept_pair: Option<&str>, head: Option<&str>, mergeable_key: Option<&str>) -> bool { |
| 60 | let (Some(kept_head), Some(kept_pair), Some(head), Some(mergeable_key)) = (kept_head, kept_pair, head, mergeable_key) else { |
| 61 | return false; |
| 62 | }; |
| 63 | kept_head == head && kept_pair == mergeable_key && mergeable_key.split_once("..").is_some_and(|(of, _)| of == head) |
| 64 | } |
| 65 | |
| 66 | /// What to do with the commits kept for a pull request when its |
| 67 | /// mergeability is worked out for a divergence. |
| 68 | #[derive(Debug, PartialEq, Eq)] |
| 69 | pub(crate) enum Keeping { |
| 70 | /// Kept for this pair of commits already. |
| 71 | Current, |
| 72 | /// The list holds (same head, same merge base: its base moved without |
| 73 | /// taking any of its commits); only the pair it holds for moves on. |
| 74 | Confirm, |
| 75 | /// Read them again: its head or where it left its base moved, or none |
| 76 | /// are kept. |
| 77 | Read, |
| 78 | /// Keep nothing: it shares no history with its base, so its commits |
| 79 | /// are read live each time. |
| 80 | Nothing, |
| 81 | } |
| 82 | |
| 83 | /// See [`Keeping`]. `kept` is what is stored: head, merge base and pair. |
| 84 | pub(crate) fn keeping(kept: Option<(&str, Option<&str>, &str)>, divergence: &Divergence) -> Keeping { |
| 85 | let Some(merge_base) = divergence.merge_base.as_deref() else { |
| 86 | return Keeping::Nothing; |
| 87 | }; |
| 88 | let pair = format!("{}..{}", divergence.head, divergence.base); |
| 89 | match kept { |
| 90 | Some((head, kept_base, kept_pair)) if head == divergence.head && kept_base == Some(merge_base) => { |
| 91 | if kept_pair == pair { Keeping::Current } else { Keeping::Confirm } |
| 92 | } |
| 93 | _ => Keeping::Read, |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | /// A pull request's commits as its default messages read them, oldest |
| 98 | /// first, merge commits left out. |
| 99 | fn texts_of(inspected: InspectedCommits) -> Vec<CommitText> { |
| 100 | inspected |
| 101 | .commits |
| 102 | .into_iter() |
| 103 | .rev() |
| 104 | .filter(|commit| commit.parents <= 1) |
| 105 | .map(|commit| CommitText { message: commit.message, author_email: commit.author_email }) |
| 106 | .collect() |
| 107 | } |
| 108 | |
| 109 | /// The default title and body of the commit each of `methods` makes for |
| 110 | /// `pull`, from its `commits` (oldest first): `None` for one that makes no |
| 111 | /// commit of its own. |
| 112 | fn messages_for( |
| 113 | repo: &Repo, |
| 114 | pull: &Pull, |
| 115 | settings: &RepoSettings, |
| 116 | methods: &[MergeMethod], |
| 117 | commits: &[CommitText], |
| 118 | items: &[u32], |
| 119 | ) -> Vec<(MergeMethod, Option<(String, String)>)> { |
| 120 | let owner = pull.owner().username.clone(); |
| 121 | let (head_owner, head_repo) = match (&pull.fork, &pull.head_repo) { |
| 122 | // A working copy is whoever asked for the change's. |
| 123 | (Some(copy), _) => (owner.clone(), format!("{}/{}", copy.namespace, copy.name)), |
| 124 | (None, Some(fork)) => (fork.namespace.clone(), format!("{}/{}", fork.namespace, fork.name)), |
| 125 | (None, None) => (repo.namespace.clone(), format!("{}/{}", repo.namespace, repo.name)), |
| 126 | }; |
| 127 | let branch = pull |
| 128 | .branch |
| 129 | .clone() |
| 130 | .or_else(|| pull.fork.as_ref().map(|copy| copy.name.clone())) |
| 131 | .unwrap_or_default(); |
| 132 | let facts = MessageFacts { |
| 133 | number: pull.number, |
| 134 | title: &pull.title, |
| 135 | body: pull.body.as_deref().unwrap_or_default(), |
| 136 | branch: &branch, |
| 137 | head_owner: &head_owner, |
| 138 | head_repo: &head_repo, |
| 139 | base: pull.base_branch(&repo.default_branch), |
| 140 | author: &owner, |
| 141 | commits, |
| 142 | items, |
| 143 | }; |
| 144 | methods.iter().map(|method| (*method, merge_message::default_message(*method, settings, &facts))).collect() |
| 145 | } |
| 146 | |
| 147 | /// Methods named for people, joined: "squash or rebase". |
| 148 | fn labels(methods: &[MergeMethod]) -> String { |
| 149 | let named: Vec<&str> = methods.iter().map(|method| method_label(*method)).collect(); |
| 150 | match named.as_slice() { |
| 151 | [] => "no method".to_owned(), |
| 152 | [one] => (*one).to_owned(), |
| 153 | [rest @ .., last] => format!("{} or {last}", rest.join(", ")), |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | /// The methods a pull request may merge by: those the repository allows, |
| 158 | /// as the rules for its base allow them, in the order offered. |
| 159 | pub(crate) fn offered(settings: &RepoSettings, requirements: &Requirements) -> Vec<MergeMethod> { |
| 160 | settings.allowed_methods().into_iter().filter(|method| requirements.allows_method(*method)).collect() |
| 161 | } |
| 162 | |
| 163 | /// The method a merge uses: the one asked for, if the repository and the |
| 164 | /// rules for `base` allow it; with none asked for, the repository's |
| 165 | /// default where it may be used, else the first that may. Why not, if |
| 166 | /// none can. |
| 167 | pub(crate) fn choose_method( |
| 168 | asked: Option<MergeMethod>, |
| 169 | settings: &RepoSettings, |
| 170 | requirements: &Requirements, |
| 171 | base: &str, |
| 172 | ) -> std::result::Result<MergeMethod, String> { |
| 173 | let offered = offered(settings, requirements); |
| 174 | let Some(method) = asked else { |
| 175 | return if offered.contains(&settings.default_merge_method) { |
| 176 | Ok(settings.default_merge_method) |
| 177 | } else { |
| 178 | offered.first().copied().ok_or_else(|| { |
| 179 | format!( |
| 180 | "No merge method this repository allows ({}) is allowed by the rules for {base}. Its admins can allow another in Settings.", |
| 181 | labels(&settings.allowed_methods()) |
| 182 | ) |
| 183 | }) |
| 184 | }; |
| 185 | }; |
| 186 | if !settings.allows(method) { |
| 187 | return Err(format!( |
| 188 | "This repository does not allow merging by {}. It allows {}.", |
| 189 | method_label(method), |
| 190 | labels(&settings.allowed_methods()) |
| 191 | )); |
| 192 | } |
| 193 | if requirements.linear_history && !method.is_linear() { |
| 194 | return Err(format!( |
| 195 | "{base} requires linear history, so a merge commit cannot land there. Merge by {} instead.", |
| 196 | labels(&offered) |
| 197 | )); |
| 198 | } |
| 199 | if !requirements.allows_method(method) { |
| 200 | return Err(format!( |
| 201 | "The rules for {base} allow merging only by {}, not by {}.", |
| 202 | labels(requirements.allowed_merge_methods.as_deref().unwrap_or_default()), |
| 203 | method_label(method) |
| 204 | )); |
| 205 | } |
| 206 | Ok(method) |
| 207 | } |
| 208 | |
| 209 | /// What a merge does about its base having moved without it. |
| 210 | #[derive(Debug, PartialEq, Eq)] |
| 211 | pub(crate) enum WhenBehind { |
| 212 | /// Land it now: it is not behind, or the method makes new commits on |
| 213 | /// the base's head anyway. |
| 214 | Land, |
| 215 | /// Bring it up to date first, and land it once that push arrives: a |
| 216 | /// fast-forward needs the base's head in it. |
| 217 | CatchUpFirst, |
| 218 | /// Refuse, saying why. |
| 219 | Refuse(String), |
| 220 | } |
| 221 | |
| 222 | /// What to do when `behind`, for a merge by `method`. |
| 223 | pub(crate) fn when_behind(method: MergeMethod, behind: bool, require_up_to_date: bool, base: &str) -> WhenBehind { |
| 224 | if !behind { |
| 225 | return WhenBehind::Land; |
| 226 | } |
| 227 | if require_up_to_date { |
| 228 | return WhenBehind::Refuse(format!( |
| 229 | "{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." |
| 230 | )); |
| 231 | } |
| 232 | match method { |
| 233 | MergeMethod::FastForward => WhenBehind::CatchUpFirst, |
| 234 | MergeMethod::Merge | MergeMethod::Squash | MergeMethod::Rebase => WhenBehind::Land, |
| 235 | } |
| 236 | } |
| 237 | |
| 238 | /// Why a fast-forward cannot catch up first: whoever merges cannot push |
| 239 | /// to where the change is. |
| 240 | pub(crate) fn cannot_fast_forward(base: &str, source: &str) -> String { |
| 241 | format!( |
| 242 | "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." |
| 243 | ) |
| 244 | } |
| 245 | |
| 246 | /// Who a squash commit is by: the person the pull request is theirs (who |
| 247 | /// asked g1t for it, or its author). The merger, for an agent's or g1t's |
| 248 | /// own, and for any other method. |
| 249 | pub(crate) fn squash_author(pull: &Pull, method: MergeMethod) -> Option<User> { |
| 250 | let owner = pull.owner(); |
| 251 | (method == MergeMethod::Squash && owner.kind == PrincipalKind::User && !g1t_contracts::rules::is_agent(owner)).then(|| owner.clone()) |
| 252 | } |
| 253 | |
| 254 | /// The branch a pull request's merge or close may delete, and where it is: |
| 255 | /// a branch of the repository, never its default branch nor the branch it |
| 256 | /// merges into, or a branch of a fork. Never a working copy's, which repos |
| 257 | /// retires with it. |
| 258 | pub(crate) fn deletable_branch(pull: &Pull, repo: &Repo) -> Option<(RepoPath, String)> { |
| 259 | if pull.fork_repo_id.is_some() { |
| 260 | return None; |
| 261 | } |
| 262 | let branch = pull.branch.as_deref().map(str::trim).filter(|branch| !branch.is_empty())?; |
| 263 | if pull.from_head_repo() { |
| 264 | return Some((pull.head_repo.clone()?, branch.to_owned())); |
| 265 | } |
| 266 | if branch == repo.default_branch || branch == pull.base_branch(&repo.default_branch) { |
| 267 | return None; |
| 268 | } |
| 269 | Some((RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() }, branch.to_owned())) |
| 270 | } |
| 271 | |
| 272 | /// What the timeline says a merge by `method` did. |
| 273 | pub(crate) fn merged_note(method: MergeMethod) -> &'static str { |
| 274 | match method { |
| 275 | MergeMethod::Squash => "squashed and merged this", |
| 276 | MergeMethod::Rebase => "rebased and merged this", |
| 277 | MergeMethod::Merge | MergeMethod::FastForward => "merged this", |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | impl Work { |
| 282 | /// A pull request's commits as its default messages read them, read |
| 283 | /// from its source now: only their messages, from a pack fetched from |
| 284 | /// `merge_base` when that is known. Empty when they cannot be read. |
| 285 | async fn read_commit_texts( |
| 286 | &self, |
| 287 | pull: &Pull, |
| 288 | target_id: &str, |
| 289 | base_branch: &str, |
| 290 | merge_base: Option<String>, |
| 291 | ) -> Result<Vec<CommitText>> { |
| 292 | let Some(head) = pull.head_commit.clone() else { |
| 293 | return Ok(Vec::new()); |
| 294 | }; |
| 295 | let found: Outcome<InspectedCommits> = g1t_kit::call( |
| 296 | &self.repos, |
| 297 | "inspect_commits", |
| 298 | &InspectCommitsArgs { |
| 299 | source_id: pull.source_id().to_owned(), |
| 300 | head, |
| 301 | target_id: target_id.to_owned(), |
| 302 | base_branch: base_branch.to_owned(), |
| 303 | limit: Some(MESSAGE_COMMITS), |
| 304 | merge_base, |
| 305 | messages_only: true, |
| 306 | }, |
| 307 | ) |
| 308 | .await?; |
| 309 | Ok(match found { |
| 310 | Outcome::Ok(inspected) => texts_of(inspected), |
| 311 | Outcome::Fail(_) => Vec::new(), |
| 312 | }) |
| 313 | } |
| 314 | |
| 315 | /// The commits kept for a pull request's default messages, if they are |
| 316 | /// its commits as it is now ([`kept_is_current`]). One database read. |
| 317 | async fn kept_commit_texts(&self, pull: &Pull) -> Result<Option<Vec<CommitText>>> { |
| 318 | if !pull.status.is_active() || pull.head_commit.is_none() { |
| 319 | return Ok(None); |
| 320 | } |
| 321 | let row = self |
| 322 | .timing |
| 323 | .db( |
| 324 | 1, |
| 325 | self.db |
| 326 | .prepare( |
| 327 | "SELECT kept.head, kept.pair, kept.commits, pulls.mergeable_key |
| 328 | FROM pulls LEFT JOIN pull_message_commits AS kept ON kept.pull_id = pulls.id |
| 329 | WHERE pulls.id = ?", |
| 330 | ) |
| 331 | .bind(&[pull.id.as_str().into()])? |
| 332 | .first::<KeptCommitsRow>(None), |
| 333 | ) |
| 334 | .await?; |
| 335 | let Some(row) = row else { return Ok(None) }; |
| 336 | if !kept_is_current(row.head.as_deref(), row.pair.as_deref(), pull.head_commit.as_deref(), row.mergeable_key.as_deref()) { |
| 337 | return Ok(None); |
| 338 | } |
| 339 | Ok(row.commits.as_deref().and_then(|commits| serde_json::from_str(commits).ok())) |
| 340 | } |
| 341 | |
| 342 | /// A pull request's commits as its default messages read them, oldest |
| 343 | /// first, merge commits left out: those kept for it when they are |
| 344 | /// current, else read now. |
| 345 | async fn commit_texts(&self, repo: &Repo, pull: &Pull) -> Result<Vec<CommitText>> { |
| 346 | if let Some(kept) = self.kept_commit_texts(pull).await? { |
| 347 | return Ok(kept); |
| 348 | } |
| 349 | self.read_commit_texts(pull, &repo.id, pull.base_branch(&repo.default_branch), None).await |
| 350 | } |
| 351 | |
| 352 | /// Keeps a pull request's commits for its default messages, once its |
| 353 | /// mergeability was worked out for `divergence` after a push, so that |
| 354 | /// the merge box reads them from here. They are read again only when |
| 355 | /// its head or where it left its base moved; a push to its base that |
| 356 | /// took none of its commits only moves the pair they hold for. Nothing |
| 357 | /// is read when no method the repository allows writes commits into a |
| 358 | /// message. |
| 359 | pub(crate) async fn keep_commit_texts(&self, pull: &Pull, divergence: &Divergence) -> Result<()> { |
| 360 | if !pull.status.is_active() || pull.head_commit.as_deref() != Some(divergence.head.as_str()) { |
| 361 | return Ok(()); |
| 362 | } |
| 363 | #[derive(Deserialize)] |
| 364 | struct Kept { |
| 365 | head: String, |
| 366 | merge_base: Option<String>, |
| 367 | pair: String, |
| 368 | } |
| 369 | let (settings, kept) = futures_util::future::try_join( |
| 370 | self.settings(&pull.repo_id), |
| 371 | self.db |
| 372 | .prepare("SELECT head, merge_base, pair FROM pull_message_commits WHERE pull_id = ?") |
| 373 | .bind(&[pull.id.as_str().into()])? |
| 374 | .first::<Kept>(None), |
| 375 | ) |
| 376 | .await?; |
| 377 | if !settings.allowed_methods().into_iter().any(|method| merge_message::needs_commits(method, &settings)) { |
| 378 | return Ok(()); |
| 379 | } |
| 380 | let pair = format!("{}..{}", divergence.head, divergence.base); |
| 381 | let now = g1t_contracts::time::rfc3339(now_ms()); |
| 382 | match keeping(kept.as_ref().map(|kept| (kept.head.as_str(), kept.merge_base.as_deref(), kept.pair.as_str())), divergence) { |
| 383 | Keeping::Current | Keeping::Nothing => Ok(()), |
| 384 | Keeping::Confirm => { |
| 385 | self.db |
| 386 | .prepare("UPDATE pull_message_commits SET pair = ?, updated_at = ? WHERE pull_id = ? AND head = ?") |
| 387 | .bind(&[pair.into(), now.into(), pull.id.as_str().into(), divergence.head.as_str().into()])? |
| 388 | .run() |
| 389 | .await?; |
| 390 | Ok(()) |
| 391 | } |
| 392 | Keeping::Read => { |
| 393 | let texts = self |
| 394 | .read_commit_texts(pull, &pull.repo_id, pull.base.as_deref().unwrap_or_default(), divergence.merge_base.clone()) |
| 395 | .await?; |
| 396 | let commits = serde_json::to_string(&texts)?; |
| 397 | if commits.len() > MAX_KEPT_COMMITS { |
| 398 | self.db |
| 399 | .prepare("DELETE FROM pull_message_commits WHERE pull_id = ?") |
| 400 | .bind(&[pull.id.as_str().into()])? |
| 401 | .run() |
| 402 | .await?; |
| 403 | return Ok(()); |
| 404 | } |
| 405 | self.db |
| 406 | .prepare( |
| 407 | "INSERT INTO pull_message_commits (pull_id, head, merge_base, pair, commits, updated_at) |
| 408 | VALUES (?1, ?2, ?3, ?4, ?5, ?6) |
| 409 | ON CONFLICT (pull_id) DO UPDATE SET head = ?2, merge_base = ?3, pair = ?4, commits = ?5, updated_at = ?6", |
| 410 | ) |
| 411 | .bind(&[ |
| 412 | pull.id.as_str().into(), |
| 413 | divergence.head.as_str().into(), |
| 414 | crate::optional(&divergence.merge_base), |
| 415 | pair.into(), |
| 416 | commits.into(), |
| 417 | now.into(), |
| 418 | ])? |
| 419 | .run() |
| 420 | .await?; |
| 421 | Ok(()) |
| 422 | } |
| 423 | } |
| 424 | } |
| 425 | |
| 426 | /// The default title and body of the commit each of `methods` makes |
| 427 | /// for `pull`, as the repository's settings write them: `None` for one |
| 428 | /// that makes no commit of its own. |
| 429 | pub(crate) async fn default_messages( |
| 430 | &self, |
| 431 | repo: &Repo, |
| 432 | pull: &Pull, |
| 433 | settings: &RepoSettings, |
| 434 | methods: &[MergeMethod], |
| 435 | ) -> Result<Vec<(MergeMethod, Option<(String, String)>)>> { |
| 436 | let commits = if methods.iter().any(|method| merge_message::needs_commits(*method, settings)) { |
| 437 | self.commit_texts(repo, pull).await? |
| 438 | } else { |
| 439 | Vec::new() |
| 440 | }; |
| 441 | let items = self.items_to_close(pull, repo, settings, false, &[]).await?.close_numbers(); |
| 442 | Ok(messages_for(repo, pull, settings, methods, &commits, &items)) |
| 443 | } |
| 444 | |
| 445 | /// The whole message of the commit a merge by `method` makes: what the |
| 446 | /// merger wrote, over the repository's default. `None` for a rebase or |
| 447 | /// fast-forward. Or why what they wrote cannot be used. |
| 448 | pub(crate) async fn merge_message_for( |
| 449 | &self, |
| 450 | repo: &Repo, |
| 451 | pull: &Pull, |
| 452 | settings: &RepoSettings, |
| 453 | method: MergeMethod, |
| 454 | title: Option<&str>, |
| 455 | body: Option<&str>, |
| 456 | ) -> Result<std::result::Result<Option<String>, &'static str>> { |
| 457 | if !matches!(method, MergeMethod::Merge | MergeMethod::Squash) { |
| 458 | return Ok(Ok(None)); |
| 459 | } |
| 460 | // Nothing of the default is used when the merger wrote both. |
| 461 | let default = if title.is_some() && body.is_some() { |
| 462 | Some((String::new(), String::new())) |
| 463 | } else { |
| 464 | self.default_messages(repo, pull, settings, &[method]).await?.pop().and_then(|(_, message)| message) |
| 465 | }; |
| 466 | Ok(merge_message::chosen(default, title, body)) |
| 467 | } |
| 468 | |
| 469 | /// `merge_choices`: how a pull request may merge now, for the merge box. |
| 470 | pub(crate) async fn merge_choices(&self, a: ViewArgs) -> Result<Outcome<MergeChoices>> { |
| 471 | let (repo, mut pull) = check!(self.pull_at(&a.repo, a.number, &a.viewer).await?); |
| 472 | crate::fill_base(&mut pull, &repo); |
| 473 | let base = pull.base_branch(&repo.default_branch).to_owned(); |
| 474 | // The commits kept for its messages are read beside its settings; |
| 475 | // only when none are current are they read from its source. |
| 476 | let ((settings, requirements), behind, kept) = futures_util::future::try_join3( |
| 477 | self.settings_and_requirements(&repo, &pull), |
| 478 | self.behind_now(&pull), |
| 479 | self.kept_commit_texts(&pull), |
| 480 | ) |
| 481 | .await?; |
| 482 | let methods = offered(&settings, &requirements); |
| 483 | let default_method = choose_method(None, &settings, &requirements, &base).ok(); |
| 484 | let viewer = a.viewer.as_ref(); |
| 485 | // Whoever looks may catch it up to fast-forward it: they could push |
| 486 | // the merge themselves (catch_up_pull). |
| 487 | let can_catch_up = viewer.is_some_and(|viewer| match (&pull.fork_repo_id, pull.from_head_repo()) { |
| 488 | (Some(_), _) => pull.is_owned_by(&viewer.id), |
| 489 | // A fork's branch takes pushes from whoever repos lets push |
| 490 | // there, which is not known here: offered, and refused clearly. |
| 491 | (None, true) => true, |
| 492 | (None, false) => access::can(Some(viewer), &repo, Capability::Push), |
| 493 | }); |
| 494 | let commits = if !methods.iter().any(|method| merge_message::needs_commits(*method, &settings)) { |
| 495 | Vec::new() |
| 496 | } else if let Some(kept) = kept { |
| 497 | kept |
| 498 | } else if pull.status.is_active() { |
| 499 | self.read_commit_texts(&pull, &repo.id, &base, None).await? |
| 500 | } else { |
| 501 | // A merged or closed one merges no more. |
| 502 | Vec::new() |
| 503 | }; |
| 504 | let items = self.items_to_close(&pull, &repo, &settings, false, &[]).await?.close_numbers(); |
| 505 | let messages = messages_for(&repo, &pull, &settings, &methods, &commits, &items); |
| 506 | let options = messages |
| 507 | .into_iter() |
| 508 | .map(|(method, message)| { |
| 509 | let unavailable = if behind && settings.require_up_to_date { |
| 510 | Some(format!("This repository requires it to be up to date with {base} before it merges.")) |
| 511 | } else { |
| 512 | (method == MergeMethod::FastForward && behind && !can_catch_up) |
| 513 | .then(|| format!("It can only fast-forward once it is up to date with {base}.")) |
| 514 | }; |
| 515 | let (commit_title, commit_message) = match message { |
| 516 | Some((title, body)) => (Some(title), Some(body)), |
| 517 | None => (None, None), |
| 518 | }; |
| 519 | MergeChoice { method, commit_title, commit_message, unavailable } |
| 520 | }) |
| 521 | .collect(); |
| 522 | let can_delete_branch = deletable_branch(&pull, &repo).is_some(); |
| 523 | let auto_merge_allowed = settings.allow_auto_merge |
| 524 | && pull.status.is_active() |
| 525 | && viewer.is_some_and(|viewer| access::can(Some(viewer), &repo, Capability::Merge)); |
| 526 | Ok(Outcome::Ok(MergeChoices { |
| 527 | methods, |
| 528 | default_method, |
| 529 | options, |
| 530 | behind, |
| 531 | can_fast_forward: !behind, |
| 532 | suggest_update: behind && (settings.suggest_updating_branches || settings.require_up_to_date), |
| 533 | require_up_to_date: settings.require_up_to_date, |
| 534 | merge_queue: settings.merge_queue, |
| 535 | auto_merge_allowed, |
| 536 | delete_branch_default: settings.delete_branch_on_merge && can_delete_branch, |
| 537 | can_delete_branch, |
| 538 | sign_off: settings.require_web_commit_sign_off, |
| 539 | })) |
| 540 | } |
| 541 | |
| 542 | /// Whether another open pull request still needs `branch` of |
| 543 | /// `source_id`: one merging into it, or one opened from it into |
| 544 | /// another branch. |
| 545 | async fn branch_in_use(&self, pull: &Pull, branch: &str) -> Result<bool> { |
| 546 | Ok(self |
| 547 | .db |
| 548 | .prepare( |
| 549 | "SELECT 1 AS value FROM pulls |
| 550 | WHERE id <> ?1 AND status IN ('draft', 'open') AND fork_repo_id IS NULL |
| 551 | AND ((repo_id = ?2 AND base_branch = ?3 AND ?4 IS NULL) |
| 552 | OR (source_branch = ?3 AND COALESCE(head_repo_id, repo_id) = ?5)) |
| 553 | LIMIT 1", |
| 554 | ) |
| 555 | .bind(&[ |
| 556 | pull.id.as_str().into(), |
| 557 | pull.repo_id.as_str().into(), |
| 558 | branch.into(), |
| 559 | crate::optional(&pull.head_repo_id), |
| 560 | pull.source_id().into(), |
| 561 | ])? |
| 562 | .first::<u32>(Some("value")) |
| 563 | .await? |
| 564 | .is_some()) |
| 565 | } |
| 566 | |
| 567 | /// Deletes a pull request's branch as `actor`, if it may be deleted and |
| 568 | /// still points at `head`, and says so. Repos decides whether `actor` |
| 569 | /// may push there and whether the rules for the branch let it go. |
| 570 | /// Whether it was deleted, or why not. |
| 571 | async fn remove_head_branch(&self, repo: &Repo, pull: &Pull, actor: &User, head: Option<String>) -> Result<Outcome<()>> { |
| 572 | let Some((path, branch)) = deletable_branch(pull, repo) else { |
| 573 | return Ok(Outcome::fail( |
| 574 | FailureCode::Invalid, |
| 575 | if pull.fork_repo_id.is_some() { |
| 576 | "This pull request's change is in a working copy made for it, which goes by itself.".to_owned() |
| 577 | } else { |
| 578 | "This pull request's branch cannot be deleted here.".to_owned() |
| 579 | }, |
| 580 | )); |
| 581 | }; |
| 582 | if self.branch_in_use(pull, &branch).await? { |
| 583 | return Ok(Outcome::fail( |
| 584 | FailureCode::Conflict, |
| 585 | format!("Another open pull request still uses {branch}, so it stays."), |
| 586 | )); |
| 587 | } |
| 588 | let removed: Outcome<g1t_contracts::repos::Branch> = g1t_kit::call( |
| 589 | &self.repos, |
| 590 | "remove_branch", |
| 591 | &RemoveBranchArgs { actor: actor.clone(), path, branch: branch.clone(), head, surface: None }, |
| 592 | ) |
| 593 | .await?; |
| 594 | check!(removed); |
| 595 | self.db |
| 596 | .prepare("UPDATE pulls SET head_branch_deleted = 1 WHERE id = ?") |
| 597 | .bind(&[pull.id.as_str().into()])? |
| 598 | .run() |
| 599 | .await?; |
| 600 | self.note(&pull.repo_id, pull.number, (&actor.id, &actor.username), &format!("deleted the `{branch}` branch")) |
| 601 | .await?; |
| 602 | self.publish( |
| 603 | "pull.head_branch_deleted", |
| 604 | &pull.repo_id, |
| 605 | actor, |
| 606 | PullEvent { detail: Some(branch), ..Self::pull_event(pull) }, |
| 607 | ) |
| 608 | .await?; |
| 609 | Ok(Outcome::Ok(())) |
| 610 | } |
| 611 | |
| 612 | /// After a merge: deletes its branch where the merger asked for it, or |
| 613 | /// the repository does on merge. A branch that cannot go stays, quietly: |
| 614 | /// the merge itself succeeded. |
| 615 | pub(crate) async fn delete_branch_after_merge( |
| 616 | &self, |
| 617 | repo: &Repo, |
| 618 | pull: &Pull, |
| 619 | actor: &User, |
| 620 | asked: Option<bool>, |
| 621 | head: Option<String>, |
| 622 | ) -> Result<()> { |
| 623 | if !asked.unwrap_or(self.settings(&repo.id).await?.delete_branch_on_merge) || deletable_branch(pull, repo).is_none() { |
| 624 | return Ok(()); |
| 625 | } |
| 626 | // A branch that moved since it merged keeps the new commits. |
| 627 | if head.is_none() { |
| 628 | return Ok(()); |
| 629 | } |
| 630 | if let Outcome::Fail(failure) = self.remove_head_branch(repo, pull, actor, head).await? { |
| 631 | worker::console_warn!("branch of {} kept after merging: {}", pull.id, failure.message); |
| 632 | } |
| 633 | Ok(()) |
| 634 | } |
| 635 | |
| 636 | /// `delete_head_branch`: deletes a merged or closed pull request's |
| 637 | /// branch, if it still points where the pull request left it. |
| 638 | pub(crate) async fn delete_head_branch(&self, a: PullActionArgs) -> Result<Outcome<Pull>> { |
| 639 | if !a.actor.verified { |
| 640 | return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED)); |
| 641 | } |
| 642 | let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &Some(a.actor.clone())).await?); |
| 643 | check!(writable(&repo)); |
| 644 | if pull.status.is_active() { |
| 645 | return Ok(Outcome::fail( |
| 646 | FailureCode::Conflict, |
| 647 | "This pull request is still open. Its branch can be deleted once it is merged or closed.", |
| 648 | )); |
| 649 | } |
| 650 | if pull.head_branch_deleted { |
| 651 | return Ok(Outcome::fail(FailureCode::Conflict, "Its branch is already deleted.")); |
| 652 | } |
| 653 | if !pull.from_head_repo() { |
| 654 | check!(allowed(Some(&a.actor), &repo, Capability::Push)); |
| 655 | } |
| 656 | check!(self.remove_head_branch(&repo, &pull, &a.actor, pull.head_commit.clone()).await?); |
| 657 | Ok(match self.pull(&repo.id, pull.number).await? { |
| 658 | Some(pull) => Outcome::Ok(pull), |
| 659 | None => no_pull(), |
| 660 | }) |
| 661 | } |
| 662 | |
| 663 | /// `restore_head_branch`: makes a merged or closed pull request's |
| 664 | /// branch again, at the commit it was left at. |
| 665 | pub(crate) async fn restore_head_branch(&self, a: PullActionArgs) -> Result<Outcome<Pull>> { |
| 666 | if !a.actor.verified { |
| 667 | return Ok(Outcome::fail(FailureCode::Forbidden, crate::UNVERIFIED)); |
| 668 | } |
| 669 | let (repo, pull) = check!(self.pull_at(&a.repo, a.number, &Some(a.actor.clone())).await?); |
| 670 | check!(writable(&repo)); |
| 671 | if pull.status.is_active() { |
| 672 | return Ok(Outcome::fail(FailureCode::Conflict, "This pull request is still open, and so is its branch.")); |
| 673 | } |
| 674 | let (Some((path, branch)), Some(head)) = (deletable_branch(&pull, &repo), pull.head_commit.clone()) else { |
| 675 | return Ok(Outcome::fail(FailureCode::Invalid, "This pull request has no branch that can be restored.")); |
| 676 | }; |
| 677 | let exists: Option<String> = g1t_kit::call( |
| 678 | &self.repos, |
| 679 | "head", |
| 680 | &HeadArgs { repo_id: pull.source_id().to_owned(), branch: branch.clone() }, |
| 681 | ) |
| 682 | .await?; |
| 683 | if exists.is_some() { |
| 684 | return Ok(Outcome::fail(FailureCode::Conflict, format!("{branch} exists already."))); |
| 685 | } |
| 686 | let created: Outcome<g1t_contracts::repos::Branch> = g1t_kit::call( |
| 687 | &self.repos, |
| 688 | "create_branch", |
| 689 | &CreateBranchArgs { actor: a.actor.clone(), path, name: branch.clone(), from: head, surface: None }, |
| 690 | ) |
| 691 | .await?; |
| 692 | check!(created); |
| 693 | self.db |
| 694 | .prepare("UPDATE pulls SET head_branch_deleted = 0 WHERE id = ?") |
| 695 | .bind(&[pull.id.as_str().into()])? |
| 696 | .run() |
| 697 | .await?; |
| 698 | self.note(&pull.repo_id, pull.number, (&a.actor.id, &a.actor.username), &format!("restored the `{branch}` branch")) |
| 699 | .await?; |
| 700 | self.publish( |
| 701 | "pull.head_branch_restored", |
| 702 | &pull.repo_id, |
| 703 | &a.actor, |
| 704 | PullEvent { detail: Some(branch), ..Self::pull_event(&pull) }, |
| 705 | ) |
| 706 | .await?; |
| 707 | Ok(match self.pull(&repo.id, pull.number).await? { |
| 708 | Some(pull) => Outcome::Ok(pull), |
| 709 | None => no_pull(), |
| 710 | }) |
| 711 | } |
| 712 | } |
| 713 | |
| 714 | #[cfg(test)] |
| 715 | mod tests { |
| 716 | use super::*; |
| 717 | use crate::rows::stored::{ASKER, G1T, pull}; |
| 718 | |
| 719 | fn rules(allowed: Option<Vec<MergeMethod>>, linear: bool) -> Requirements { |
| 720 | Requirements { allowed_merge_methods: allowed, linear_history: linear, ..Requirements::default() } |
| 721 | } |
| 722 | |
| 723 | #[test] |
| 724 | fn the_method_is_the_one_asked_for_if_allowed() { |
| 725 | let settings = RepoSettings::default(); |
| 726 | let open = rules(None, false); |
| 727 | assert_eq!(choose_method(Some(MergeMethod::Squash), &settings, &open, "main"), Ok(MergeMethod::Squash)); |
| 728 | assert_eq!(choose_method(None, &settings, &open, "main"), Ok(MergeMethod::Merge)); |
| 729 | // Fast-forward is off unless the repository turns it on. |
| 730 | let refused = choose_method(Some(MergeMethod::FastForward), &settings, &open, "main").unwrap_err(); |
| 731 | assert_eq!(refused, "This repository does not allow merging by fast-forward. It allows merge commit, squash or rebase."); |
| 732 | let ff = RepoSettings { allow_fast_forward: true, ..RepoSettings::default() }; |
| 733 | assert_eq!(choose_method(Some(MergeMethod::FastForward), &ff, &open, "main"), Ok(MergeMethod::FastForward)); |
| 734 | } |
| 735 | |
| 736 | #[test] |
| 737 | fn linear_history_refuses_a_merge_commit_and_moves_the_default_on() { |
| 738 | let settings = RepoSettings::default(); |
| 739 | let linear = rules(None, true); |
| 740 | let refused = choose_method(Some(MergeMethod::Merge), &settings, &linear, "main").unwrap_err(); |
| 741 | assert_eq!(refused, "main requires linear history, so a merge commit cannot land there. Merge by squash or rebase instead."); |
| 742 | // Unasked, the default (merge) gives way to the first that may. |
| 743 | assert_eq!(choose_method(None, &settings, &linear, "main"), Ok(MergeMethod::Squash)); |
| 744 | assert_eq!(offered(&settings, &linear), vec![MergeMethod::Squash, MergeMethod::Rebase]); |
| 745 | } |
| 746 | |
| 747 | #[test] |
| 748 | fn rules_narrow_the_methods() { |
| 749 | let settings = RepoSettings { default_merge_method: MergeMethod::Squash, ..RepoSettings::default() }; |
| 750 | let rebase_only = rules(Some(vec![MergeMethod::Rebase]), false); |
| 751 | assert_eq!(choose_method(None, &settings, &rebase_only, "main"), Ok(MergeMethod::Rebase)); |
| 752 | let refused = choose_method(Some(MergeMethod::Squash), &settings, &rebase_only, "release").unwrap_err(); |
| 753 | assert_eq!(refused, "The rules for release allow merging only by rebase, not by squash."); |
| 754 | // Nothing both allow. |
| 755 | let ff_only = rules(Some(vec![MergeMethod::FastForward]), false); |
| 756 | let none = choose_method(None, &settings, &ff_only, "main").unwrap_err(); |
| 757 | assert!(none.starts_with("No merge method this repository allows (merge commit, squash or rebase)"), "{none}"); |
| 758 | assert!(offered(&settings, &ff_only).is_empty()); |
| 759 | } |
| 760 | |
| 761 | #[test] |
| 762 | fn behind_is_no_problem_unless_it_must_be_up_to_date_or_fast_forward() { |
| 763 | assert_eq!(when_behind(MergeMethod::Merge, false, true, "main"), WhenBehind::Land); |
| 764 | for method in [MergeMethod::Merge, MergeMethod::Squash, MergeMethod::Rebase] { |
| 765 | assert_eq!(when_behind(method, true, false, "main"), WhenBehind::Land, "{method:?}"); |
| 766 | assert!(matches!(when_behind(method, true, true, "main"), WhenBehind::Refuse(_))); |
| 767 | } |
| 768 | assert_eq!(when_behind(MergeMethod::FastForward, true, false, "main"), WhenBehind::CatchUpFirst); |
| 769 | assert_eq!(when_behind(MergeMethod::FastForward, false, false, "main"), WhenBehind::Land); |
| 770 | assert!(matches!(when_behind(MergeMethod::FastForward, true, true, "main"), WhenBehind::Refuse(_))); |
| 771 | } |
| 772 | |
| 773 | fn repo() -> Repo { |
| 774 | serde_json::from_value(serde_json::json!({ |
| 775 | "id": "rep_1", "namespace": "flagon", "name": "g1t", "isPrivate": false, |
| 776 | "ownerId": "usr_1", "defaultBranch": "main", "forkOf": null, "protected": false, |
| 777 | "createdAt": "", "updatedAt": "" |
| 778 | })) |
| 779 | .unwrap() |
| 780 | } |
| 781 | |
| 782 | fn branch_pull(branch: &str) -> Pull { |
| 783 | let mut made = pull(ASKER, None); |
| 784 | made.fork = None; |
| 785 | made.fork_repo_id = None; |
| 786 | made.branch = Some(branch.into()); |
| 787 | made |
| 788 | } |
| 789 | |
| 790 | #[test] |
| 791 | fn only_a_branch_that_is_not_the_base_or_a_working_copy_is_deleted() { |
| 792 | let repo = repo(); |
| 793 | let feature = branch_pull("feature"); |
| 794 | assert_eq!( |
| 795 | deletable_branch(&feature, &repo), |
| 796 | Some((RepoPath { namespace: "flagon".into(), name: "g1t".into() }, "feature".into())) |
| 797 | ); |
| 798 | assert!(deletable_branch(&branch_pull("main"), &repo).is_none(), "the default branch stays"); |
| 799 | let mut into_release = branch_pull("release"); |
| 800 | into_release.base = Some("release".into()); |
| 801 | assert!(deletable_branch(&into_release, &repo).is_none()); |
| 802 | // A working copy goes by itself. |
| 803 | assert!(deletable_branch(&pull(G1T, Some(ASKER)), &repo).is_none()); |
| 804 | // A fork's branch, even one named like the base. |
| 805 | let mut forked = branch_pull("main"); |
| 806 | forked.head_repo = Some(RepoPath { namespace: "ana".into(), name: "g1t".into() }); |
| 807 | forked.head_repo_id = Some("rep_2".into()); |
| 808 | assert_eq!(deletable_branch(&forked, &repo).map(|(path, branch)| (path.namespace, branch)), Some(("ana".into(), "main".into()))); |
| 809 | } |
| 810 | |
| 811 | #[test] |
| 812 | fn a_squash_is_by_the_person_whose_change_it_is() { |
| 813 | let own = branch_pull("feature"); |
| 814 | assert_eq!(squash_author(&own, MergeMethod::Squash).map(|user| user.id), Some(ASKER.0.to_owned())); |
| 815 | assert!(squash_author(&own, MergeMethod::Merge).is_none()); |
| 816 | // What g1t made for someone is theirs. |
| 817 | assert_eq!(squash_author(&pull(G1T, Some(ASKER)), MergeMethod::Squash).map(|user| user.id), Some(ASKER.0.to_owned())); |
| 818 | // g1t's own work is the merger's. |
| 819 | assert!(squash_author(&pull(("g1t", "g1t"), None), MergeMethod::Squash).is_none()); |
| 820 | } |
| 821 | |
| 822 | fn diverged(head: &str, base: &str, merge_base: Option<&str>) -> Divergence { |
| 823 | Divergence { head: head.into(), base: base.into(), merge_base: merge_base.map(Into::into), ..Divergence::default() } |
| 824 | } |
| 825 | |
| 826 | #[test] |
| 827 | fn kept_commits_are_used_only_for_the_head_and_pair_they_were_kept_for() { |
| 828 | let current = |kept_head, kept_pair, head, key| kept_is_current(kept_head, kept_pair, head, key); |
| 829 | assert!(current(Some("h1"), Some("h1..b1"), Some("h1"), Some("h1..b1"))); |
| 830 | // Its head moved: pushed to since they were kept. |
| 831 | assert!(!current(Some("h1"), Some("h1..b1"), Some("h2"), Some("h1..b1"))); |
| 832 | // Its base moved and mergeability was worked out again without them |
| 833 | // being confirmed for the new pair. |
| 834 | assert!(!current(Some("h1"), Some("h1..b1"), Some("h1"), Some("h1..b2"))); |
| 835 | // Mergeability worked out for another head than the one it has now. |
| 836 | assert!(!current(Some("h1"), Some("h1..b1"), Some("h1"), Some("h0..b1"))); |
| 837 | assert!(!current(Some("h1"), Some("h0..b1"), Some("h1"), Some("h0..b1"))); |
| 838 | // Nothing kept, nothing worked out, or nothing pushed. |
| 839 | assert!(!current(None, None, Some("h1"), Some("h1..b1"))); |
| 840 | assert!(!current(Some("h1"), Some("h1..b1"), Some("h1"), None)); |
| 841 | assert!(!current(Some("h1"), Some("h1..b1"), None, Some("h1..b1"))); |
| 842 | } |
| 843 | |
| 844 | #[test] |
| 845 | fn commits_are_read_again_only_when_the_head_or_merge_base_moves() { |
| 846 | let now = diverged("h1", "b1", Some("m1")); |
| 847 | assert_eq!(keeping(None, &now), Keeping::Read, "none kept"); |
| 848 | assert_eq!(keeping(Some(("h1", Some("m1"), "h1..b1")), &now), Keeping::Current); |
| 849 | // Its base moved on without taking any of its commits. |
| 850 | assert_eq!(keeping(Some(("h1", Some("m1"), "h1..b0")), &now), Keeping::Confirm); |
| 851 | // Pushed to. |
| 852 | assert_eq!(keeping(Some(("h0", Some("m1"), "h0..b1")), &now), Keeping::Read); |
| 853 | // Its base took some of its commits, or it caught up with its base. |
| 854 | assert_eq!(keeping(Some(("h1", Some("m0"), "h1..b0")), &now), Keeping::Read); |
| 855 | assert_eq!(keeping(Some(("h1", None, "h1..b0")), &now), Keeping::Read); |
| 856 | // No history in common: nothing is kept, and the box reads them live. |
| 857 | assert_eq!(keeping(Some(("h1", Some("m1"), "h1..b1")), &diverged("h1", "b1", None)), Keeping::Nothing); |
| 858 | } |
| 859 | |
| 860 | #[test] |
| 861 | fn kept_commits_read_back_as_they_were_written() { |
| 862 | let texts = vec![ |
| 863 | CommitText { message: "feat: one\n\nWhy.".into(), author_email: Some("ada@acme.com".into()) }, |
| 864 | CommitText { message: "fix: two".into(), author_email: None }, |
| 865 | ]; |
| 866 | let json = serde_json::to_string(&texts).unwrap(); |
| 867 | assert_eq!(serde_json::from_str::<Vec<CommitText>>(&json).unwrap(), texts); |
| 868 | } |
| 869 | |
| 870 | #[test] |
| 871 | fn merge_commits_are_left_out_of_the_messages_oldest_first() { |
| 872 | let commit = |sha: &str, parents| g1t_contracts::rules::CommitFacts { |
| 873 | sha: sha.into(), |
| 874 | message: sha.into(), |
| 875 | parents, |
| 876 | ..g1t_contracts::rules::CommitFacts::default() |
| 877 | }; |
| 878 | let inspected = InspectedCommits { commits: vec![commit("newest", 1), commit("merge", 2), commit("oldest", 0)], complete: true }; |
| 879 | let texts: Vec<String> = texts_of(inspected).into_iter().map(|text| text.message).collect(); |
| 880 | assert_eq!(texts, ["oldest", "newest"]); |
| 881 | } |
| 882 | |
| 883 | #[test] |
| 884 | fn the_timeline_says_how_it_merged() { |
| 885 | assert_eq!(merged_note(MergeMethod::Squash), "squashed and merged this"); |
| 886 | assert_eq!(merged_note(MergeMethod::FastForward), "merged this"); |
| 887 | assert_eq!(labels(&[MergeMethod::Squash, MergeMethod::Rebase, MergeMethod::FastForward]), "squash, rebase or fast-forward"); |
| 888 | } |
| 889 | } |