steid

@jamesgill /

steid/src/application/pull_request_tests.rs
Rust39.2 KBCode·Blame·History·Raw
1use std::time::Duration;
2
3use super::*;
4use crate::{
5 domain::{
6 Email, IssueNumber, Membership, MembershipId, OrgId, Organization, PasswordHash, RepoId,
7 Role, User, UserId, Visibility, repository::OrgRepository,
8 },
9 infrastructure::{
10 git::{InMemoryGitMerge, InMemoryGitQuery},
11 repository::{
12 InMemoryIssueRepo, InMemoryLabelRepo, InMemoryMembershipRepo, InMemoryOrgRepo,
13 InMemoryPullRequestRepo, InMemoryPushEventRepo, InMemoryRepoRepo, InMemoryUserRepo,
14 },
15 },
16};
17
18fn at(seconds: u64) -> SystemTime {
19 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
20}
21
22struct Fixture {
23 orgs: InMemoryOrgRepo,
24 memberships: InMemoryMembershipRepo,
25 repos: InMemoryRepoRepo,
26 issues: InMemoryIssueRepo,
27 pulls: InMemoryPullRequestRepo,
28 events: InMemoryPushEventRepo,
29 users: InMemoryUserRepo,
30 labels: InMemoryLabelRepo,
31 handle: OrgName,
32 owner: Actor,
33 member: Actor,
34 stranger: Actor,
35}
36
37/// One organisation with an owner and a plain member, a public repository `steid`
38/// and a private one `secret` — the issue fixture, so the two sets of tests are
39/// comparable.
40async fn fixture() -> Fixture {
41 let orgs = InMemoryOrgRepo::new();
42 let memberships = InMemoryMembershipRepo::new();
43 let repos = InMemoryRepoRepo::new();
44 let users = InMemoryUserRepo::new();
45
46 let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
47 orgs.save(&org).await.expect("save org");
48
49 let user_for = |email: &str| {
50 User::new(
51 UserId::generate(),
52 Email::new(email).expect("valid email"),
53 PasswordHash::from_trusted("$argon2id$test"),
54 org.id.clone(),
55 )
56 };
57
58 let owner = user_for("owner@example.com");
59 let member = user_for("member@example.com");
60
61 for (user, role) in [(&owner, Role::Owner), (&member, Role::Member)] {
62 users.save(user).await.expect("save user");
63 memberships
64 .save(&Membership::new(
65 MembershipId::generate(),
66 org.id.clone(),
67 user.id.clone(),
68 role,
69 ))
70 .await
71 .expect("save membership");
72 }
73
74 for (name, visibility) in [
75 ("steid", Visibility::Public),
76 ("secret", Visibility::Private),
77 ] {
78 repos
79 .save(
80 &Repository::new(
81 RepoId::generate(),
82 org.id.clone(),
83 name,
84 None,
85 visibility,
86 at(1_000),
87 )
88 .expect("valid repo"),
89 )
90 .await
91 .expect("save repo");
92 }
93
94 Fixture {
95 orgs,
96 memberships,
97 repos,
98 issues: InMemoryIssueRepo::new(),
99 pulls: InMemoryPullRequestRepo::new(),
100 events: InMemoryPushEventRepo::new(),
101 users,
102 labels: InMemoryLabelRepo::new(),
103 handle: org.name.clone(),
104 owner: Actor::User(owner.id),
105 member: Actor::User(member.id),
106 stranger: Actor::Anonymous,
107 }
108}
109
110fn repo(name: &str) -> RepoName {
111 RepoName::new(name).expect("valid name")
112}
113
114fn rev(value: &str) -> RefName {
115 RefName::new(value).expect("valid revision")
116}
117
118fn oid(prefix: &str) -> ObjectId {
119 ObjectId::from_trusted(format!("{prefix:0>40}"))
120}
121
122/// What the use cases actually resolve. A pull request's ends are **branches**, so
123/// everything goes through `refs/heads/` — a bare name would let a tag answer.
124fn heads(branch: &str) -> String {
125 format!("refs/heads/{branch}")
126}
127
128/// A repository where `main` and `spike` are different commits, and their merge base
129/// is a third — the genuine three-way case, which is the one that needs a preview.
130fn three_way() -> InMemoryGitQuery {
131 InMemoryGitQuery::new()
132 .with_resolved(&heads("main"), "a")
133 .with_resolved(&heads("spike"), "b")
134 .with_merge_base(&oid("c").to_string())
135}
136
137async fn open(f: &Fixture, name: &str, source: &str, target: &str) -> PullRequest {
138 open_with(f, name, source, target, &three_way()).await
139}
140
141async fn open_with(
142 f: &Fixture,
143 name: &str,
144 source: &str,
145 target: &str,
146 git: &InMemoryGitQuery,
147) -> PullRequest {
148 create_pull(
149 &f.owner,
150 &f.handle,
151 &repo(name),
152 &NewPull {
153 title: format!("Merge {source}"),
154 body: "the body".to_owned(),
155 source: rev(source),
156 target: rev(target),
157 },
158 at(2_000),
159 &Stores::new(&f.orgs, &f.memberships, &f.repos),
160 &f.pulls,
161 git,
162 )
163 .await
164 .expect("should open")
165}
166
167async fn viewed(
168 f: &Fixture,
169 actor: &Actor,
170 name: &str,
171 number: IssueNumber,
172 git: &InMemoryGitQuery,
173) -> Option<PullDetail> {
174 view_pull(
175 actor,
176 &f.handle,
177 &repo(name),
178 number,
179 true,
180 &Stores::new(&f.orgs, &f.memberships, &f.repos),
181 &f.pulls,
182 &f.users,
183 git,
184 &f.labels,
185 )
186 .await
187 .expect("should read")
188}
189
190#[allow(clippy::too_many_arguments)]
191async fn merge(
192 f: &Fixture,
193 actor: &Actor,
194 name: &str,
195 number: IssueNumber,
196 strategy: MergeStrategy,
197 delete_source: bool,
198 git: &InMemoryGitQuery,
199 merges: &InMemoryGitMerge,
200) -> Result<Merged> {
201 merge_pull(
202 actor,
203 &f.handle,
204 &repo(name),
205 number,
206 strategy,
207 delete_source,
208 true,
209 at(5_000),
210 &Stores::new(&f.orgs, &f.memberships, &f.repos),
211 &f.pulls,
212 &f.events,
213 &f.issues,
214 git,
215 merges,
216 &MergeAuthor {
217 name: "Ada Lovelace".to_owned(),
218 email: "ada@example.com".to_owned(),
219 },
220 )
221 .await
222}
223
224// --- opening ----------------------------------------------------------------------
225
226#[tokio::test]
227async fn a_pull_request_draws_from_the_same_sequence_as_issues() {
228 // The property the whole `#n` cross-link scheme rests on: one sequence, so a
229 // number is never handed out twice on a repository.
230 let f = fixture().await;
231
232 let first = open(&f, "steid", "spike", "main").await;
233 let second = open(&f, "steid", "spike", "main").await;
234
235 assert_eq!(first.number, IssueNumber::from_trusted(1));
236 assert_eq!(second.number, IssueNumber::from_trusted(2));
237
238 let issue = crate::application::create_issue(
239 &f.owner,
240 &f.handle,
241 &repo("steid"),
242 &crate::application::NewIssue {
243 title: "an issue".to_owned(),
244 body: String::new(),
245 },
246 at(2_000),
247 &Stores::new(&f.orgs, &f.memberships, &f.repos),
248 &f.issues,
249 )
250 .await
251 .expect("should open");
252
253 assert_eq!(issue.number, IssueNumber::from_trusted(3));
254}
255
256#[tokio::test]
257async fn a_branch_that_is_not_there_is_refused_on_the_form() {
258 let f = fixture().await;
259 // Everything resolves except what is named here, because the fake resolves any
260 // revision by default — so this asks for a repository with no branches at all.
261 let empty = InMemoryGitQuery::empty();
262
263 let refused = create_pull(
264 &f.owner,
265 &f.handle,
266 &repo("steid"),
267 &NewPull {
268 title: "Merge a ghost".to_owned(),
269 body: String::new(),
270 source: rev("ghost"),
271 target: rev("main"),
272 },
273 at(2_000),
274 &Stores::new(&f.orgs, &f.memberships, &f.repos),
275 &f.pulls,
276 &empty,
277 )
278 .await;
279
280 assert!(matches!(
281 refused,
282 Err(Error::Domain(DomainError::Validation { .. }))
283 ));
284}
285
286#[tokio::test]
287async fn only_the_owner_may_open_one() {
288 let f = fixture().await;
289 let git = three_way();
290
291 for (actor, expected_forbidden) in [(&f.member, true), (&f.stranger, true)] {
292 let refused = create_pull(
293 actor,
294 &f.handle,
295 &repo("steid"),
296 &NewPull {
297 title: "Merge spike".to_owned(),
298 body: String::new(),
299 source: rev("spike"),
300 target: rev("main"),
301 },
302 at(2_000),
303 &Stores::new(&f.orgs, &f.memberships, &f.repos),
304 &f.pulls,
305 &git,
306 )
307 .await;
308
309 assert_eq!(
310 matches!(refused, Err(Error::Domain(DomainError::Forbidden))),
311 expected_forbidden,
312 "a public repository is visible, so the refusal names permission"
313 );
314 }
315}
316
317#[tokio::test]
318async fn a_stranger_sees_nothing_on_a_private_repository() {
319 let f = fixture().await;
320 let git = three_way();
321 let pull = open(&f, "secret", "spike", "main").await;
322
323 assert!(
324 viewed(&f, &f.stranger, "secret", pull.number, &git)
325 .await
326 .is_none()
327 );
328 assert!(
329 list_pulls(
330 &f.stranger,
331 &f.handle,
332 &repo("secret"),
333 PullRequestState::Open,
334 None,
335 1,
336 &Stores::new(&f.orgs, &f.memberships, &f.repos),
337 &f.pulls,
338 &f.users,
339 &f.labels,
340 )
341 .await
342 .expect("should list")
343 .is_none()
344 );
345}
346
347// --- mergeability -------------------------------------------------------------------
348
349#[tokio::test]
350async fn a_three_way_merge_that_works_offers_a_merge_commit_and_a_squash() {
351 let f = fixture().await;
352 let git = three_way();
353 let pull = open_with(&f, "steid", "spike", "main", &git).await;
354
355 let detail = viewed(&f, &f.owner, "steid", pull.number, &git)
356 .await
357 .expect("visible");
358
359 assert_eq!(
360 detail.merge,
361 Mergeability::Ready {
362 fast_forward: false
363 }
364 );
365 assert_eq!(
366 detail.merge.strategies(),
367 vec![MergeStrategy::Merge, MergeStrategy::Squash],
368 "a fast-forward is not on offer when the target is not an ancestor"
369 );
370}
371
372#[tokio::test]
373async fn a_target_that_is_an_ancestor_leads_with_the_fast_forward() {
374 let f = fixture().await;
375 // The merge base *is* the target, so the target can simply catch up.
376 let git = InMemoryGitQuery::new()
377 .with_resolved(&heads("main"), "a")
378 .with_resolved(&heads("spike"), "b")
379 .with_merge_base(&oid("a").to_string());
380 let pull = open_with(&f, "steid", "spike", "main", &git).await;
381
382 let detail = viewed(&f, &f.owner, "steid", pull.number, &git)
383 .await
384 .expect("visible");
385
386 assert_eq!(detail.merge, Mergeability::Ready { fast_forward: true });
387 assert_eq!(
388 detail.merge.strategies().first(),
389 Some(&MergeStrategy::FastForward),
390 "the exact answer is offered first"
391 );
392}
393
394#[tokio::test]
395async fn a_source_already_contained_in_the_target_has_nothing_to_merge() {
396 let f = fixture().await;
397 let git = InMemoryGitQuery::new()
398 .with_resolved(&heads("main"), "a")
399 .with_resolved(&heads("spike"), "b")
400 .with_merge_base(&oid("b").to_string());
401 let pull = open_with(&f, "steid", "spike", "main", &git).await;
402
403 let detail = viewed(&f, &f.owner, "steid", pull.number, &git)
404 .await
405 .expect("visible");
406
407 assert_eq!(detail.merge, Mergeability::NothingToMerge);
408 assert!(detail.merge.strategies().is_empty());
409}
410
411#[tokio::test]
412async fn a_conflict_names_the_files_and_offers_nothing() {
413 let f = fixture().await;
414 let git = three_way().with_conflicts(&["src/lib.rs", "Cargo.toml"]);
415 let pull = open_with(&f, "steid", "spike", "main", &git).await;
416
417 let detail = viewed(&f, &f.owner, "steid", pull.number, &git)
418 .await
419 .expect("visible");
420
421 assert_eq!(
422 detail.merge,
423 Mergeability::Conflicted {
424 paths: vec!["src/lib.rs".to_owned(), "Cargo.toml".to_owned()],
425 }
426 );
427 assert!(detail.merge.strategies().is_empty());
428}
429
430#[tokio::test]
431async fn a_deleted_source_branch_is_named_rather_than_reported_as_a_conflict() {
432 // A force-push is fine — the request follows the branch — but a *deleted*
433 // branch is a state the page has to say out loud.
434 let f = fixture().await;
435 let git = three_way();
436 let pull = open_with(&f, "steid", "spike", "main", &git).await;
437
438 let gone = InMemoryGitQuery::empty().with_resolved(&heads("main"), "a");
439 let detail = viewed(&f, &f.owner, "steid", pull.number, &gone)
440 .await
441 .expect("visible");
442
443 assert_eq!(
444 detail.merge,
445 Mergeability::MissingBranch {
446 branch: rev("spike")
447 }
448 );
449}
450
451#[tokio::test]
452async fn an_ordinary_source_branch_is_offered_for_deletion() {
453 let f = fixture().await;
454 let git = three_way();
455 let pull = open_with(&f, "steid", "spike", "main", &git).await;
456
457 let detail = viewed(&f, &f.owner, "steid", pull.number, &git)
458 .await
459 .expect("visible");
460
461 assert!(!detail.source_is_default);
462}
463
464#[tokio::test]
465async fn merging_the_default_branch_does_not_propose_deleting_it() {
466 // `main` into a long-lived branch is a real thing to do, and the tidy default
467 // would delete the branch everybody clones.
468 let f = fixture().await;
469 let git = InMemoryGitQuery::new()
470 .with_resolved(&heads("main"), "a")
471 .with_resolved(&heads("release"), "b")
472 .with_merge_base(&oid("c").to_string());
473 let pull = open_with(&f, "steid", "main", "release", &git).await;
474
475 let detail = viewed(&f, &f.owner, "steid", pull.number, &git)
476 .await
477 .expect("visible");
478
479 assert!(!detail.merge.strategies().is_empty());
480 assert!(detail.source_is_default);
481}
482
483#[tokio::test]
484async fn an_old_git_says_so_rather_than_offering_a_button() {
485 let f = fixture().await;
486 let git = three_way();
487 let pull = open_with(&f, "steid", "spike", "main", &git).await;
488
489 let detail = view_pull(
490 &f.owner,
491 &f.handle,
492 &repo("steid"),
493 pull.number,
494 false,
495 &Stores::new(&f.orgs, &f.memberships, &f.repos),
496 &f.pulls,
497 &f.users,
498 &git,
499 &f.labels,
500 )
501 .await
502 .expect("should read")
503 .expect("visible");
504
505 assert_eq!(detail.merge, Mergeability::Unsupported);
506 assert!(detail.merge.strategies().is_empty());
507}
508
509#[tokio::test]
510async fn a_merged_request_asks_git_nothing() {
511 let f = fixture().await;
512 let git = three_way();
513 let merges = InMemoryGitMerge::writing("f");
514 let pull = open_with(&f, "steid", "spike", "main", &git).await;
515
516 merge(
517 &f,
518 &f.owner,
519 "steid",
520 pull.number,
521 MergeStrategy::Merge,
522 false,
523 &git,
524 &merges,
525 )
526 .await
527 .expect("should merge");
528
529 let detail = viewed(&f, &f.owner, "steid", pull.number, &git)
530 .await
531 .expect("visible");
532
533 assert_eq!(detail.merge, Mergeability::NotOpen);
534 assert_eq!(detail.state, PullRequestState::Merged);
535}
536
537// --- merging ---------------------------------------------------------------------
538
539/// Opens an issue, so a pull request has something to close.
540async fn open_issue(f: &Fixture, name: &str, title: &str) -> crate::domain::Issue {
541 crate::application::create_issue(
542 &f.owner,
543 &f.handle,
544 &repo(name),
545 &crate::application::NewIssue {
546 title: title.to_owned(),
547 body: String::new(),
548 },
549 at(1_000),
550 &Stores::new(&f.orgs, &f.memberships, &f.repos),
551 &f.issues,
552 )
553 .await
554 .expect("should open")
555}
556
557async fn issue_state(f: &Fixture, name: &str, number: IssueNumber) -> crate::domain::IssueState {
558 let repository = f
559 .repos
560 .find_by_org_and_name(
561 &f.orgs
562 .find_by_name(&f.handle)
563 .await
564 .expect("query")
565 .expect("org")
566 .id,
567 &repo(name),
568 )
569 .await
570 .expect("query")
571 .expect("repo");
572
573 f.issues
574 .find_by_number(&repository.id, number)
575 .await
576 .expect("query")
577 .expect("issue")
578 .state
579}
580
581#[tokio::test]
582async fn merging_closes_the_issues_the_request_said_it_closes() {
583 let f = fixture().await;
584 let git = three_way();
585 let merges = InMemoryGitMerge::writing("f");
586
587 let bug = open_issue(&f, "steid", "Blame is slow").await;
588 let other = open_issue(&f, "steid", "Docs are thin").await;
589
590 let pull = create_pull(
591 &f.owner,
592 &f.handle,
593 &repo("steid"),
594 &NewPull {
595 title: format!("Speed blame up (closes #{})", bug.number),
596 // A bare mention is not a closure, which is what keeps `#n` usable for
597 // pointing at context.
598 body: format!("See #{} for the background.", other.number),
599 source: rev("spike"),
600 target: rev("main"),
601 },
602 at(2_000),
603 &Stores::new(&f.orgs, &f.memberships, &f.repos),
604 &f.pulls,
605 &git,
606 )
607 .await
608 .expect("should open");
609
610 let merged = merge(
611 &f,
612 &f.owner,
613 "steid",
614 pull.number,
615 MergeStrategy::Merge,
616 false,
617 &git,
618 &merges,
619 )
620 .await
621 .expect("should merge");
622
623 assert_eq!(merged.closed, vec![bug.number]);
624 assert_eq!(
625 issue_state(&f, "steid", bug.number).await,
626 crate::domain::IssueState::Closed
627 );
628 assert_eq!(
629 issue_state(&f, "steid", other.number).await,
630 crate::domain::IssueState::Open,
631 "a mention is not a closure"
632 );
633
634 // The issue says what closed it, and links the request by number.
635 let comments = f
636 .issues
637 .list_comments(&bug.id)
638 .await
639 .expect("should read comments");
640 assert_eq!(comments.len(), 1);
641 assert!(
642 comments[0]
643 .body
644 .contains(&format!("pull request #{}", pull.number)),
645 "got {:?}",
646 comments[0].body
647 );
648}
649
650#[tokio::test]
651async fn a_closing_reference_to_a_pull_request_closes_nothing() {
652 // The numbers are one sequence, so `#7` may be either kind — and closing a
653 // *request* because another merged is not what anybody means by the word.
654 let f = fixture().await;
655 let git = three_way();
656 let merges = InMemoryGitMerge::writing("f");
657
658 let first = open_with(&f, "steid", "spike", "main", &git).await;
659 let second = create_pull(
660 &f.owner,
661 &f.handle,
662 &repo("steid"),
663 &NewPull {
664 title: "Follow-up".to_owned(),
665 body: format!("Closes #{}.", first.number),
666 source: rev("tidy"),
667 target: rev("main"),
668 },
669 at(2_000),
670 &Stores::new(&f.orgs, &f.memberships, &f.repos),
671 &f.pulls,
672 &git,
673 )
674 .await
675 .expect("should open");
676
677 let merged = merge(
678 &f,
679 &f.owner,
680 "steid",
681 second.number,
682 MergeStrategy::Merge,
683 false,
684 &git,
685 &merges,
686 )
687 .await
688 .expect("should merge");
689
690 assert!(merged.closed.is_empty());
691 assert!(
692 f.pulls
693 .find_by_number(&second.repo_id, first.number)
694 .await
695 .expect("query")
696 .expect("still there")
697 .state
698 .is_open()
699 );
700}
701
702#[tokio::test]
703async fn an_issue_that_is_already_closed_is_left_alone() {
704 let f = fixture().await;
705 let git = three_way();
706 let merges = InMemoryGitMerge::writing("f");
707
708 let bug = open_issue(&f, "steid", "Blame is slow").await;
709
710 crate::application::set_issue_state(
711 &f.owner,
712 &f.handle,
713 &repo("steid"),
714 bug.number,
715 crate::domain::IssueState::Closed,
716 at(2_500),
717 &Stores::new(&f.orgs, &f.memberships, &f.repos),
718 &f.issues,
719 )
720 .await
721 .expect("should close");
722
723 let pull = create_pull(
724 &f.owner,
725 &f.handle,
726 &repo("steid"),
727 &NewPull {
728 title: "Speed blame up".to_owned(),
729 body: format!("Fixes #{}.", bug.number),
730 source: rev("spike"),
731 target: rev("main"),
732 },
733 at(3_000),
734 &Stores::new(&f.orgs, &f.memberships, &f.repos),
735 &f.pulls,
736 &git,
737 )
738 .await
739 .expect("should open");
740
741 let merged = merge(
742 &f,
743 &f.owner,
744 "steid",
745 pull.number,
746 MergeStrategy::Merge,
747 false,
748 &git,
749 &merges,
750 )
751 .await
752 .expect("should merge");
753
754 // No second announcement on something that was already shut.
755 assert!(merged.closed.is_empty());
756 assert!(
757 f.issues
758 .list_comments(&bug.id)
759 .await
760 .expect("comments")
761 .is_empty()
762 );
763}
764
765#[tokio::test]
766async fn a_merge_commit_takes_the_target_first_and_moves_the_branch() {
767 let f = fixture().await;
768 let git = three_way();
769 let merges = InMemoryGitMerge::writing("f");
770 let pull = open_with(&f, "steid", "spike", "main", &git).await;
771
772 let merged = merge(
773 &f,
774 &f.owner,
775 "steid",
776 pull.number,
777 MergeStrategy::Merge,
778 false,
779 &git,
780 &merges,
781 )
782 .await
783 .expect("should merge");
784
785 let written = merges.commits();
786 assert_eq!(written.len(), 1);
787 assert_eq!(
788 written[0].parents,
789 vec![oid("a"), oid("b")],
790 "the target is the first parent, so its history stays the first-parent line"
791 );
792 assert!(
793 written[0]
794 .message
795 .starts_with("Merge pull request #1 from spike")
796 );
797 assert_eq!(written[0].author_name, "Ada Lovelace");
798
799 // The ref moved to what was written, guarded on where it was.
800 let updates = merges.updates();
801 assert_eq!(updates.len(), 1);
802 assert_eq!(updates[0].branch, rev("main"));
803 assert_eq!(updates[0].old, oid("a"));
804 assert_eq!(updates[0].new, Some(merged.commit.clone()));
805 assert!(!merged.deleted_source);
806}
807
808#[tokio::test]
809async fn a_squash_has_one_parent_and_lists_what_it_squashed() {
810 let f = fixture().await;
811 let git = three_way().with_log(vec![
812 crate::domain::CommitSummary {
813 id: oid("b"),
814 summary: "second thing".to_owned(),
815 author_name: "Ada".to_owned(),
816 committed_at: at(3_000),
817 },
818 crate::domain::CommitSummary {
819 id: oid("c"),
820 summary: "first thing".to_owned(),
821 author_name: "Ada".to_owned(),
822 committed_at: at(2_500),
823 },
824 ]);
825 let merges = InMemoryGitMerge::writing("f");
826 let pull = open_with(&f, "steid", "spike", "main", &git).await;
827
828 merge(
829 &f,
830 &f.owner,
831 "steid",
832 pull.number,
833 MergeStrategy::Squash,
834 false,
835 &git,
836 &merges,
837 )
838 .await
839 .expect("should merge");
840
841 let written = merges.commits();
842 assert_eq!(written[0].parents, vec![oid("a")], "one parent, not two");
843 assert!(written[0].message.starts_with("Merge spike (#1)"));
844 assert!(
845 written[0].message.contains("* second thing")
846 && written[0].message.contains("* first thing"),
847 "the squashed subjects are the only surviving record of them: {:?}",
848 written[0].message
849 );
850}
851
852#[tokio::test]
853async fn a_fast_forward_writes_no_commit_at_all() {
854 let f = fixture().await;
855 let git = InMemoryGitQuery::new()
856 .with_resolved(&heads("main"), "a")
857 .with_resolved(&heads("spike"), "b")
858 .with_merge_base(&oid("a").to_string());
859 let merges = InMemoryGitMerge::new();
860 let pull = open_with(&f, "steid", "spike", "main", &git).await;
861
862 let merged = merge(
863 &f,
864 &f.owner,
865 "steid",
866 pull.number,
867 MergeStrategy::FastForward,
868 false,
869 &git,
870 &merges,
871 )
872 .await
873 .expect("should merge");
874
875 assert!(merges.commits().is_empty(), "nothing to write");
876 assert_eq!(
877 merged.commit,
878 oid("b"),
879 "the target lands on the source's own tip"
880 );
881}
882
883#[tokio::test]
884async fn a_fast_forward_is_refused_where_it_would_not_be_exact() {
885 let f = fixture().await;
886 let git = three_way();
887 let merges = InMemoryGitMerge::new();
888 let pull = open_with(&f, "steid", "spike", "main", &git).await;
889
890 let refused = merge(
891 &f,
892 &f.owner,
893 "steid",
894 pull.number,
895 MergeStrategy::FastForward,
896 false,
897 &git,
898 &merges,
899 )
900 .await;
901
902 assert!(matches!(
903 refused,
904 Err(Error::Domain(DomainError::Validation { .. }))
905 ));
906 assert!(merges.updates().is_empty(), "nothing moved");
907}
908
909#[tokio::test]
910async fn deleting_the_source_happens_after_the_target_has_moved() {
911 // The order matters: a guard that refuses the move must not cost somebody their
912 // branch.
913 let f = fixture().await;
914 let git = three_way();
915 let merges = InMemoryGitMerge::writing("f");
916 let pull = open_with(&f, "steid", "spike", "main", &git).await;
917
918 let merged = merge(
919 &f,
920 &f.owner,
921 "steid",
922 pull.number,
923 MergeStrategy::Merge,
924 true,
925 &git,
926 &merges,
927 )
928 .await
929 .expect("should merge");
930
931 let updates = merges.updates();
932 assert_eq!(updates.len(), 2);
933 assert_eq!(updates[0].branch, rev("main"), "the target moves first");
934 assert_eq!(updates[1].branch, rev("spike"));
935 assert_eq!(updates[1].new, None, "the source is deleted");
936 assert!(merged.deleted_source);
937}
938
939#[tokio::test]
940async fn a_merge_records_a_push_event_for_every_ref_it_moved() {
941 // Through `record_push`, the function the git transport calls, so a merge and a
942 // push produce the same events and reach the same webhooks.
943 let f = fixture().await;
944 let git = three_way();
945 let merges = InMemoryGitMerge::writing("f");
946 let pull = open_with(&f, "steid", "spike", "main", &git).await;
947
948 let merged = merge(
949 &f,
950 &f.owner,
951 "steid",
952 pull.number,
953 MergeStrategy::Merge,
954 true,
955 &git,
956 &merges,
957 )
958 .await
959 .expect("should merge");
960
961 let names: Vec<&str> = merged
962 .events
963 .iter()
964 .map(|event| event.ref_name.as_str())
965 .collect();
966 assert_eq!(names, vec!["refs/heads/main", "refs/heads/spike"]);
967
968 let target = &merged.events[0];
969 assert_eq!(target.before, oid("a"));
970 assert_eq!(target.after, merged.commit);
971
972 let source = &merged.events[1];
973 assert_eq!(source.before, oid("b"));
974 assert!(
975 crate::domain::is_zero(&source.after),
976 "a deleted branch is recorded as a deletion"
977 );
978}
979
980#[tokio::test]
981async fn a_branch_that_moved_since_the_page_loaded_makes_the_merge_lose_cleanly() {
982 let f = fixture().await;
983 let git = three_way();
984 let merges = InMemoryGitMerge::stale();
985 let pull = open_with(&f, "steid", "spike", "main", &git).await;
986
987 let refused = merge(
988 &f,
989 &f.owner,
990 "steid",
991 pull.number,
992 MergeStrategy::Merge,
993 false,
994 &git,
995 &merges,
996 )
997 .await;
998
999 assert!(
1000 matches!(refused, Err(Error::GitMerge(error)) if error.is_stale()),
1001 "expected staleness"
1002 );
1003
1004 // Still open, so the page can be reloaded and the merge tried again.
1005 let detail = viewed(&f, &f.owner, "steid", pull.number, &git)
1006 .await
1007 .expect("visible");
1008 assert_eq!(detail.state, PullRequestState::Open);
1009}
1010
1011#[tokio::test]
1012async fn merging_a_conflicted_request_is_refused_before_anything_is_written() {
1013 let f = fixture().await;
1014 let git = three_way();
1015 let pull = open_with(&f, "steid", "spike", "main", &git).await;
1016
1017 // Conflicting only now, which is exactly the case the recheck exists for: the
1018 // page that offered the button was rendered before the conflict arrived.
1019 let conflicted = three_way().with_conflicts(&["src/lib.rs"]);
1020 let merges = InMemoryGitMerge::new();
1021
1022 let refused = merge(
1023 &f,
1024 &f.owner,
1025 "steid",
1026 pull.number,
1027 MergeStrategy::Merge,
1028 false,
1029 &conflicted,
1030 &merges,
1031 )
1032 .await;
1033
1034 assert!(matches!(
1035 refused,
1036 Err(Error::Domain(DomainError::Validation { .. }))
1037 ));
1038 assert!(merges.commits().is_empty());
1039 assert!(merges.updates().is_empty());
1040}
1041
1042#[tokio::test]
1043async fn merging_twice_lands_one_commit() {
1044 let f = fixture().await;
1045 let git = three_way();
1046 let merges = InMemoryGitMerge::writing("f");
1047 let pull = open_with(&f, "steid", "spike", "main", &git).await;
1048
1049 merge(
1050 &f,
1051 &f.owner,
1052 "steid",
1053 pull.number,
1054 MergeStrategy::Merge,
1055 false,
1056 &git,
1057 &merges,
1058 )
1059 .await
1060 .expect("should merge");
1061
1062 let again = merge(
1063 &f,
1064 &f.owner,
1065 "steid",
1066 pull.number,
1067 MergeStrategy::Merge,
1068 false,
1069 &git,
1070 &merges,
1071 )
1072 .await;
1073
1074 assert!(matches!(
1075 again,
1076 Err(Error::Domain(DomainError::Validation { .. }))
1077 ));
1078 assert_eq!(merges.commits().len(), 1, "the second submit wrote nothing");
1079}
1080
1081#[tokio::test]
1082async fn an_archived_repository_refuses_a_merge_and_keeps_its_conversation() {
1083 let f = fixture().await;
1084 let git = three_way();
1085 let merges = InMemoryGitMerge::writing("f");
1086 let pull = open_with(&f, "steid", "spike", "main", &git).await;
1087
1088 let org = f
1089 .orgs
1090 .find_by_name(&f.handle)
1091 .await
1092 .expect("should read")
1093 .expect("the org exists");
1094 let mut stored = f
1095 .repos
1096 .find_by_org_and_name(&org.id, &repo("steid"))
1097 .await
1098 .expect("should read")
1099 .expect("the repository exists");
1100 stored.archived = true;
1101 f.repos.save(&stored).await.expect("save repo");
1102
1103 let refused = merge(
1104 &f,
1105 &f.owner,
1106 "steid",
1107 pull.number,
1108 MergeStrategy::Merge,
1109 false,
1110 &git,
1111 &merges,
1112 )
1113 .await;
1114
1115 assert!(
1116 matches!(refused, Err(Error::Domain(DomainError::Archived))),
1117 "the same refusal a push gets"
1118 );
1119 assert!(merges.commits().is_empty());
1120
1121 // The conversation stays open — archiving says the code is finished, not that
1122 // nobody may discuss it.
1123 comment_on_pull(
1124 &f.owner,
1125 &f.handle,
1126 &repo("steid"),
1127 pull.number,
1128 "still worth doing",
1129 at(6_000),
1130 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1131 &f.pulls,
1132 )
1133 .await
1134 .expect("commenting is not a write to the contents");
1135}
1136
1137#[tokio::test]
1138async fn only_the_owner_may_merge() {
1139 let f = fixture().await;
1140 let git = three_way();
1141 let merges = InMemoryGitMerge::writing("f");
1142 let pull = open_with(&f, "steid", "spike", "main", &git).await;
1143
1144 for actor in [&f.member, &f.stranger] {
1145 let refused = merge(
1146 &f,
1147 actor,
1148 "steid",
1149 pull.number,
1150 MergeStrategy::Merge,
1151 false,
1152 &git,
1153 &merges,
1154 )
1155 .await;
1156
1157 assert!(matches!(
1158 refused,
1159 Err(Error::Domain(DomainError::Forbidden))
1160 ));
1161 }
1162
1163 assert!(merges.updates().is_empty());
1164}
1165
1166// --- state, and the shared numbering -------------------------------------------------
1167
1168#[tokio::test]
1169async fn a_request_cannot_be_marked_merged_by_the_state_form() {
1170 let f = fixture().await;
1171 let git = three_way();
1172 let pull = open_with(&f, "steid", "spike", "main", &git).await;
1173
1174 let refused = set_pull_state(
1175 &f.owner,
1176 &f.handle,
1177 &repo("steid"),
1178 pull.number,
1179 PullRequestState::Merged,
1180 at(6_000),
1181 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1182 &f.pulls,
1183 )
1184 .await;
1185
1186 assert!(matches!(
1187 refused,
1188 Err(Error::Domain(DomainError::Validation { .. }))
1189 ));
1190}
1191
1192#[tokio::test]
1193async fn the_counts_move_between_the_three_states() {
1194 let f = fixture().await;
1195 let git = three_way();
1196 let merges = InMemoryGitMerge::writing("f");
1197
1198 let merged = open_with(&f, "steid", "spike", "main", &git).await;
1199 let closed = open_with(&f, "steid", "other", "main", &git).await;
1200 open_with(&f, "steid", "third", "main", &git).await;
1201
1202 merge(
1203 &f,
1204 &f.owner,
1205 "steid",
1206 merged.number,
1207 MergeStrategy::Merge,
1208 false,
1209 &git,
1210 &merges,
1211 )
1212 .await
1213 .expect("should merge");
1214
1215 set_pull_state(
1216 &f.owner,
1217 &f.handle,
1218 &repo("steid"),
1219 closed.number,
1220 PullRequestState::Closed,
1221 at(6_000),
1222 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1223 &f.pulls,
1224 )
1225 .await
1226 .expect("should close");
1227
1228 let listed = list_pulls(
1229 &f.owner,
1230 &f.handle,
1231 &repo("steid"),
1232 PullRequestState::Open,
1233 None,
1234 1,
1235 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1236 &f.pulls,
1237 &f.users,
1238 &f.labels,
1239 )
1240 .await
1241 .expect("should list")
1242 .expect("visible");
1243
1244 assert_eq!((listed.open, listed.merged, listed.closed), (1, 1, 1));
1245 assert_eq!(listed.pulls.items.len(), 1);
1246}
1247
1248#[tokio::test]
1249async fn the_second_page_of_pull_requests_starts_where_the_first_ended() {
1250 let f = fixture().await;
1251 let git = three_way();
1252
1253 for n in 1..=(PULL_LIMIT + 3) {
1254 open_with(&f, "steid", &format!("spike-{n}"), "main", &git).await;
1255 }
1256
1257 // Built inline rather than through a closure: `repo("steid")` is a temporary,
1258 // and a closure returning the future would let the borrow outlive it.
1259 let name = repo("steid");
1260 let first = list_pulls(
1261 &f.owner,
1262 &f.handle,
1263 &name,
1264 PullRequestState::Open,
1265 None,
1266 1,
1267 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1268 &f.pulls,
1269 &f.users,
1270 &f.labels,
1271 )
1272 .await
1273 .expect("should list")
1274 .expect("visible");
1275 let second = list_pulls(
1276 &f.owner,
1277 &f.handle,
1278 &name,
1279 PullRequestState::Open,
1280 None,
1281 2,
1282 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1283 &f.pulls,
1284 &f.users,
1285 &f.labels,
1286 )
1287 .await
1288 .expect("should list")
1289 .expect("visible");
1290
1291 assert_eq!(first.pulls.items.len(), PULL_LIMIT);
1292 assert_eq!(second.pulls.items.len(), 3);
1293 assert_eq!(second.pulls.offset(), PULL_LIMIT);
1294 // Newest first, which for a monotonic sequence is descending number.
1295 assert_eq!(
1296 first.pulls.items[0].number,
1297 IssueNumber::from_trusted((PULL_LIMIT + 3) as u64)
1298 );
1299}
1300
1301#[tokio::test]
1302async fn a_number_says_which_kind_of_thing_it_is() {
1303 // What lets `/issues/7` and `/pulls/7` send a visitor to the other rather than
1304 // 404ing at a `#7` somebody wrote in a body.
1305 let f = fixture().await;
1306 let git = three_way();
1307 let pull = open_with(&f, "steid", "spike", "main", &git).await;
1308
1309 let issue = crate::application::create_issue(
1310 &f.owner,
1311 &f.handle,
1312 &repo("steid"),
1313 &crate::application::NewIssue {
1314 title: "an issue".to_owned(),
1315 body: String::new(),
1316 },
1317 at(2_000),
1318 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1319 &f.issues,
1320 )
1321 .await
1322 .expect("should open");
1323
1324 let name = repo("steid");
1325 let kind = async |number| {
1326 numbered_thing(
1327 &f.owner,
1328 &f.handle,
1329 &name,
1330 number,
1331 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1332 &f.issues,
1333 &f.pulls,
1334 )
1335 .await
1336 .expect("should read")
1337 };
1338
1339 assert_eq!(kind(pull.number).await, Numbered::PullRequest);
1340 assert_eq!(kind(issue.number).await, Numbered::Issue);
1341 assert_eq!(kind(IssueNumber::from_trusted(99)).await, Numbered::Neither);
1342}
1343
1344#[tokio::test]
1345async fn a_stranger_is_told_a_private_repository_has_neither() {
1346 // Not "no such number" versus "there is one" — that would count the issues on a
1347 // repository they cannot see.
1348 let f = fixture().await;
1349 let git = three_way();
1350 let pull = open_with(&f, "secret", "spike", "main", &git).await;
1351
1352 assert_eq!(
1353 numbered_thing(
1354 &f.stranger,
1355 &f.handle,
1356 &repo("secret"),
1357 pull.number,
1358 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1359 &f.issues,
1360 &f.pulls,
1361 )
1362 .await
1363 .expect("should read"),
1364 Numbered::Neither
1365 );
1366}
1367
1368#[tokio::test]
1369async fn a_comment_moves_the_request_without_changing_what_it_says() {
1370 let f = fixture().await;
1371 let git = three_way();
1372 let pull = open_with(&f, "steid", "spike", "main", &git).await;
1373
1374 comment_on_pull(
1375 &f.owner,
1376 &f.handle,
1377 &repo("steid"),
1378 pull.number,
1379 " looks good ",
1380 at(6_000),
1381 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1382 &f.pulls,
1383 )
1384 .await
1385 .expect("should comment");
1386
1387 let detail = viewed(&f, &f.owner, "steid", pull.number, &git)
1388 .await
1389 .expect("visible");
1390
1391 assert_eq!(detail.comments.len(), 1);
1392 assert_eq!(detail.comments[0].body, "looks good");
1393 assert_eq!(detail.title, pull.title);
1394}
1395
1396#[tokio::test]
1397async fn an_edit_cannot_move_the_branches() {
1398 let f = fixture().await;
1399 let git = three_way();
1400 let pull = open_with(&f, "steid", "spike", "main", &git).await;
1401
1402 edit_pull(
1403 &f.owner,
1404 &f.handle,
1405 &repo("steid"),
1406 pull.number,
1407 &PullEdit {
1408 title: "A better title".to_owned(),
1409 body: "and a better body".to_owned(),
1410 },
1411 at(6_000),
1412 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1413 &f.pulls,
1414 )
1415 .await
1416 .expect("should edit");
1417
1418 let detail = viewed(&f, &f.owner, "steid", pull.number, &git)
1419 .await
1420 .expect("visible");
1421
1422 assert_eq!(detail.title, "A better title");
1423 assert_eq!(detail.source_branch, rev("spike"));
1424 assert_eq!(detail.target_branch, rev("main"));
1425}
1426
1427// --- the suggested title -----------------------------------------------------------
1428
1429#[tokio::test]
1430async fn a_branch_with_one_commit_lends_the_form_its_subject() {
1431 // One commit is the case where a subject is already a title somebody wrote for
1432 // this change.
1433 let f = fixture().await;
1434 let git = three_way().with_log(vec![crate::domain::CommitSummary {
1435 id: oid("b"),
1436 summary: "perf: cache highlighting per blob object id".to_owned(),
1437 author_name: "Ada".to_owned(),
1438 committed_at: at(3_000),
1439 }]);
1440
1441 assert_eq!(
1442 suggested_title(
1443 &f.owner,
1444 &f.handle,
1445 &repo("steid"),
1446 &rev("spike"),
1447 &rev("main"),
1448 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1449 &git,
1450 )
1451 .await
1452 .expect("should read"),
1453 Some("perf: cache highlighting per blob object id".to_owned())
1454 );
1455}
1456
1457#[tokio::test]
1458async fn a_branch_with_two_commits_suggests_nothing() {
1459 // Naming the change after whichever commit happened to be last is worse than an
1460 // empty field, because it looks deliberate.
1461 let f = fixture().await;
1462 let summary = |id: &str, text: &str| crate::domain::CommitSummary {
1463 id: oid(id),
1464 summary: text.to_owned(),
1465 author_name: "Ada".to_owned(),
1466 committed_at: at(3_000),
1467 };
1468 let git = three_way().with_log(vec![
1469 summary("b", "docs: note what the cache costs"),
1470 summary("c", "perf: cache highlighting per blob object id"),
1471 ]);
1472
1473 assert_eq!(
1474 suggested_title(
1475 &f.owner,
1476 &f.handle,
1477 &repo("steid"),
1478 &rev("spike"),
1479 &rev("main"),
1480 &Stores::new(&f.orgs, &f.memberships, &f.repos),
1481 &git,
1482 )
1483 .await
1484 .expect("should read"),
1485 None
1486 );
1487}