michal/tit

Browse tree · Show commit · Download archive

Blob: tests/pull_requests.rs

Raw · Blame

#[allow(
    dead_code,
    reason = "the pull-request test uses only identity validation"
)]
#[path = "../src/auth.rs"]
mod auth;
#[path = "../src/domain/mod.rs"]
mod domain;
#[allow(
    dead_code,
    reason = "the pull-request test uses part of the shared Git API"
)]
#[path = "../src/git/mod.rs"]
mod git;
#[allow(dead_code, reason = "the pull-request test does not run maintenance")]
#[path = "../src/maintenance.rs"]
mod maintenance;
#[allow(
    dead_code,
    reason = "the pull-request test uses repository policy through Git"
)]
#[path = "../src/policy.rs"]
mod policy;
#[path = "../src/pull_request.rs"]
mod pull_request;
#[allow(dead_code, reason = "the pull-request test uses part of the store API")]
#[path = "../src/store/mod.rs"]
mod store;

use std::fs;
use std::os::unix::ffi::OsStringExt;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;

use git::read::{Mergeability, ReadCancellation, ReadError, ReadLimits, RepositoryReadService};
use git::repository::GitRepository;
use gix::hash::ObjectId;
use pull_request::{PullRequestError, PullRequestService};
use rusqlite::params;
use store::{GitOperationIntent, NewPullRequestMerge, NewPullRequestRefIntent, Store, StoreError};
use tempfile::TempDir;

#[test]
fn creates_revises_and_recovers_numbered_pull_request_refs_for_both_hashes() {
    for (index, object_format) in ["sha1", "sha256"].into_iter().enumerate() {
        let fixture = Fixture::new(object_format, index);
        let service = PullRequestService::new(&fixture.database, &fixture.repositories);
        let opened = service
            .open(
                "alice",
                "project",
                "alice",
                "Add the feature",
                "Keep the revision context.",
                "refs/heads/main",
                "refs/heads/feature",
            )
            .expect("open a pull request");
        assert_eq!(opened.number, 1);
        assert_eq!(fixture.pull_ref(1), opened.head_object_id);
        let comparison = service
            .compare("alice", "project", 1, None, None)
            .expect("compare the first revision");
        assert_eq!(comparison.detail.pull_request.number, 1);
        assert_eq!(comparison.revision.number, 1);
        assert_eq!(
            comparison.comparison.mergeability,
            Mergeability::FastForward
        );
        assert_eq!(comparison.comparison.commits.len(), 1);
        assert_eq!(comparison.comparison.changed_paths, [b"feature.txt"]);
        assert_eq!(comparison.comparison.files.len(), 1);
        service
            .edit(
                "alice",
                "project",
                1,
                "alice",
                "Add the edited feature",
                "Keep the edited revision context.",
            )
            .expect("edit a pull request");
        service
            .set_state("alice", "project", 1, "alice", "closed")
            .expect("close a pull request");
        assert!(matches!(
            service.revise("alice", "project", 1, "alice"),
            Err(PullRequestError::Store(StoreError::PullRequestState))
        ));
        service
            .set_state("alice", "project", 1, "alice", "open")
            .expect("reopen a pull request");
        let lifecycle = service
            .get("alice", "project", 1, Some("alice"))
            .expect("read pull-request lifecycle changes");
        assert_eq!(lifecycle.pull_request.title, "Add the edited feature");
        assert_eq!(lifecycle.pull_request.state, "open");
        assert!(
            lifecycle
                .timeline
                .iter()
                .any(|event| event.kind == "pull-request-edited")
        );
        assert!(
            lifecycle
                .timeline
                .iter()
                .any(|event| event.kind == "pull-request-closed")
        );
        assert!(
            lifecycle
                .timeline
                .iter()
                .any(|event| event.kind == "pull-request-reopened")
        );
        run(
            &fixture.worktree,
            Command::new("git")
                .args(["fetch", "-q"])
                .arg(&fixture.bare)
                .arg("refs/pull/1/head"),
        );
        assert_eq!(
            rev_parse(&fixture.worktree, "FETCH_HEAD"),
            opened.head_object_id
        );
        assert!(matches!(
            service.open(
                "alice",
                "project",
                "bob",
                "Reader change",
                "Readers cannot open pull requests.",
                "refs/heads/main",
                "refs/heads/feature",
            ),
            Err(PullRequestError::Store(StoreError::PullRequestDenied))
        ));

        let first = service
            .get("alice", "project", 1, None)
            .expect("read a public pull request");
        assert_eq!(first.revisions.len(), 1);
        assert_eq!(first.revisions[0].head_object_id, opened.head_object_id);

        fixture.commit_feature("second feature revision");
        let revised = service
            .revise("alice", "project", 1, "alice")
            .expect("revise a pull request");
        assert_ne!(revised.head_object_id, opened.head_object_id);
        assert_eq!(fixture.pull_ref(1), revised.head_object_id);
        let second = service
            .get("alice", "project", 1, Some("alice"))
            .expect("read revised pull request");
        assert_eq!(second.revisions.len(), 2);
        assert_eq!(second.revisions[0].head_object_id, opened.head_object_id);
        assert_eq!(second.revisions[1].head_object_id, revised.head_object_id);
        let original_comparison = service
            .compare("alice", "project", 1, Some(1), Some("alice"))
            .expect("compare the immutable first revision");
        assert_eq!(
            original_comparison.revision.head_object_id,
            opened.head_object_id
        );
        assert_eq!(original_comparison.comparison.commits.len(), 1);
        let current_comparison = service
            .compare("alice", "project", 1, None, Some("alice"))
            .expect("compare the current revision");
        assert_eq!(current_comparison.revision.number, 2);
        assert_eq!(current_comparison.comparison.commits.len(), 2);

        let git = GitRepository::open(&fixture.bare).expect("open the bare fixture");
        let base = git
            .resolve_branch("refs/heads/main")
            .expect("resolve the base");
        let head = git
            .resolve_branch("refs/heads/feature")
            .expect("resolve the head");
        let mut store = Store::open(&fixture.database).expect("open the store");
        let pending = store
            .begin_pull_request_open(&NewPullRequestRefIntent {
                id: "10000000000000000000000000000000",
                pull_request_id: "20000000000000000000000000000000",
                owner: "alice",
                repository: "project",
                actor: "alice",
                title: "Recover the ref",
                body: "The intent exists before the ref.",
                base_ref: "refs/heads/main",
                head_ref: "refs/heads/feature",
                base_object_id: &base.to_string(),
                head_object_id: &head.to_string(),
                created_at: 100,
            })
            .expect("begin a pending pull request");
        assert_eq!(pending.pull_request_number, 2);
        drop(store);
        service.recover().expect("recover a pre-ref intent");
        assert_eq!(fixture.pull_ref(2), head.to_string());
        let recovered = service
            .get("alice", "project", 2, None)
            .expect("read the recovered pull request");
        assert_eq!(recovered.revisions.len(), 1);

        fixture.commit_feature("recovery after ref update");
        let git = GitRepository::open(&fixture.bare).expect("reopen the bare fixture");
        let next_base = git
            .resolve_branch("refs/heads/main")
            .expect("resolve the next base");
        let next_head = git
            .resolve_branch("refs/heads/feature")
            .expect("resolve the next head");
        let mut store = Store::open(&fixture.database).expect("reopen the store");
        let pending_revision = store
            .begin_pull_request_revision(
                2,
                &NewPullRequestRefIntent {
                    id: "30000000000000000000000000000000",
                    pull_request_id: "20000000000000000000000000000000",
                    owner: "alice",
                    repository: "project",
                    actor: "alice",
                    title: "Recover the ref",
                    body: "The intent exists before the ref.",
                    base_ref: "refs/heads/main",
                    head_ref: "refs/heads/feature",
                    base_object_id: &next_base.to_string(),
                    head_object_id: &next_head.to_string(),
                    created_at: 101,
                },
            )
            .expect("begin a pending revision");
        git.update_reference("refs/pull/2/head", Some(head), next_head)
            .expect("apply the ref before metadata");
        drop(store);
        service.recover().expect("recover a post-ref intent");
        let recovered_revision = service
            .get("alice", "project", 2, None)
            .expect("read the recovered revision");
        assert_eq!(recovered_revision.revisions.len(), 2);
        assert_eq!(
            recovered_revision.revisions[1].number,
            pending_revision.revision_number
        );

        let service = Arc::new(service);
        let handles = ["Concurrent A", "Concurrent B"].map(|title| {
            let service = Arc::clone(&service);
            std::thread::spawn(move || {
                service
                    .open(
                        "alice",
                        "project",
                        "alice",
                        title,
                        "Use one stable number.",
                        "refs/heads/main",
                        "refs/heads/feature",
                    )
                    .expect("open a concurrent pull request")
                    .number
            })
        });
        let mut numbers = handles.map(|handle| handle.join().expect("join an opener"));
        numbers.sort_unstable();
        assert_eq!(numbers, [3, 4]);
        assert_eq!(fixture.pull_ref(3), next_head.to_string());
        assert_eq!(fixture.pull_ref(4), next_head.to_string());

        let event_kinds: Vec<String> = Store::open(&fixture.database)
            .expect("open the event store")
            .connection()
            .prepare(
                "SELECT kind FROM repository_event
                 WHERE kind LIKE 'pull-request-%' ORDER BY sequence",
            )
            .expect("prepare the event query")
            .query_map([], |row| row.get(0))
            .expect("query pull-request events")
            .collect::<Result<_, _>>()
            .expect("read pull-request events");
        assert_eq!(
            event_kinds,
            [
                "pull-request-created",
                "pull-request-edited",
                "pull-request-closed",
                "pull-request-reopened",
                "pull-request-revised",
                "pull-request-created",
                "pull-request-revised",
                "pull-request-created",
                "pull-request-created"
            ]
        );
    }
}

#[test]
fn fast_forwards_pull_requests_with_one_durable_merge_event_for_both_hashes() {
    for (index, object_format) in ["sha1", "sha256"].into_iter().enumerate() {
        let fixture = Fixture::new(object_format, index + 30);
        let service = PullRequestService::new(&fixture.database, &fixture.repositories);
        let opened = service
            .open(
                "alice",
                "project",
                "alice",
                "Fast-forward the feature",
                "Move the base ref to the reviewed head.",
                "refs/heads/main",
                "refs/heads/feature",
            )
            .expect("open a fast-forward pull request");
        assert!(matches!(
            service.merge("alice", "project", 1, "bob", "fast-forward"),
            Err(PullRequestError::Store(StoreError::PullRequestDenied))
        ));
        Store::open(&fixture.database)
            .expect("open the collaborator store")
            .connection()
            .execute(
                "UPDATE repository_collaborator SET role = 'writer'
                 WHERE account_id = (SELECT id FROM account WHERE username = 'bob')",
                [],
            )
            .expect("make the collaborator a writer");
        assert!(matches!(
            service.merge("alice", "project", 1, "bob", "fast-forward"),
            Err(PullRequestError::Store(StoreError::PullRequestDenied))
        ));

        let merged = service
            .merge("alice", "project", 1, "alice", "fast-forward")
            .expect("fast-forward the pull request");
        assert_eq!(merged.state, "merged");
        assert_eq!(
            rev_parse(&fixture.bare, "refs/heads/main"),
            opened.head_object_id
        );
        assert!(matches!(
            service.merge("alice", "project", 1, "alice", "fast-forward"),
            Err(PullRequestError::Store(StoreError::PullRequestState))
        ));

        let store = Store::open(&fixture.database).expect("open the merge store");
        let intent_state: String = store
            .connection()
            .query_row(
                "SELECT git_operation_intent.state
                 FROM pull_request_merge_intent
                 JOIN git_operation_intent
                   ON git_operation_intent.id = pull_request_merge_intent.intent_id",
                [],
                |row| row.get(0),
            )
            .expect("read the merge intent");
        assert_eq!(intent_state, "completed");
        let events: Vec<String> = store
            .connection()
            .prepare(
                "SELECT kind FROM repository_event
                 WHERE sequence > 1 ORDER BY sequence",
            )
            .expect("prepare merge events")
            .query_map([], |row| row.get(0))
            .expect("query merge events")
            .collect::<Result<_, _>>()
            .expect("read merge events");
        assert_eq!(events, ["push", "ref-updated", "pull-request-merged",]);
    }
}

#[test]
fn rejects_a_merge_when_the_base_moved_after_the_revision() {
    let fixture = Fixture::new("sha1", 40);
    let service = PullRequestService::new(&fixture.database, &fixture.repositories);
    service
        .open(
            "alice",
            "project",
            "alice",
            "Stale base",
            "Do not merge a stale comparison.",
            "refs/heads/main",
            "refs/heads/feature",
        )
        .expect("open a pull request");
    fixture.commit_on("main", "base.txt", "new base\n", "move the base");
    assert!(matches!(
        service.merge("alice", "project", 1, "alice", "fast-forward"),
        Err(PullRequestError::StaleRevision)
    ));
    let count: i64 = Store::open(&fixture.database)
        .expect("open the stale merge store")
        .connection()
        .query_row(
            "SELECT COUNT(*) FROM pull_request_merge_intent",
            [],
            |row| row.get(0),
        )
        .expect("count merge intents");
    assert_eq!(count, 0);
}

#[test]
fn creates_worktree_free_merge_commits_with_deterministic_parents_for_both_hashes() {
    for (index, object_format) in ["sha1", "sha256"].into_iter().enumerate() {
        let fixture = Fixture::new(object_format, index + 50);
        fixture.commit_on("main", "base.txt", "base side\n", "advance base");
        run(
            &fixture.worktree,
            Command::new("git").args(["switch", "-q", "feature"]),
        );
        run(
            &fixture.worktree,
            Command::new("git").args(["mv", "feature.txt", "renamed-feature.txt"]),
        );
        let renamed = fixture.worktree.join("renamed-feature.txt");
        fs::set_permissions(&renamed, fs::Permissions::from_mode(0o755))
            .expect("set the executable mode");
        git_commit(&fixture.worktree, "rename the feature");
        run(
            &fixture.worktree,
            Command::new("git")
                .args(["push", "-q"])
                .arg(&fixture.bare)
                .arg("feature"),
        );

        let git = GitRepository::open(&fixture.bare).expect("open the merge repository");
        let base = git
            .resolve_branch("refs/heads/main")
            .expect("resolve the merge base");
        let head = git
            .resolve_branch("refs/heads/feature")
            .expect("resolve the merge head");
        let object_state = git_object_state(&fixture.bare);
        let first = git
            .prepare_merge_commit(base, head, "alice", 1234, "deterministic merge")
            .expect("prepare a merge commit");
        let second = git
            .prepare_merge_commit(base, head, "alice", 1234, "deterministic merge")
            .expect("prepare the same merge commit");
        assert_eq!(first, second);
        assert_eq!(git_object_state(&fixture.bare), object_state);
        assert!(!fixture.bare.join("index").exists());

        let service = PullRequestService::new(&fixture.database, &fixture.repositories);
        service
            .open(
                "alice",
                "project",
                "alice",
                "Merge the rename",
                "Keep the rename and executable mode.",
                "refs/heads/main",
                "refs/heads/feature",
            )
            .expect("open a divergent pull request");
        service
            .merge("alice", "project", 1, "alice", "merge-commit")
            .expect("create a merge commit");
        let merged = rev_parse(&fixture.bare, "refs/heads/main");
        assert_ne!(merged, base.to_string());
        assert_ne!(merged, head.to_string());
        let description = git_output(
            &fixture.bare,
            &["show", "-s", "--format=%an|%ae|%cn|%ce|%P|%s", &merged],
        );
        let expected = format!(
            "alice|alice@users.tit|alice|alice@users.tit|{base} {head}|Merge pull request #1 from refs/heads/feature"
        );
        assert_eq!(description.trim(), expected);
        let tree = git_output(&fixture.bare, &["ls-tree", "-r", &merged]);
        assert!(tree.contains("100755 blob"));
        assert!(tree.contains("\trenamed-feature.txt"));
        assert!(tree.contains("\tbase.txt"));
        assert!(!fixture.bare.join("index").exists());
    }
}

#[test]
fn rejects_a_conflicting_server_merge_without_moving_the_base() {
    let fixture = Fixture::new("sha1", 60);
    fixture.commit_on("main", "README.md", "main content\n", "change main");
    fixture.commit_on(
        "feature",
        "README.md",
        "feature content\n",
        "change feature",
    );
    let base = rev_parse(&fixture.bare, "refs/heads/main");
    let service = PullRequestService::new(&fixture.database, &fixture.repositories);
    service
        .open(
            "alice",
            "project",
            "alice",
            "Conflicting merge",
            "Do not create a conflict commit.",
            "refs/heads/main",
            "refs/heads/feature",
        )
        .expect("open a conflicting pull request");
    assert!(matches!(
        service.merge("alice", "project", 1, "alice", "merge-commit"),
        Err(PullRequestError::Mergeability)
    ));
    assert_eq!(rev_parse(&fixture.bare, "refs/heads/main"), base);
    let count: i64 = Store::open(&fixture.database)
        .expect("open the conflict store")
        .connection()
        .query_row(
            "SELECT COUNT(*) FROM pull_request_merge_intent",
            [],
            |row| row.get(0),
        )
        .expect("count conflict intents");
    assert_eq!(count, 0);
}

#[test]
fn recovers_a_completed_merge_and_abandons_a_concurrent_base_change() {
    let fixture = Fixture::new("sha1", 70);
    let service = PullRequestService::new(&fixture.database, &fixture.repositories);
    service
        .open(
            "alice",
            "project",
            "alice",
            "Recover this merge",
            "Complete metadata after the ref update.",
            "refs/heads/main",
            "refs/heads/feature",
        )
        .expect("open a recoverable pull request");
    begin_test_merge_intent(&fixture, 1, "71000000000000000000000000000000");
    let git = GitRepository::open(&fixture.bare).expect("open the recovery repository");
    let base = git
        .resolve_branch("refs/heads/main")
        .expect("resolve the recovery base");
    let head = git
        .resolve_branch("refs/heads/feature")
        .expect("resolve the recovery head");
    git.update_reference_with_log("refs/heads/main", Some(base), head, "interrupted merge")
        .expect("apply the interrupted merge ref");
    service.recover().expect("recover merge metadata");
    assert_eq!(
        service
            .get("alice", "project", 1, Some("alice"))
            .expect("read the recovered merge")
            .pull_request
            .state,
        "merged"
    );

    let concurrent = Fixture::new("sha1", 71);
    let service = PullRequestService::new(&concurrent.database, &concurrent.repositories);
    service
        .open(
            "alice",
            "project",
            "alice",
            "Race the base",
            "A concurrent base update wins before this ref moves.",
            "refs/heads/main",
            "refs/heads/feature",
        )
        .expect("open a concurrent pull request");
    begin_test_merge_intent(&concurrent, 1, "72000000000000000000000000000000");
    concurrent.commit_on("main", "raced.txt", "concurrent\n", "race the merge");
    let raced_target = rev_parse(&concurrent.bare, "refs/heads/main");
    service
        .recover()
        .expect("abandon the merge that did not update its ref");
    assert_eq!(rev_parse(&concurrent.bare, "refs/heads/main"), raced_target);
    assert_eq!(
        service
            .get("alice", "project", 1, Some("alice"))
            .expect("read the unmerged pull request")
            .pull_request
            .state,
        "open"
    );
    let store = Store::open(&concurrent.database).expect("open the raced merge store");
    let state: String = store
        .connection()
        .query_row(
            "SELECT state FROM git_operation_intent WHERE id = ?1",
            ["72000000000000000000000000000000"],
            |row| row.get(0),
        )
        .expect("read the raced intent state");
    assert_eq!(state, "abandoned");
    let merge_count: i64 = store
        .connection()
        .query_row(
            "SELECT COUNT(*) FROM pull_request_merge_intent",
            [],
            |row| row.get(0),
        )
        .expect("count active merge metadata");
    assert_eq!(merge_count, 0);
}

#[test]
fn classifies_clean_conflicting_and_already_merged_revisions_for_both_hashes() {
    for (index, object_format) in ["sha1", "sha256"].into_iter().enumerate() {
        let fixture = Fixture::new(object_format, index + 10);
        fixture.commit_on("main", "base.txt", "base side\n", "advance base");
        fixture.commit_on(
            "feature",
            "feature-two.txt",
            "feature side\n",
            "advance feature",
        );
        let service = PullRequestService::new(&fixture.database, &fixture.repositories);
        service
            .open(
                "alice",
                "project",
                "alice",
                "Clean divergence",
                "The branches change different paths.",
                "refs/heads/main",
                "refs/heads/feature",
            )
            .expect("open a clean divergent pull request");
        let object_state = git_object_state(&fixture.bare);
        let clean = service
            .compare("alice", "project", 1, None, None)
            .expect("compare clean divergence");
        assert_eq!(clean.comparison.mergeability, Mergeability::Clean);
        assert_eq!(clean.comparison.changed_paths.len(), 2);
        assert_eq!(git_object_state(&fixture.bare), object_state);

        fixture.commit_on("main", "README.md", "main content\n", "change base content");
        fixture.commit_on(
            "feature",
            "README.md",
            "feature content\n",
            "change feature content",
        );
        service
            .open(
                "alice",
                "project",
                "alice",
                "Conflicting divergence",
                "The branches change the same line.",
                "refs/heads/main",
                "refs/heads/feature",
            )
            .expect("open a conflicting pull request");
        let object_state = git_object_state(&fixture.bare);
        let conflicting = service
            .compare("alice", "project", 2, None, None)
            .expect("compare conflicting divergence");
        assert_eq!(
            conflicting.comparison.mergeability,
            Mergeability::Conflicting
        );
        assert_eq!(git_object_state(&fixture.bare), object_state);

        fixture.merge_feature_into_main();
        service
            .open(
                "alice",
                "project",
                "alice",
                "Merged head",
                "The head is already in the base.",
                "refs/heads/main",
                "refs/heads/feature",
            )
            .expect("open an already merged pull request");
        let merged = service
            .compare("alice", "project", 3, None, None)
            .expect("compare an already merged head");
        assert_eq!(merged.comparison.mergeability, Mergeability::AlreadyMerged);

        fixture.create_unrelated_branch();
        service
            .open(
                "alice",
                "project",
                "alice",
                "Unrelated head",
                "The branches do not have a common commit.",
                "refs/heads/main",
                "refs/heads/unrelated",
            )
            .expect("open an unrelated pull request");
        let unrelated = service
            .compare("alice", "project", 4, None, None)
            .expect("compare unrelated histories");
        assert_eq!(unrelated.comparison.mergeability, Mergeability::Unrelated);
        assert_eq!(unrelated.comparison.merge_base, None);
        assert_eq!(unrelated.comparison.changed_paths, [b"unrelated.txt"]);

        let base = ObjectId::from_hex(merged.revision.base_object_id.as_bytes())
            .expect("parse the base ID");
        let head = ObjectId::from_hex(merged.revision.head_object_id.as_bytes())
            .expect("parse the head ID");
        let limits = ReadLimits {
            max_history_commits: 1,
            ..ReadLimits::default()
        };
        let reader = RepositoryReadService::open(&fixture.bare, limits)
            .expect("open a limited repository reader");
        assert!(matches!(
            reader.comparison(base, head, &ReadCancellation::default()),
            Err(ReadError::Limit("history commits" | "comparison commits"))
        ));

        let base = ObjectId::from_hex(clean.revision.base_object_id.as_bytes())
            .expect("parse the clean base ID");
        let head = ObjectId::from_hex(clean.revision.head_object_id.as_bytes())
            .expect("parse the clean head ID");
        let limits = ReadLimits {
            max_diff_bytes: 1,
            ..ReadLimits::default()
        };
        let reader = RepositoryReadService::open(&fixture.bare, limits)
            .expect("open an output-limited repository reader");
        assert!(matches!(
            reader.comparison(base, head, &ReadCancellation::default()),
            Err(ReadError::Limit("comparison output bytes" | "diff bytes"))
        ));

        let limits = ReadLimits {
            max_duration: Duration::from_nanos(1),
            ..ReadLimits::default()
        };
        let reader = RepositoryReadService::open(&fixture.bare, limits)
            .expect("open a time-limited repository reader");
        assert!(matches!(
            reader.comparison(base, head, &ReadCancellation::default()),
            Err(ReadError::Deadline)
        ));
    }
}

#[test]
fn records_review_actions_and_immutable_line_anchors_for_both_hashes() {
    for (index, object_format) in ["sha1", "sha256"].into_iter().enumerate() {
        let fixture = Fixture::new(object_format, index + 20);
        let byte_path = "review-å.txt".as_bytes();
        fixture.commit_bytes_on("feature", byte_path, b"byte path\n", "add a byte path");
        let service = PullRequestService::new(&fixture.database, &fixture.repositories);
        service
            .open(
                "alice",
                "project",
                "alice",
                "Review anchors",
                "Keep each review action.",
                "refs/heads/main",
                "refs/heads/feature",
            )
            .expect("open a reviewed pull request");
        service
            .review(
                "alice",
                "project",
                1,
                1,
                "bob",
                "comment",
                "A **general** comment.",
                None,
                None,
                None,
            )
            .expect("add a reader comment");
        service
            .review(
                "alice", "project", 1, 1, "bob", "approved", "", None, None, None,
            )
            .expect("approve the revision");
        service
            .review(
                "alice",
                "project",
                1,
                1,
                "alice",
                "changes-requested",
                "Change this line.",
                None,
                None,
                None,
            )
            .expect("request changes");
        let line_id = service
            .review(
                "alice",
                "project",
                1,
                1,
                "bob",
                "line-comment",
                "Use a clearer value.",
                Some(byte_path),
                Some("head"),
                Some(1),
            )
            .expect("add a line comment");
        assert!(matches!(
            service.review(
                "alice",
                "project",
                1,
                1,
                "bob",
                "line-comment",
                "This line does not exist.",
                Some(byte_path),
                Some("head"),
                Some(2),
            ),
            Err(PullRequestError::ReviewAnchor)
        ));

        fixture.commit_feature("make the line comment outdated");
        service
            .revise("alice", "project", 1, "alice")
            .expect("record a new revision");
        let detail = service
            .get("alice", "project", 1, Some("bob"))
            .expect("read review activity");
        assert_eq!(detail.revisions.len(), 2);
        assert_eq!(detail.reviews.len(), 4);
        let line = detail
            .reviews
            .iter()
            .find(|review| review.id == line_id)
            .expect("find the line review");
        assert_eq!(line.revision, 1);
        assert_eq!(line.path.as_deref(), Some(byte_path));
        assert_eq!(line.side.as_deref(), Some("head"));
        assert_eq!(line.line, Some(1));
        assert_eq!(
            line.commit_object_id.as_deref(),
            Some(detail.revisions[0].head_object_id.as_str())
        );
        assert_eq!(
            detail
                .timeline
                .iter()
                .map(|event| event.kind.as_str())
                .collect::<Vec<_>>(),
            [
                "pull-request-created",
                "pull-request-commented",
                "pull-request-approved",
                "pull-request-changes-requested",
                "pull-request-line-commented",
                "pull-request-revised",
            ]
        );

        let store = Store::open(&fixture.database).expect("open the review policy store");
        let first_page = store
            .pull_request_detail_page("alice", "project", 1, Some("bob"), 1, 1, 2)
            .expect("read the first bounded review page");
        assert_eq!(first_page.reviews.len(), 2);
        assert!(first_page.reviews_has_next);
        assert_eq!(first_page.timeline.len(), 2);
        assert!(first_page.timeline_has_next);
        let second_page = store
            .pull_request_detail_page("alice", "project", 1, Some("bob"), 2, 2, 2)
            .expect("read the second bounded review page");
        assert_eq!(second_page.reviews_page, 2);
        assert_eq!(second_page.reviews.len(), 2);
        assert_eq!(second_page.timeline_page, 2);
        assert_eq!(second_page.timeline.len(), 2);
        store
            .connection()
            .execute(
                "UPDATE repository SET visibility = 'private' WHERE slug = 'project'",
                [],
            )
            .expect("make the reviewed repository private");
        store
            .connection()
            .execute(
                "DELETE FROM repository_collaborator WHERE account_id = 2",
                [],
            )
            .expect("remove the reader");
        assert!(matches!(
            service.review(
                "alice",
                "project",
                1,
                2,
                "bob",
                "comment",
                "This must stay hidden.",
                None,
                None,
                None,
            ),
            Err(PullRequestError::Store(StoreError::PullRequestHidden))
        ));
    }
}

struct Fixture {
    _directory: TempDir,
    database: PathBuf,
    repositories: PathBuf,
    worktree: PathBuf,
    bare: PathBuf,
}

impl Fixture {
    fn new(object_format: &str, index: usize) -> Self {
        let directory = TempDir::new().expect("create a fixture directory");
        let repositories = directory.path().join("repositories");
        fs::create_dir(&repositories).expect("create a repository directory");
        let repositories = fs::canonicalize(repositories).expect("canonicalize repositories");
        let database = directory.path().join("tit.sqlite3");
        let store = Store::open(&database).expect("create the database");
        store
            .connection()
            .execute(
                "INSERT INTO account
                 (id, username, is_administrator, state, created_at)
                 VALUES (1, 'alice', 1, 'active', 1)",
                [],
            )
            .expect("create the owner");
        store
            .connection()
            .execute(
                "INSERT INTO account
                 (id, username, is_administrator, state, created_at)
                 VALUES (2, 'bob', 0, 'active', 1)",
                [],
            )
            .expect("create a reader");
        let repository_id = format!("{index:032x}");
        store
            .connection()
            .execute(
                "INSERT INTO repository
                 (id, owner_account_id, slug, visibility, state, object_format, created_at)
                 VALUES (?1, 1, 'project', 'public', 'active', ?2, 2)",
                params![repository_id, object_format],
            )
            .expect("create repository metadata");
        store
            .connection()
            .execute(
                "INSERT INTO repository_collaborator
                 (repository_id, account_id, role, created_at)
                 VALUES (?1, 2, 'reader', 2)",
                [&repository_id],
            )
            .expect("grant reader access");
        drop(store);

        let worktree = directory.path().join("worktree");
        run(
            directory.path(),
            Command::new("git")
                .args(["init", "-q", "-b", "main", "--object-format", object_format])
                .arg(&worktree),
        );
        run(
            &worktree,
            Command::new("git").args(["config", "user.name", "Tit Test"]),
        );
        run(
            &worktree,
            Command::new("git").args(["config", "user.email", "tit@example.test"]),
        );
        fs::write(worktree.join("README.md"), b"base\n").expect("write base content");
        git_commit(&worktree, "base");
        run(
            &worktree,
            Command::new("git").args(["switch", "-q", "-c", "feature"]),
        );
        fs::write(worktree.join("feature.txt"), b"feature\n").expect("write feature content");
        git_commit(&worktree, "feature");
        let bare = repositories.join(format!("{repository_id}.git"));
        run(
            directory.path(),
            Command::new("git")
                .args(["clone", "-q", "--bare"])
                .arg(&worktree)
                .arg(&bare),
        );
        Self {
            _directory: directory,
            database,
            repositories,
            worktree,
            bare,
        }
    }

    fn commit_feature(&self, message: &str) {
        fs::write(self.worktree.join("feature.txt"), format!("{message}\n"))
            .expect("write revised feature content");
        git_commit(&self.worktree, message);
        run(
            &self.worktree,
            Command::new("git")
                .args(["push", "-q"])
                .arg(&self.bare)
                .arg("feature"),
        );
    }

    fn commit_on(&self, branch: &str, path: &str, content: &str, message: &str) {
        run(
            &self.worktree,
            Command::new("git").args(["switch", "-q", branch]),
        );
        fs::write(self.worktree.join(path), content).expect("write branch content");
        git_commit(&self.worktree, message);
        run(
            &self.worktree,
            Command::new("git")
                .args(["push", "-q"])
                .arg(&self.bare)
                .arg(branch),
        );
    }

    fn commit_bytes_on(&self, branch: &str, path: &[u8], content: &[u8], message: &str) {
        run(
            &self.worktree,
            Command::new("git").args(["switch", "-q", branch]),
        );
        fs::write(
            self.worktree
                .join(std::ffi::OsString::from_vec(path.to_vec())),
            content,
        )
        .expect("write byte-path content");
        git_commit(&self.worktree, message);
        run(
            &self.worktree,
            Command::new("git")
                .args(["push", "-q"])
                .arg(&self.bare)
                .arg(branch),
        );
    }

    fn merge_feature_into_main(&self) {
        run(
            &self.worktree,
            Command::new("git").args(["switch", "-q", "main"]),
        );
        run(
            &self.worktree,
            Command::new("git").args(["merge", "-q", "--no-commit", "-X", "ours", "feature"]),
        );
        git_commit(&self.worktree, "merge feature");
        run(
            &self.worktree,
            Command::new("git")
                .args(["push", "-q"])
                .arg(&self.bare)
                .arg("main"),
        );
    }

    fn create_unrelated_branch(&self) {
        run(
            &self.worktree,
            Command::new("git").args(["switch", "-q", "--orphan", "unrelated"]),
        );
        fs::write(self.worktree.join("unrelated.txt"), b"unrelated\n")
            .expect("write unrelated content");
        git_commit(&self.worktree, "unrelated root");
        run(
            &self.worktree,
            Command::new("git")
                .args(["push", "-q"])
                .arg(&self.bare)
                .arg("unrelated"),
        );
    }

    fn pull_ref(&self, number: i64) -> String {
        GitRepository::open(&self.bare)
            .expect("open the bare fixture")
            .reference_target(&format!("refs/pull/{number}/head"))
            .expect("read a pull-request ref")
            .expect("find a pull-request ref")
            .to_string()
    }
}

fn git_commit(worktree: &Path, message: &str) {
    run(worktree, Command::new("git").args(["add", "."]));
    let mut command = Command::new("git");
    command
        .args(["commit", "-q", "-m", message])
        .env("GIT_AUTHOR_NAME", "Tit Test")
        .env("GIT_AUTHOR_EMAIL", "tit@example.test")
        .env("GIT_COMMITTER_NAME", "Tit Test")
        .env("GIT_COMMITTER_EMAIL", "tit@example.test")
        .env("GIT_CONFIG_COUNT", "1")
        .env("GIT_CONFIG_KEY_0", "commit.gpgsign")
        .env("GIT_CONFIG_VALUE_0", "false");
    run(worktree, &mut command);
}

fn begin_test_merge_intent(fixture: &Fixture, number: i64, intent_id: &str) {
    let service = PullRequestService::new(&fixture.database, &fixture.repositories);
    let detail = service
        .get("alice", "project", number, Some("alice"))
        .expect("read a merge intent pull request");
    let revision = detail.revisions.last().expect("find the merge revision");
    let git = GitRepository::open(&fixture.bare).expect("open the merge intent repository");
    let base = git
        .resolve_branch("refs/heads/main")
        .expect("resolve the merge intent base");
    let head = git
        .resolve_branch("refs/heads/feature")
        .expect("resolve the merge intent head");
    let initial = format!("{base} refs/heads/main\n").into_bytes();
    let proposed = format!("{head} refs/heads/main\n").into_bytes();
    let created_at = revision.created_at + 1;
    let quarantine = fixture
        .bare
        .join("objects")
        .join("tit-quarantine")
        .join(intent_id);
    let mut store = Store::open(&fixture.database).expect("open the merge intent store");
    store
        .begin_pull_request_merge(
            &NewPullRequestMerge {
                owner: "alice",
                repository: "project",
                number,
                revision: revision.number,
                actor: "alice",
                method: "fast-forward",
                base_ref: "refs/heads/main",
                old_target: &base.to_string(),
                head_target: &head.to_string(),
                new_target: &head.to_string(),
                created_at,
            },
            &GitOperationIntent {
                id: intent_id,
                repository_path: fixture.bare.to_str().expect("a UTF-8 repository path"),
                actor: "alice",
                initial_refs: &initial,
                proposed_refs: &proposed,
                event_payload: &proposed,
                quarantine_path: quarantine.to_str().expect("a UTF-8 quarantine path"),
                created_at,
            },
        )
        .expect("begin a test merge intent");
    store
        .mark_git_objects_promoted(intent_id, None)
        .expect("mark test merge objects promoted");
}

fn rev_parse(repository: &Path, revision: &str) -> String {
    let output = Command::new("git")
        .args(["rev-parse", revision])
        .current_dir(repository)
        .output()
        .expect("resolve a fixture revision");
    assert!(output.status.success(), "resolve a fixture revision");
    String::from_utf8(output.stdout)
        .expect("read a fixture object ID")
        .trim()
        .to_owned()
}

fn git_output(repository: &Path, arguments: &[&str]) -> String {
    let output = Command::new("git")
        .args(arguments)
        .current_dir(repository)
        .output()
        .expect("run a Git inspection command");
    assert!(
        output.status.success(),
        "Git inspection failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8(output.stdout).expect("read Git inspection output")
}

fn git_object_state(repository: &Path) -> String {
    let output = Command::new("git")
        .args(["count-objects", "-v"])
        .current_dir(repository)
        .output()
        .expect("count fixture objects");
    assert!(output.status.success(), "count fixture objects");
    String::from_utf8(output.stdout).expect("read the object count")
}

fn run(directory: &Path, command: &mut Command) {
    let output = command
        .current_dir(directory)
        .output()
        .expect("run a fixture command");
    assert!(
        output.status.success(),
        "fixture command failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}