//! Branches that take a pull only once it is reviewed: approvals from //! those who may write, code owners from CODEOWNERS, standing requests for //! changes, and approvals gone stale, as `merge`, `merge --check` and the //! merge queue read them. use super::*; /// What `args` says on failing, as `who` runs them. fn refusal(who: &Sandbox, args: &[&str]) -> String { let (done, out, err) = who.knotless(args, b""); assert!(!done, "{args:?}: {out}"); err } #[test] fn protected_branches_take_reviewed_pulls() { // Started first, so its index sees the repositories made after. let server = gateway_with(None, true); let [mut owner, mut alice, mut bob, mut stranger] = people(); for who in [&mut owner, &mut alice, &mut bob, &mut stranger] { who.set("KNOTLESS_APPVIEW", &server.base); } let url = owner.url("reviewed"); let work = owner.work("w", 1); std::fs::create_dir_all(work.join(".knotless")).unwrap(); let a = &alice.account.handle; let codeowners = format!( "# Who owns what\n* {}\nweb/ @{a} @org/team @nobody.invalid\ndocs/ @org/team\nweb/vendor/\n", owner.account.did ); owner.commit_file(&work, ".knotless/CODEOWNERS", &codeowners); owner.git(&work, &["push", "-q", &url, "main"]); // Sharing for writing first delegates the owner's writes to the // appview, as `delegate` does, and says so; once is enough. for (n, who) in [&alice, &bob].into_iter().enumerate() { let share = ["repo", "share", &url, &who.account.handle, "--write"]; let (code, out) = owner.cli(&share, b""); assert_eq!(code, 0, "{out}"); let delegated = format!("delegated to {}\nshared", server.did); assert_eq!(out.contains(&delegated), n == 0, "{out}"); } // What a rule asks of reviews, as `rules` says it. let asks = [ "branch", "protect", &url, "main", "--reviews", "1", "--code-owners", ]; let (code, out) = owner.cli(&asks, b""); assert_eq!( (code, out.as_str()), (0, "main\tlinear\treviews 1\tcode owners") ); let rules = owner.record("dev.knotless.repo", "reviewed")["rules"].clone(); let reviews = json!({"approvals": 1, "codeOwners": true}); assert_eq!(rules[0]["reviews"], reviews); for bad in [["--reviews", "11"], ["--reviews", "x"]] { let args = [&["branch", "protect", &url, "main"][..], &bad[..]].concat(); assert_eq!(owner.cli(&args, b"").0, 1, "{args:?}"); } // Bob's pull changes a file of the owner's and one of alice's. let clone = bob.path("clone"); bob.git(bob.dir.path(), &["clone", "-q", &url, "clone"]); bob.git(&clone, &["checkout", "-q", "-b", "x"]); std::fs::create_dir_all(clone.join("web/vendor")).unwrap(); bob.commit_file(&clone, "web/a.css", "a\n"); bob.git(&clone, &["push", "-q", "origin", "x"]); bob.commit_file(&clone, "web/vendor/lib.js", "lib\n"); bob.commit_file(&clone, "b", "b\n"); bob.git(&clone, &["push", "-q", "origin", "x"]); let (code, pull) = bob.cli(&["pull", "create", &url, "main", &url, "x", "X"], b""); assert_eq!(code, 0, "{pull}"); wait_until(|| owner.cli(&["pull", "list", &url], b"").1.contains(&pull)); // Nobody has reviewed it: it lacks an approval and both owners. let owner_did = &owner.account.did; let checked = owner.cli(&["pull", "merge", &url, &pull, "--check"], b"").1; let lines: Vec<&str> = checked.lines().collect(); assert_eq!(lines[0], "blocked\tsquash\tchecks none\treviews blocked"); assert_eq!(lines[1], "missing\treviews 0 of 1"); assert_eq!(lines[2], format!("missing\towners of *: {owner_did}")); assert_eq!(lines[3], format!("missing\towners of web/: @{a}")); for skipped in [ "warning\t.knotless/CODEOWNERS line 3: @org/team is a team, and knotless has no teams; skipped", "warning\t.knotless/CODEOWNERS line 3: @nobody.invalid does not resolve to an account; skipped", "warning\t.knotless/CODEOWNERS line 4: docs/ has none of its owners left; skipped", ] { assert!(checked.contains(skipped), "{checked}"); } assert!( checked.ends_with("the owner, its author or not, merges past it with `--force`"), "{checked}" ); let err = refusal(&bob, &["pull", "merge", &url, &pull]); assert!( err.contains(&format!( "{pull} is not reviewed as main asks: reviews 0 of 1; owners of *" )), "{err}" ); // The owner's own pull of what they alone own can never have a code // owner's approval, as an author's never counts: its check says so. owner.git(&work, &["checkout", "-q", "-b", "own"]); owner.commit_file(&work, "o", "o\n"); owner.git(&work, &["push", "-q", &url, "own"]); let (code, own) = owner.cli(&["pull", "create", &url, "main", &url, "own", "Own"], b""); assert_eq!(code, 0, "{own}"); wait_until(|| owner.cli(&["pull", "list", &url], b"").1.contains(&own)); let checked = owner.cli(&["pull", "merge", &url, &own, "--check"], b"").1; let alone = format!( "missing\towners of *: {owner_did}, its author alone, whose own approval never counts, so * needs another owner in CODEOWNERS" ); assert!(checked.contains(&alone), "{checked}"); assert_eq!(owner.cli(&["pull", "close", &url, &own], b"").0, 0); // Past the client, the gateway holds bob to the same: his own squash // of the pull, a commit of his on main, and one through createCommit. bob.git(&clone, &["checkout", "-q", "-B", "own", "origin/main"]); bob.git(&clone, &["merge", "-q", "--squash", "x"]); bob.git(&clone, &["commit", "-q", "-m", "squashed"]); let err = refused(&bob, &clone, "own:main").unwrap(); let held = format!("protected branch: {pull} is not reviewed as main asks"); assert!(err.contains(&held), "{err}"); bob.commit_file(&clone, "d", "d\n"); let err = refused(&bob, &clone, "own:main").unwrap(); let plain = "protected branch: changes land on it only through reviewed pulls"; assert!(err.contains(plain), "{err}"); let committed = [ "repo", "commit", &url, "-b", "main", "-m", "drop f", "--delete", "f", ]; let err = refusal(&bob, &committed); assert!( err.contains("main takes changes only through reviewed pulls"), "{err}" ); bob.git(&clone, &["checkout", "-q", "x"]); // A stranger's approval counts for nothing; alice's for an approval // and for web/. let approve = ["comment", "add", &url, &pull, "fine", "--approve"]; assert_eq!(stranger.cli(&approve, b"").0, 0); assert_eq!(alice.cli(&approve, b"").0, 0); let only_owner = format!("missing\towners of *: {owner_did}"); // Its merge says why the stranger's does not count, once both // approvals are seen: alice's may be before the stranger's. let not = format!( "uncounted\t@{}'s approval does not count: only the owner's and collaborators' approvals count", stranger.account.handle ); wait_until(|| { let checked = owner.cli(&["pull", "merge", &url, &pull, "--check"], b"").1; !checked.contains("reviews 0 of 1") && checked.contains(&only_owner) && checked.contains(¬) }); let err = refusal(&bob, &["pull", "merge", &url, &pull]); assert!(err.contains(¬[10..]), "{err}"); // The owner asks for changes: that blocks it, by their handle. let changes = ["comment", "add", &url, &pull, "not yet", "--changes"]; assert_eq!(owner.cli(&changes, b"").0, 0); let asked = format!("missing\tchanges asked by @{}", owner.account.handle); wait_until(|| { owner .cli(&["pull", "merge", &url, &pull, "--check"], b"") .1 .contains(&asked) }); // Queued, it is turned away, saying why. let ci = &stranger.account.handle; assert_eq!(owner.cli(&["runner", "add", &url, ci], b"").0, 0); // Bob reads the reviews for himself, and may not have the owner's // yet: his refusal comes to name it. let on = [&asks[..], &["--queue"][..]].concat(); assert_eq!(owner.cli(&on, b"").0, 0); let queued = || refusal(&bob, &["pull", "merge", &url, &pull, "--queue"]); wait_until(|| queued().contains("changes asked by @")); let err = queued(); assert!(err.contains("Queue it again once it is"), "{err}"); // Once the owner approves, it merges, the queue off again. assert_eq!(owner.cli(&asks, b"").0, 0); assert_eq!(owner.cli(&approve, b"").0, 0); wait_until(|| { owner .cli(&["pull", "merge", &url, &pull, "--check"], b"") .1 .starts_with("clean\tsquash\tchecks none\treviews passed") }); // Only approvals of its head count, once the rule says so: a push // leaves it with none. let stale = [ "branch", "protect", &url, "main", "--reviews", "1", "--dismiss-stale", ]; let (code, out) = owner.cli(&stale, b""); assert_eq!( (code, out.as_str()), (0, "main\tlinear\treviews 1\tdismiss stale") ); bob.commit_file(&clone, "c", "c\n"); bob.git(&clone, &["push", "-q", "origin", "x"]); wait_until(|| { owner .cli(&["pull", "merge", &url, &pull, "--check"], b"") .1 .contains("missing\treviews 0 of 1") }); let err = refusal(&bob, &["pull", "merge", &url, &pull]); assert!(err.contains("reviews 0 of 1"), "{err}"); // Approved again, bob merges it, through the gateway. assert_eq!(alice.cli(&approve, b"").0, 0); assert_eq!(owner.cli(&approve, b"").0, 0); wait_until(|| { owner .cli(&["pull", "merge", &url, &pull, "--check"], b"") .1 .starts_with("clean") }); let (code, merged) = bob.cli(&["pull", "merge", &url, &pull], b""); assert_eq!(code, 0, "{merged}"); let (_, rules) = owner.cli(&["branch", "protect", &url, "main", "--undo"], b""); assert_eq!(rules, ""); }