| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | |
| 8 | use std::collections::HashMap; |
| 9 | use std::path::Path; |
| 10 | |
| 11 | use forge_core::reviews::{ReviewComment, Side}; |
| 12 | use forge_core::{Mergeable, PullRequest, Repo}; |
| 13 | use forge_git::pulls::{ |
| 14 | MergeCheck, blob_at, check_merge, fetch_into_pull_ref, is_ancestor, merge_base, |
| 15 | }; |
| 16 | use serde_json::json; |
| 17 | use similar::{ChangeTag, TextDiff}; |
| 18 | |
| 19 | use crate::internal::is_zero_oid; |
| 20 | use crate::notify::{self, Activity}; |
| 21 | use crate::state::AppState; |
| 22 | use crate::webhooks::emit::{emit_item_action, lookup_sender}; |
| 23 | |
| 24 | |
| 25 | |
| 26 | |
| 27 | |
| 28 | |
| 29 | |
| 30 | |
| 31 | |
| 32 | pub async fn sync_pulls_for_ref( |
| 33 | state: &AppState, |
| 34 | repo: &Repo, |
| 35 | actor_id: &str, |
| 36 | refname: &str, |
| 37 | old: &str, |
| 38 | new: &str, |
| 39 | ) -> forge_core::Result<()> { |
| 40 | let Some(branch) = refname.strip_prefix("refs/heads/") else { |
| 41 | return Ok(()); |
| 42 | }; |
| 43 | let heads = state.store.open_pulls_for_head(&repo.id, branch).await?; |
| 44 | for (base_repo_id, number, issue_id) in heads { |
| 45 | if let Err(e) = sync_head( |
| 46 | state, |
| 47 | repo, |
| 48 | actor_id, |
| 49 | &base_repo_id, |
| 50 | number, |
| 51 | &issue_id, |
| 52 | branch, |
| 53 | old, |
| 54 | new, |
| 55 | ) |
| 56 | .await |
| 57 | { |
| 58 | tracing::error!(error = %e, repo_id = %repo.id, number, "pull sync: head update failed"); |
| 59 | } |
| 60 | } |
| 61 | let bases = state.store.open_pulls_for_base(&repo.id, branch).await?; |
| 62 | for (number, issue_id) in bases { |
| 63 | if let Err(e) = sync_base(state, repo, actor_id, number, &issue_id, old, new).await { |
| 64 | tracing::error!(error = %e, repo_id = %repo.id, number, "pull sync: base update failed"); |
| 65 | } |
| 66 | } |
| 67 | Ok(()) |
| 68 | } |
| 69 | |
| 70 | |
| 71 | #[allow(clippy::too_many_arguments)] |
| 72 | async fn sync_head( |
| 73 | state: &AppState, |
| 74 | head_repo: &Repo, |
| 75 | actor_id: &str, |
| 76 | base_repo_id: &str, |
| 77 | number: i64, |
| 78 | issue_id: &str, |
| 79 | branch: &str, |
| 80 | old: &str, |
| 81 | new: &str, |
| 82 | ) -> forge_core::Result<()> { |
| 83 | let actor = (!actor_id.is_empty()).then_some(actor_id); |
| 84 | let Some(pr) = state.store.pull_by_number(base_repo_id, number).await? else { |
| 85 | return Ok(()); |
| 86 | }; |
| 87 | if is_zero_oid(new) { |
| 88 | state |
| 89 | .store |
| 90 | .add_event( |
| 91 | issue_id, |
| 92 | actor, |
| 93 | "head_deleted", |
| 94 | json!({"branch": branch, "last_head": pr.head_oid}), |
| 95 | ) |
| 96 | .await?; |
| 97 | return Ok(()); |
| 98 | } |
| 99 | let Some(base_repo) = state.store.repo_by_id(base_repo_id).await? else { |
| 100 | return Ok(()); |
| 101 | }; |
| 102 | let base_path = forge_git::repo::repo_path(&state.git.data_dir, &base_repo.id)?; |
| 103 | let head_path = forge_git::repo::repo_path(&state.git.data_dir, &head_repo.id)?; |
| 104 | fetch_into_pull_ref(&state.git, &base_path, &head_path, new, number).await?; |
| 105 | state.store.update_pull_head(issue_id, new).await?; |
| 106 | mark_outdated_threads(state, &base_path, issue_id, new).await; |
| 107 | state |
| 108 | .store |
| 109 | .add_event( |
| 110 | issue_id, |
| 111 | actor, |
| 112 | "head_updated", |
| 113 | json!({"from": old, "to": new}), |
| 114 | ) |
| 115 | .await?; |
| 116 | let sender = lookup_sender(state, actor_id).await; |
| 117 | emit_item_action(state, &base_repo, &sender, true, number, "synchronize").await; |
| 118 | |
| 119 | let Some(pr) = state.store.pull_by_number(base_repo_id, number).await? else { |
| 120 | return Ok(()); |
| 121 | }; |
| 122 | notify::dispatch( |
| 123 | state, |
| 124 | &base_repo, |
| 125 | &pr.issue, |
| 126 | actor_id, |
| 127 | Activity::Pushed, |
| 128 | None, |
| 129 | ) |
| 130 | .await; |
| 131 | crate::ci::on_pull_event(state, &base_repo, &pr, actor_id).await; |
| 132 | refresh_mergeability(state, &base_repo, &pr).await |
| 133 | } |
| 134 | |
| 135 | |
| 136 | |
| 137 | const MAX_REMAP_BYTES: usize = 1024 * 1024; |
| 138 | |
| 139 | |
| 140 | |
| 141 | |
| 142 | |
| 143 | |
| 144 | |
| 145 | |
| 146 | |
| 147 | async fn mark_outdated_threads(state: &AppState, base_path: &Path, issue_id: &str, new_head: &str) { |
| 148 | let threads = match state.store.review_threads(issue_id, None).await { |
| 149 | Ok(threads) => threads, |
| 150 | Err(e) => { |
| 151 | tracing::error!(error = %e, issue_id, "pull sync: loading review threads failed"); |
| 152 | return; |
| 153 | } |
| 154 | }; |
| 155 | let mut cache = HashMap::new(); |
| 156 | let mut texts = HashMap::new(); |
| 157 | let mut outdated = Vec::new(); |
| 158 | let mut moved = Vec::new(); |
| 159 | for thread in threads { |
| 160 | let root = thread.root; |
| 161 | if root.outdated { |
| 162 | continue; |
| 163 | } |
| 164 | let old = cached_blob(state, base_path, &mut cache, &root.commit_oid, &root.path).await; |
| 165 | let new = cached_blob(state, base_path, &mut cache, new_head, &root.path).await; |
| 166 | let (old, new) = match (old, new) { |
| 167 | (Ok(old), Ok(new)) => (old, new), |
| 168 | (Err(e), _) | (_, Err(e)) => { |
| 169 | tracing::error!(error = %e, comment_id = %root.id, "pull sync: blob lookup failed"); |
| 170 | continue; |
| 171 | } |
| 172 | }; |
| 173 | if old == new { |
| 174 | continue; |
| 175 | } |
| 176 | let target = if root.side == Side::New && old.is_some() && new.is_some() { |
| 177 | match remap_root_line(base_path, &mut texts, &root, new_head) { |
| 178 | Ok(target) => target, |
| 179 | Err(e) => { |
| 180 | tracing::error!(error = %e, comment_id = %root.id, "pull sync: text lookup failed"); |
| 181 | continue; |
| 182 | } |
| 183 | } |
| 184 | } else { |
| 185 | None |
| 186 | }; |
| 187 | match target { |
| 188 | Some(line) => moved.push((root.id.clone(), line)), |
| 189 | None => outdated.push(root.id.clone()), |
| 190 | } |
| 191 | } |
| 192 | for (id, line) in &moved { |
| 193 | if let Err(e) = state |
| 194 | .store |
| 195 | .relocate_review_comment(id, *line, new_head) |
| 196 | .await |
| 197 | { |
| 198 | tracing::error!(error = %e, comment_id = %id, "pull sync: relocating comment failed"); |
| 199 | } |
| 200 | } |
| 201 | if outdated.is_empty() { |
| 202 | return; |
| 203 | } |
| 204 | if let Err(e) = state.store.mark_outdated(issue_id, &outdated).await { |
| 205 | tracing::error!(error = %e, issue_id, "pull sync: marking comments outdated failed"); |
| 206 | } |
| 207 | } |
| 208 | |
| 209 | |
| 210 | |
| 211 | |
| 212 | fn remap_root_line( |
| 213 | base_path: &Path, |
| 214 | texts: &mut HashMap<(String, String), Option<String>>, |
| 215 | root: &ReviewComment, |
| 216 | new_head: &str, |
| 217 | ) -> forge_core::Result<Option<u32>> { |
| 218 | let old = cached_text(base_path, texts, &root.commit_oid, &root.path)?; |
| 219 | let new = cached_text(base_path, texts, new_head, &root.path)?; |
| 220 | Ok(match (old, new) { |
| 221 | (Some(old), Some(new)) => unchanged_line(&old, &new, root.line), |
| 222 | _ => None, |
| 223 | }) |
| 224 | } |
| 225 | |
| 226 | |
| 227 | |
| 228 | fn unchanged_line(old: &str, new: &str, old_line: u32) -> Option<u32> { |
| 229 | let index = usize::try_from(old_line).ok()?.checked_sub(1)?; |
| 230 | TextDiff::from_lines(old, new) |
| 231 | .iter_all_changes() |
| 232 | .find(|change| change.tag() == ChangeTag::Equal && change.old_index() == Some(index)) |
| 233 | .and_then(|change| change.new_index()) |
| 234 | .and_then(|index| u32::try_from(index + 1).ok()) |
| 235 | } |
| 236 | |
| 237 | |
| 238 | fn cached_text( |
| 239 | base_path: &Path, |
| 240 | cache: &mut HashMap<(String, String), Option<String>>, |
| 241 | commit: &str, |
| 242 | path: &str, |
| 243 | ) -> forge_core::Result<Option<String>> { |
| 244 | let key = (commit.to_owned(), path.to_owned()); |
| 245 | if let Some(text) = cache.get(&key) { |
| 246 | return Ok(text.clone()); |
| 247 | } |
| 248 | let text = forge_git::browse::read_text_blob(base_path, commit, path, MAX_REMAP_BYTES)?; |
| 249 | cache.insert(key, text.clone()); |
| 250 | Ok(text) |
| 251 | } |
| 252 | |
| 253 | |
| 254 | async fn cached_blob( |
| 255 | state: &AppState, |
| 256 | base_path: &Path, |
| 257 | cache: &mut HashMap<(String, String), Option<String>>, |
| 258 | commit: &str, |
| 259 | path: &str, |
| 260 | ) -> forge_core::Result<Option<String>> { |
| 261 | let key = (commit.to_owned(), path.to_owned()); |
| 262 | if let Some(blob) = cache.get(&key) { |
| 263 | return Ok(blob.clone()); |
| 264 | } |
| 265 | let blob = blob_at(&state.git, base_path, commit, path).await?; |
| 266 | cache.insert(key, blob.clone()); |
| 267 | Ok(blob) |
| 268 | } |
| 269 | |
| 270 | |
| 271 | async fn sync_base( |
| 272 | state: &AppState, |
| 273 | repo: &Repo, |
| 274 | actor_id: &str, |
| 275 | number: i64, |
| 276 | issue_id: &str, |
| 277 | old: &str, |
| 278 | new: &str, |
| 279 | ) -> forge_core::Result<()> { |
| 280 | if is_zero_oid(new) { |
| 281 | |
| 282 | return Ok(()); |
| 283 | } |
| 284 | let Some(pr) = state.store.pull_by_number(&repo.id, number).await? else { |
| 285 | return Ok(()); |
| 286 | }; |
| 287 | let base_path = forge_git::repo::repo_path(&state.git.data_dir, &repo.id)?; |
| 288 | if is_ancestor(&state.git, &base_path, &pr.head_oid, new).await? { |
| 289 | let was_contained = |
| 290 | !is_zero_oid(old) && is_ancestor(&state.git, &base_path, &pr.head_oid, old).await?; |
| 291 | if !was_contained { |
| 292 | |
| 293 | state.store.mark_merged(issue_id, actor_id, new).await?; |
| 294 | let sender = lookup_sender(state, actor_id).await; |
| 295 | emit_item_action(state, repo, &sender, true, number, "merged").await; |
| 296 | notify::dispatch(state, repo, &pr.issue, actor_id, Activity::Merged, None).await; |
| 297 | return Ok(()); |
| 298 | } |
| 299 | } |
| 300 | refresh_mergeability(state, repo, &pr).await |
| 301 | } |
| 302 | |
| 303 | |
| 304 | |
| 305 | |
| 306 | |
| 307 | |
| 308 | pub async fn refresh_mergeability( |
| 309 | state: &AppState, |
| 310 | base_repo: &Repo, |
| 311 | pr: &PullRequest, |
| 312 | ) -> forge_core::Result<()> { |
| 313 | let base_path = forge_git::repo::repo_path(&state.git.data_dir, &base_repo.id)?; |
| 314 | let Some(base_tip) = forge_git::browse::default_branch_commit(&base_path, &pr.base_branch)? |
| 315 | else { |
| 316 | return Ok(()); |
| 317 | }; |
| 318 | let (mergeable, files) = |
| 319 | match check_merge(&state.git, &base_path, &base_tip, &pr.head_oid).await? { |
| 320 | MergeCheck::Clean { .. } => (Mergeable::Clean, Vec::new()), |
| 321 | MergeCheck::Conflict { files } => (Mergeable::Conflict, files), |
| 322 | MergeCheck::UpToDate => (Mergeable::UpToDate, Vec::new()), |
| 323 | }; |
| 324 | let base_of_head = merge_base(&state.git, &base_path, &base_tip, &pr.head_oid).await?; |
| 325 | state |
| 326 | .store |
| 327 | .set_mergeability( |
| 328 | &pr.issue.id, |
| 329 | &base_tip, |
| 330 | base_of_head.as_deref(), |
| 331 | mergeable, |
| 332 | &files, |
| 333 | ) |
| 334 | .await?; |
| 335 | Ok(()) |
| 336 | } |
| 337 | |
| 338 | #[cfg(test)] |
| 339 | mod tests { |
| 340 | use std::path::{Path, PathBuf}; |
| 341 | |
| 342 | use forge_core::{IssueState, Visibility}; |
| 343 | use forge_git::repo::{init_bare, repo_path}; |
| 344 | use tempfile::TempDir; |
| 345 | |
| 346 | use super::*; |
| 347 | use crate::post_update::after_ref_update; |
| 348 | use crate::test_support::test_state; |
| 349 | |
| 350 | fn git(dir: &Path, args: &[&str]) -> String { |
| 351 | let out = std::process::Command::new("git") |
| 352 | .arg("-C") |
| 353 | .arg(dir) |
| 354 | .args(args) |
| 355 | .env("GIT_AUTHOR_NAME", "test") |
| 356 | .env("GIT_AUTHOR_EMAIL", "test@example.com") |
| 357 | .env("GIT_COMMITTER_NAME", "test") |
| 358 | .env("GIT_COMMITTER_EMAIL", "test@example.com") |
| 359 | .env("GIT_CONFIG_NOSYSTEM", "1") |
| 360 | .env("GIT_CONFIG_GLOBAL", "/dev/null") |
| 361 | .output() |
| 362 | .expect("git runs"); |
| 363 | assert!( |
| 364 | out.status.success(), |
| 365 | "git {args:?} failed: {}", |
| 366 | String::from_utf8_lossy(&out.stderr) |
| 367 | ); |
| 368 | String::from_utf8_lossy(&out.stdout).trim().to_string() |
| 369 | } |
| 370 | |
| 371 | |
| 372 | fn commit(work: &Path, file: &str, contents: &str) -> String { |
| 373 | std::fs::write(work.join(file), contents).expect("write file"); |
| 374 | git(work, &["add", file]); |
| 375 | git( |
| 376 | work, |
| 377 | &[ |
| 378 | "-c", |
| 379 | "commit.gpgsign=false", |
| 380 | "commit", |
| 381 | "-q", |
| 382 | "-m", |
| 383 | &format!("update {file}"), |
| 384 | ], |
| 385 | ); |
| 386 | git(work, &["rev-parse", "HEAD"]) |
| 387 | } |
| 388 | |
| 389 | struct Fixture { |
| 390 | _tmp: TempDir, |
| 391 | state: AppState, |
| 392 | repo: Repo, |
| 393 | owner_id: String, |
| 394 | bare: PathBuf, |
| 395 | work: PathBuf, |
| 396 | } |
| 397 | |
| 398 | |
| 399 | async fn fixture() -> Fixture { |
| 400 | let tmp = tempfile::tempdir().expect("tempdir"); |
| 401 | let state = test_state(tmp.path()).await; |
| 402 | let owner = state |
| 403 | .store |
| 404 | .create_user("alice", None, "unused", false) |
| 405 | .await |
| 406 | .expect("user"); |
| 407 | let repo = state |
| 408 | .store |
| 409 | .create_repo(&owner.id, "proj", "", Visibility::Private, "main") |
| 410 | .await |
| 411 | .expect("repo"); |
| 412 | let bare = repo_path(&state.git.data_dir, &repo.id).expect("repo path"); |
| 413 | init_bare(&state.git, &bare, "main") |
| 414 | .await |
| 415 | .expect("init bare"); |
| 416 | let work = tmp.path().join("work"); |
| 417 | git(tmp.path(), &["init", "-b", "main", "work"]); |
| 418 | Fixture { |
| 419 | _tmp: tmp, |
| 420 | state, |
| 421 | repo, |
| 422 | owner_id: owner.id, |
| 423 | bare, |
| 424 | work, |
| 425 | } |
| 426 | } |
| 427 | |
| 428 | impl Fixture { |
| 429 | fn bare_str(&self) -> &str { |
| 430 | self.bare.to_str().expect("utf8 path") |
| 431 | } |
| 432 | |
| 433 | fn push(&self, refspec: &str) { |
| 434 | git(&self.work, &["push", self.bare_str(), refspec]); |
| 435 | } |
| 436 | |
| 437 | |
| 438 | async fn push_and_sync(&self, branch: &str, old: &str, new: &str) { |
| 439 | self.push(&format!("{new}:refs/heads/{branch}")); |
| 440 | after_ref_update( |
| 441 | &self.state, |
| 442 | &self.repo, |
| 443 | &self.owner_id, |
| 444 | &format!("refs/heads/{branch}"), |
| 445 | old, |
| 446 | new, |
| 447 | ) |
| 448 | .await |
| 449 | .expect("post-update"); |
| 450 | } |
| 451 | |
| 452 | async fn pull(&self, number: i64) -> PullRequest { |
| 453 | self.state |
| 454 | .store |
| 455 | .pull_by_number(&self.repo.id, number) |
| 456 | .await |
| 457 | .expect("lookup") |
| 458 | .expect("pull exists") |
| 459 | } |
| 460 | } |
| 461 | |
| 462 | #[tokio::test] |
| 463 | async fn pushing_to_head_branch_updates_the_pull() { |
| 464 | let fx = fixture().await; |
| 465 | let base = commit(&fx.work, "file.txt", "one"); |
| 466 | fx.push("main"); |
| 467 | git(&fx.work, &["checkout", "-b", "feature"]); |
| 468 | let f1 = commit(&fx.work, "feature.txt", "f1"); |
| 469 | fx.push("feature"); |
| 470 | let pr = fx |
| 471 | .state |
| 472 | .store |
| 473 | .create_pull( |
| 474 | &fx.repo.id, |
| 475 | &fx.owner_id, |
| 476 | "Feature", |
| 477 | "", |
| 478 | "main", |
| 479 | &fx.repo.id, |
| 480 | "feature", |
| 481 | &f1, |
| 482 | &base, |
| 483 | ) |
| 484 | .await |
| 485 | .expect("create pull"); |
| 486 | let number = pr.issue.number; |
| 487 | |
| 488 | let f2 = commit(&fx.work, "feature.txt", "f2"); |
| 489 | fx.push_and_sync("feature", &f1, &f2).await; |
| 490 | |
| 491 | let pr = fx.pull(number).await; |
| 492 | assert_eq!(pr.head_oid, f2); |
| 493 | assert_eq!(pr.mergeable, Mergeable::Clean); |
| 494 | let pull_ref = git( |
| 495 | &fx.bare, |
| 496 | &["rev-parse", &format!("refs/pull/{number}/head")], |
| 497 | ); |
| 498 | assert_eq!(pull_ref, f2); |
| 499 | let events = fx |
| 500 | .state |
| 501 | .store |
| 502 | .list_events(&pr.issue.id) |
| 503 | .await |
| 504 | .expect("events"); |
| 505 | let updated = events |
| 506 | .iter() |
| 507 | .find(|e| e.kind == "head_updated") |
| 508 | .expect("head_updated event"); |
| 509 | assert_eq!(updated.data["from"], f1); |
| 510 | assert_eq!(updated.data["to"], f2); |
| 511 | assert_eq!(updated.actor_id.as_deref(), Some(fx.owner_id.as_str())); |
| 512 | } |
| 513 | |
| 514 | #[tokio::test] |
| 515 | async fn deleting_head_branch_keeps_last_head() { |
| 516 | let fx = fixture().await; |
| 517 | let base = commit(&fx.work, "file.txt", "one"); |
| 518 | fx.push("main"); |
| 519 | git(&fx.work, &["checkout", "-b", "feature"]); |
| 520 | let f1 = commit(&fx.work, "feature.txt", "f1"); |
| 521 | fx.push("feature"); |
| 522 | let pr = fx |
| 523 | .state |
| 524 | .store |
| 525 | .create_pull( |
| 526 | &fx.repo.id, |
| 527 | &fx.owner_id, |
| 528 | "Feature", |
| 529 | "", |
| 530 | "main", |
| 531 | &fx.repo.id, |
| 532 | "feature", |
| 533 | &f1, |
| 534 | &base, |
| 535 | ) |
| 536 | .await |
| 537 | .expect("create pull"); |
| 538 | let zero = "0".repeat(40); |
| 539 | git(&fx.bare, &["update-ref", "-d", "refs/heads/feature"]); |
| 540 | after_ref_update( |
| 541 | &fx.state, |
| 542 | &fx.repo, |
| 543 | &fx.owner_id, |
| 544 | "refs/heads/feature", |
| 545 | &f1, |
| 546 | &zero, |
| 547 | ) |
| 548 | .await |
| 549 | .expect("post-update"); |
| 550 | |
| 551 | let pr = fx.pull(pr.issue.number).await; |
| 552 | assert_eq!(pr.head_oid, f1); |
| 553 | let events = fx |
| 554 | .state |
| 555 | .store |
| 556 | .list_events(&pr.issue.id) |
| 557 | .await |
| 558 | .expect("events"); |
| 559 | assert!(events.iter().any(|e| e.kind == "head_deleted")); |
| 560 | } |
| 561 | |
| 562 | #[tokio::test] |
| 563 | async fn pushing_conflicting_base_marks_pull_conflicting() { |
| 564 | let fx = fixture().await; |
| 565 | let base = commit(&fx.work, "file.txt", "one"); |
| 566 | fx.push("main"); |
| 567 | git(&fx.work, &["checkout", "-b", "feature"]); |
| 568 | let f1 = commit(&fx.work, "file.txt", "feature"); |
| 569 | fx.push("feature"); |
| 570 | let pr = fx |
| 571 | .state |
| 572 | .store |
| 573 | .create_pull( |
| 574 | &fx.repo.id, |
| 575 | &fx.owner_id, |
| 576 | "Feature", |
| 577 | "", |
| 578 | "main", |
| 579 | &fx.repo.id, |
| 580 | "feature", |
| 581 | &f1, |
| 582 | &base, |
| 583 | ) |
| 584 | .await |
| 585 | .expect("create pull"); |
| 586 | assert_eq!(pr.mergeable, Mergeable::Unknown); |
| 587 | |
| 588 | git(&fx.work, &["checkout", "main"]); |
| 589 | let m1 = commit(&fx.work, "file.txt", "main"); |
| 590 | fx.push_and_sync("main", &base, &m1).await; |
| 591 | |
| 592 | let pr = fx.pull(pr.issue.number).await; |
| 593 | assert_eq!(pr.mergeable, Mergeable::Conflict); |
| 594 | assert_eq!(pr.conflict_files, vec!["file.txt".to_string()]); |
| 595 | assert_eq!(pr.base_oid, m1); |
| 596 | assert_eq!(pr.merge_base.as_deref(), Some(base.as_str())); |
| 597 | } |
| 598 | |
| 599 | #[tokio::test] |
| 600 | async fn pushing_a_changed_file_marks_its_comments_outdated() { |
| 601 | use forge_core::reviews::Side; |
| 602 | |
| 603 | let fx = fixture().await; |
| 604 | commit(&fx.work, "b.txt", "b0"); |
| 605 | let base = commit(&fx.work, "a.txt", "a0"); |
| 606 | fx.push("main"); |
| 607 | git(&fx.work, &["checkout", "-b", "feature"]); |
| 608 | let f1 = commit(&fx.work, "a.txt", "a1"); |
| 609 | fx.push("feature"); |
| 610 | let pr = fx |
| 611 | .state |
| 612 | .store |
| 613 | .create_pull( |
| 614 | &fx.repo.id, |
| 615 | &fx.owner_id, |
| 616 | "Feature", |
| 617 | "", |
| 618 | "main", |
| 619 | &fx.repo.id, |
| 620 | "feature", |
| 621 | &f1, |
| 622 | &base, |
| 623 | ) |
| 624 | .await |
| 625 | .expect("create pull"); |
| 626 | let store = &fx.state.store; |
| 627 | store |
| 628 | .add_review_comment( |
| 629 | &pr.issue.id, |
| 630 | &fx.owner_id, |
| 631 | false, |
| 632 | "a.txt", |
| 633 | Side::New, |
| 634 | 1, |
| 635 | &f1, |
| 636 | "", |
| 637 | "on a", |
| 638 | ) |
| 639 | .await |
| 640 | .expect("comment on a"); |
| 641 | store |
| 642 | .add_review_comment( |
| 643 | &pr.issue.id, |
| 644 | &fx.owner_id, |
| 645 | false, |
| 646 | "b.txt", |
| 647 | Side::New, |
| 648 | 1, |
| 649 | &f1, |
| 650 | "", |
| 651 | "on b", |
| 652 | ) |
| 653 | .await |
| 654 | .expect("comment on b"); |
| 655 | |
| 656 | let f2 = commit(&fx.work, "a.txt", "a2"); |
| 657 | fx.push_and_sync("feature", &f1, &f2).await; |
| 658 | |
| 659 | let threads = store |
| 660 | .review_threads(&pr.issue.id, None) |
| 661 | .await |
| 662 | .expect("threads"); |
| 663 | let outdated_at = |path: &str| { |
| 664 | threads |
| 665 | .iter() |
| 666 | .find(|t| t.root.path == path) |
| 667 | .map(|t| t.root.outdated) |
| 668 | }; |
| 669 | assert_eq!(outdated_at("a.txt"), Some(true)); |
| 670 | assert_eq!(outdated_at("b.txt"), Some(false)); |
| 671 | } |
| 672 | |
| 673 | #[tokio::test] |
| 674 | async fn base_push_containing_head_marks_pull_merged() { |
| 675 | let fx = fixture().await; |
| 676 | let base = commit(&fx.work, "file.txt", "one"); |
| 677 | fx.push("main"); |
| 678 | git(&fx.work, &["checkout", "-b", "feature"]); |
| 679 | let f1 = commit(&fx.work, "feature.txt", "f1"); |
| 680 | fx.push("feature"); |
| 681 | let pr = fx |
| 682 | .state |
| 683 | .store |
| 684 | .create_pull( |
| 685 | &fx.repo.id, |
| 686 | &fx.owner_id, |
| 687 | "Feature", |
| 688 | "", |
| 689 | "main", |
| 690 | &fx.repo.id, |
| 691 | "feature", |
| 692 | &f1, |
| 693 | &base, |
| 694 | ) |
| 695 | .await |
| 696 | .expect("create pull"); |
| 697 | |
| 698 | |
| 699 | git(&fx.work, &["checkout", "main"]); |
| 700 | let side = commit(&fx.work, "other.txt", "other"); |
| 701 | git( |
| 702 | &fx.work, |
| 703 | &["merge", "--no-ff", "feature", "-m", "merge feature"], |
| 704 | ); |
| 705 | let merge = git(&fx.work, &["rev-parse", "HEAD"]); |
| 706 | assert_ne!(merge, side); |
| 707 | fx.push_and_sync("main", &side, &merge).await; |
| 708 | |
| 709 | let pr = fx.pull(pr.issue.number).await; |
| 710 | assert_eq!(pr.mergeable, Mergeable::Merged); |
| 711 | assert_eq!(pr.merge_commit.as_deref(), Some(merge.as_str())); |
| 712 | assert!(pr.merged_at.is_some()); |
| 713 | assert_eq!(pr.issue.state, IssueState::Closed); |
| 714 | let events = fx |
| 715 | .state |
| 716 | .store |
| 717 | .list_events(&pr.issue.id) |
| 718 | .await |
| 719 | .expect("events"); |
| 720 | assert!(events.iter().any(|e| e.kind == "merged")); |
| 721 | } |
| 722 | |
| 723 | #[test] |
| 724 | fn unchanged_line_follows_insertions_above() { |
| 725 | let old = "a\nb\nc\n"; |
| 726 | let new = "x\na\nb\nc\n"; |
| 727 | assert_eq!(unchanged_line(old, new, 1), Some(2)); |
| 728 | assert_eq!(unchanged_line(old, new, 2), Some(3)); |
| 729 | assert_eq!(unchanged_line(old, new, 3), Some(4)); |
| 730 | assert_eq!(unchanged_line(old, new, 0), None); |
| 731 | assert_eq!(unchanged_line(old, new, 9), None); |
| 732 | } |
| 733 | |
| 734 | #[test] |
| 735 | fn unchanged_line_is_none_for_modified_or_deleted_lines() { |
| 736 | assert_eq!(unchanged_line("a\nb\nc\n", "a\nB\nc\n", 2), None); |
| 737 | assert_eq!(unchanged_line("a\nb\nc\n", "a\nB\nc\n", 3), Some(3)); |
| 738 | assert_eq!(unchanged_line("a\nb\n", "a\n", 2), None); |
| 739 | } |
| 740 | |
| 741 | #[tokio::test] |
| 742 | async fn pushing_moved_lines_relocates_comments_and_changed_lines_go_outdated() { |
| 743 | use forge_core::reviews::Side; |
| 744 | |
| 745 | let fx = fixture().await; |
| 746 | let base = commit(&fx.work, "b.txt", "b0"); |
| 747 | fx.push("main"); |
| 748 | git(&fx.work, &["checkout", "-b", "feature"]); |
| 749 | let f1 = commit(&fx.work, "a.txt", "a\nb\nc\n"); |
| 750 | fx.push("feature"); |
| 751 | let pr = fx |
| 752 | .state |
| 753 | .store |
| 754 | .create_pull( |
| 755 | &fx.repo.id, |
| 756 | &fx.owner_id, |
| 757 | "Feature", |
| 758 | "", |
| 759 | "main", |
| 760 | &fx.repo.id, |
| 761 | "feature", |
| 762 | &f1, |
| 763 | &base, |
| 764 | ) |
| 765 | .await |
| 766 | .expect("create pull"); |
| 767 | let store = &fx.state.store; |
| 768 | for (line, body) in [(2, "on b"), (3, "on c")] { |
| 769 | store |
| 770 | .add_review_comment( |
| 771 | &pr.issue.id, |
| 772 | &fx.owner_id, |
| 773 | false, |
| 774 | "a.txt", |
| 775 | Side::New, |
| 776 | line, |
| 777 | &f1, |
| 778 | "", |
| 779 | body, |
| 780 | ) |
| 781 | .await |
| 782 | .expect("comment"); |
| 783 | } |
| 784 | |
| 785 | let f2 = commit(&fx.work, "a.txt", "x\na\nb\nC\n"); |
| 786 | fx.push_and_sync("feature", &f1, &f2).await; |
| 787 | |
| 788 | let threads = store |
| 789 | .review_threads(&pr.issue.id, None) |
| 790 | .await |
| 791 | .expect("threads"); |
| 792 | let on = |body: &str| { |
| 793 | threads |
| 794 | .iter() |
| 795 | .map(|t| &t.root) |
| 796 | .find(|r| r.body == body) |
| 797 | .expect("root") |
| 798 | .clone() |
| 799 | }; |
| 800 | let moved = on("on b"); |
| 801 | assert!(!moved.outdated); |
| 802 | assert_eq!(moved.line, 3); |
| 803 | assert_eq!(moved.commit_oid, f2); |
| 804 | assert_eq!(moved.diff_hunk, ""); |
| 805 | assert!(on("on c").outdated); |
| 806 | } |
| 807 | |
| 808 | #[tokio::test] |
| 809 | async fn deleting_a_file_marks_its_comments_outdated() { |
| 810 | use forge_core::reviews::Side; |
| 811 | |
| 812 | let fx = fixture().await; |
| 813 | let base = commit(&fx.work, "b.txt", "b0"); |
| 814 | fx.push("main"); |
| 815 | git(&fx.work, &["checkout", "-b", "feature"]); |
| 816 | let f1 = commit(&fx.work, "a.txt", "a\nb\n"); |
| 817 | fx.push("feature"); |
| 818 | let pr = fx |
| 819 | .state |
| 820 | .store |
| 821 | .create_pull( |
| 822 | &fx.repo.id, |
| 823 | &fx.owner_id, |
| 824 | "Feature", |
| 825 | "", |
| 826 | "main", |
| 827 | &fx.repo.id, |
| 828 | "feature", |
| 829 | &f1, |
| 830 | &base, |
| 831 | ) |
| 832 | .await |
| 833 | .expect("create pull"); |
| 834 | fx.state |
| 835 | .store |
| 836 | .add_review_comment( |
| 837 | &pr.issue.id, |
| 838 | &fx.owner_id, |
| 839 | false, |
| 840 | "a.txt", |
| 841 | Side::New, |
| 842 | 1, |
| 843 | &f1, |
| 844 | "", |
| 845 | "on a", |
| 846 | ) |
| 847 | .await |
| 848 | .expect("comment"); |
| 849 | |
| 850 | git(&fx.work, &["rm", "a.txt"]); |
| 851 | git(&fx.work, &["commit", "-m", "remove a"]); |
| 852 | let f2 = git(&fx.work, &["rev-parse", "HEAD"]); |
| 853 | fx.push_and_sync("feature", &f1, &f2).await; |
| 854 | |
| 855 | let threads = fx |
| 856 | .state |
| 857 | .store |
| 858 | .review_threads(&pr.issue.id, None) |
| 859 | .await |
| 860 | .expect("threads"); |
| 861 | assert_eq!(threads.len(), 1); |
| 862 | assert!(threads[0].root.outdated); |
| 863 | assert_eq!(threads[0].root.commit_oid, f1); |
| 864 | } |
| 865 | } |