forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
27.9 KiB
1//! Keeps pull requests in step with their branches after a ref update.
2//!
3//! Called from `post_update::after_ref_update` for every `refs/heads/<b>` update of a repo.
4//! Work runs inline (not spawned): each step is a few git calls and a few row writes, and inline
5//! execution keeps the results deterministic for tests and for the merge route. Per-pull errors
6//! are logged and skipped so one broken pull request does not block the others or the push.
7
8use std::collections::HashMap;
9use std::path::Path;
10
11use forge_core::reviews::{ReviewComment, Side};
12use forge_core::{Mergeable, PullRequest, Repo};
13use forge_git::pulls::{
14 MergeCheck, blob_at, check_merge, fetch_into_pull_ref, is_ancestor, merge_base,
15};
16use serde_json::json;
17use similar::{ChangeTag, TextDiff};
18
19use crate::internal::is_zero_oid;
20use crate::notify::{self, Activity};
21use crate::state::AppState;
22use crate::webhooks::emit::{emit_item_action, lookup_sender};
23
24/// Syncs every open pull request affected by `refname` (old -> new) in `repo`.
25///
26/// - PRs whose head is `(repo, branch)`: the pull ref is updated, the head moves, a `head_updated`
27/// event is recorded and mergeability is refreshed. A deleted branch records `head_deleted` and
28/// keeps the last head.
29/// - PRs whose base is `(repo, branch)`: if the PR head is now contained in the new base tip and
30/// was not contained before the push, the PR is marked merged (a manual merge on the CLI).
31/// Otherwise mergeability is refreshed.
32pub 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/// Handles one open PR whose head branch is `branch` in `head_repo`.
71#[allow(clippy::too_many_arguments)]
72async 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 // Reload so the refresh sees the new head.
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/// Largest file, in bytes, whose text is compared line by line when a moved head is checked.
136/// Bigger files, binary files and missing files fall back to outdated.
137const MAX_REMAP_BYTES: usize = 1024 * 1024;
138
139/// Handles submitted root comments after the head moved to `new_head`.
140///
141/// A root is left alone while the blob at its `path` is the same at its `commit_oid` and at
142/// `new_head`. When the blob changed, a new-side root whose line kept its content is moved to the
143/// line's new number at `new_head` (replies follow it). Every other changed root is outdated:
144/// its line was deleted or modified, or the file vanished, grew past `MAX_REMAP_BYTES` or became
145/// binary. Blob lookups are cached per `(commit, path)`. Failures are logged and leave the
146/// comments as they were, so they never fail the push.
147async 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/// The line a changed root moves to at `new_head`, or None when it must be outdated. The root's
210/// line is looked up in the text at its own commit and at `new_head`; a missing or oversized text
211/// gives None.
212fn 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/// The new line number of `old_line` (1-based) when its content is unchanged in `new`, or None
227/// when the line was deleted or modified.
228fn 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/// The text of the file at `path` in `commit`, looked up once per `(commit, path)`.
238fn 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/// The blob at `path` in `commit`, looked up once per `(commit, path)`.
254async 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/// Handles one open PR whose base branch was just updated in `repo`.
271async 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 // The base branch was deleted; there is nothing to compare against.
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 // The push brought the head into base: a merge done outside the web UI.
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/// Recomputes whether `pr` merges cleanly into the current tip of its base branch in `base_repo`,
304/// and stores the result (base tip, merge base, mergeability, conflicting files).
305///
306/// A missing base branch leaves the stored result untouched. Git failures are returned; callers
307/// that must not fail a push log them.
308pub 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)]
339mod 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 /// Writes `contents` to `file` in the work tree, commits it and returns the new HEAD.
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 /// A private repo with a bare copy on disk and an empty work tree that pushes to it.
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 /// Pushes `refspec` and runs the post-update work the hook would run.
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 // A manual merge on the CLI: main gets a side commit, then the feature branch.
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 // Lines are inserted above "b" and "c" is rewritten.
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}