ghostflow 0.1.0

Routines which implement various parts of "ghostflow", or the git-hosted workflow.
Documentation
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.

use std::fmt::Display;
use std::path::Path;
use std::process::Command;
use std::sync::Arc;

use git_workarea::GitContext;

use crate::actions::test::refs::*;
use crate::host::{CommitStatus, CommitStatusState, HostedProject, HostingService, MergeRequest};
use crate::tests::mock::{MockData, MockMergeRequest, MockService};
use crate::tests::utils::test_workspace_dir;

const REPO_NAME: &str = "base";
const MR_ID: u64 = 1;
const MR_ID_OTHER: u64 = 2;
const COMMIT: &str = "7189cf557ba2c7c61881ff8669158710b94d8df1";
const COMMIT_OTHER: &str = "f6f8de8c7c5f1a081b14f5a47c7798268f383222";

fn test_refs_service() -> Arc<MockService> {
    let base = MockData::repo(REPO_NAME);
    let user = MockData::user("user");
    MockData::builder()
        .add_merge_request(MockMergeRequest::new(MR_ID, &user, COMMIT, &base, None))
        .add_merge_request(MockMergeRequest::new(
            MR_ID_OTHER,
            &user,
            COMMIT_OTHER,
            &base,
            None,
        ))
        .add_project(base)
        .add_user(user)
        .service()
}

fn git_context(workspace_path: &Path) -> GitContext {
    // Here, we create two clones of the current repository: one to act as the remote and another
    // to be the repository the test action acts on. The first is cloned from the source tree's
    // directory while the second is cloned from that first clone. This sets up the `origin` remote
    // properly for the `test` command.

    let origindir = workspace_path.join("origin");
    let clone = Command::new("git")
        .arg("clone")
        .arg("--bare")
        .arg(concat!(env!("CARGO_MANIFEST_DIR"), "/../.git"))
        .arg(&origindir)
        .output()
        .unwrap();
    if !clone.status.success() {
        panic!(
            "origin clone failed: {}",
            String::from_utf8_lossy(&clone.stderr),
        );
    }

    let gitdir = workspace_path.join("git");
    let clone = Command::new("git")
        .arg("clone")
        .arg("--bare")
        .arg(origindir)
        .arg(&gitdir)
        .output()
        .unwrap();
    if !clone.status.success() {
        panic!(
            "working clone failed: {}",
            String::from_utf8_lossy(&clone.stderr),
        );
    }

    GitContext::new(gitdir)
}

fn create_test(git: &GitContext, service: &Arc<MockService>) -> TestRefs {
    let project = HostedProject {
        name: REPO_NAME.into(),
        service: Arc::clone(service) as Arc<dyn HostingService>,
    };

    TestRefs::new(git.clone(), project)
}

// Creating the stage action structure should create the stage ref on the remote.
fn test_ref_exists<I>(ctx: &GitContext, namespace: &str, id: I) -> bool
where
    I: Display,
{
    let refname = format!("refs/{namespace}/{id}");
    let remote = ctx
        .git()
        .arg("ls-remote")
        .arg("--exit-code")
        .arg("origin")
        .arg(&refname)
        .output()
        .unwrap()
        .status
        .success();
    let local = ctx
        .git()
        .arg("show-ref")
        .arg("--quiet")
        .arg("--verify")
        .arg(&refname)
        .status()
        .unwrap()
        .success();

    assert_eq!(
        remote, local,
        "The `refs/{namespace}/...` refs should be the same on the local and remote repositories.",
    );
    remote && local
}

#[derive(Clone, Copy, PartialEq, Eq)]
enum TestRefState {
    Pushed,
    Removed,
}

impl TestRefState {
    fn description(self) -> &'static str {
        match self {
            TestRefState::Pushed => "pushed for testing",
            TestRefState::Removed => "removed from testing",
        }
    }
}

fn check_commit_status(actual: &CommitStatus, mr: &MergeRequest, state: TestRefState) {
    assert_eq!(actual.state, CommitStatusState::Success);
    assert_eq!(actual.refname, Some(mr.source_branch.clone()));
    assert_eq!(actual.name, "ghostflow-test");
    assert_eq!(actual.description, state.description());
    assert_eq!(actual.target_url, None);
}

// Testing an MR should push a ref and make a comment.
#[test]
fn test_test_mr() {
    let tempdir = test_workspace_dir();
    let ctx = git_context(tempdir.path());
    let service = test_refs_service();
    let test = create_test(&ctx, &service);

    let mr = service.merge_request(REPO_NAME, MR_ID).unwrap();
    test.test_mr(&mr).unwrap();

    assert!(test_ref_exists(&ctx, "test-topics", mr.id));

    let mr_comments = service.mr_comments(mr.id);
    let mr_commit_statuses = service.commit_statuses(&mr.commit.id);

    assert_eq!(service.remaining_data(), 0);

    assert_eq!(mr_comments.len(), 1);
    assert_eq!(mr_comments[0], "This topic has been pushed for testing.");

    assert_eq!(mr_commit_statuses.len(), 1);

    check_commit_status(&mr_commit_statuses[0], &mr, TestRefState::Pushed);
}

// Testing an MR with a custom namespace should work.
#[test]
fn test_test_mr_namespace() {
    let tempdir = test_workspace_dir();
    let ctx = git_context(tempdir.path());
    let service = test_refs_service();
    let mut test = create_test(&ctx, &service);
    let namespace = "test-topics-alternate";
    test.ref_namespace(namespace);

    let mr = service.merge_request(REPO_NAME, MR_ID).unwrap();
    test.test_mr(&mr).unwrap();

    assert!(test_ref_exists(&ctx, namespace, mr.id));

    let mr_comments = service.mr_comments(mr.id);
    let mr_commit_statuses = service.commit_statuses(&mr.commit.id);

    assert_eq!(service.remaining_data(), 0);

    assert_eq!(mr_comments.len(), 1);
    assert_eq!(mr_comments[0], "This topic has been pushed for testing.");

    assert_eq!(mr_commit_statuses.len(), 1);

    check_commit_status(&mr_commit_statuses[0], &mr, TestRefState::Pushed);
}

// Testing an MR should push a ref, but not make a comment if quiet.
#[test]
fn test_test_mr_quiet() {
    let tempdir = test_workspace_dir();
    let ctx = git_context(tempdir.path());
    let service = test_refs_service();
    let mut test = create_test(&ctx, &service);
    test.quiet();

    let mr = service.merge_request(REPO_NAME, MR_ID).unwrap();
    test.test_mr(&mr).unwrap();

    assert!(test_ref_exists(&ctx, "test-topics", mr.id));

    let mr_commit_statuses = service.commit_statuses(&mr.commit.id);

    assert_eq!(service.remaining_data(), 0);

    assert_eq!(mr_commit_statuses.len(), 1);

    check_commit_status(&mr_commit_statuses[0], &mr, TestRefState::Pushed);
}

// Untesting an MR should remove the ref from the remote.
#[test]
fn test_untest_mr() {
    let tempdir = test_workspace_dir();
    let ctx = git_context(tempdir.path());
    let service = test_refs_service();
    let test = create_test(&ctx, &service);

    let mr = service.merge_request(REPO_NAME, MR_ID).unwrap();
    test.test_mr(&mr).unwrap();
    test.untest_mr(&mr).unwrap();

    assert!(!test_ref_exists(&ctx, "test-topics", mr.id));

    let mr_comments = service.mr_comments(mr.id);
    let mr_commit_statuses = service.commit_statuses(&mr.commit.id);

    assert_eq!(service.remaining_data(), 0);

    assert_eq!(mr_comments.len(), 1);
    assert_eq!(mr_comments[0], "This topic has been pushed for testing.");

    assert_eq!(mr_commit_statuses.len(), 2);

    check_commit_status(&mr_commit_statuses[0], &mr, TestRefState::Pushed);
    check_commit_status(&mr_commit_statuses[1], &mr, TestRefState::Removed);
}

// Untesting an untested MR is fine.
#[test]
fn test_untest_mr_untested() {
    let tempdir = test_workspace_dir();
    let ctx = git_context(tempdir.path());
    let service = test_refs_service();
    let test = create_test(&ctx, &service);

    let mr = service.merge_request(REPO_NAME, MR_ID).unwrap();
    test.untest_mr(&mr).unwrap();

    assert!(!test_ref_exists(&ctx, "test-topics", mr.id));

    assert_eq!(service.remaining_data(), 0);
}

// Clearing the remote should clear all refs.
#[test]
fn test_clear_all_tests() {
    let tempdir = test_workspace_dir();
    let ctx = git_context(tempdir.path());
    let service = test_refs_service();
    let test = create_test(&ctx, &service);

    let mr = service.merge_request(REPO_NAME, MR_ID).unwrap();
    let mr2 = service.merge_request(REPO_NAME, MR_ID_OTHER).unwrap();
    test.test_mr(&mr).unwrap();
    test.test_mr(&mr2).unwrap();
    test.clear_all_mrs().unwrap();

    assert!(!test_ref_exists(&ctx, "test-topics", mr.id));
    assert!(!test_ref_exists(&ctx, "test-topics", mr2.id));

    let mr_comments = service.mr_comments(mr.id);
    let mr_commit_statuses = service.commit_statuses(&mr.commit.id);
    let mr2_commit_comments = service.mr_comments(mr2.id);
    let mr2_commit_statuses = service.commit_statuses(&mr2.commit.id);

    assert_eq!(service.remaining_data(), 0);

    assert_eq!(mr_comments.len(), 1);
    assert_eq!(mr_comments[0], "This topic has been pushed for testing.");

    assert_eq!(mr2_commit_comments.len(), 1);
    assert_eq!(
        mr2_commit_comments[0],
        "This topic has been pushed for testing.",
    );

    assert_eq!(mr_commit_statuses.len(), 2);

    check_commit_status(&mr_commit_statuses[0], &mr, TestRefState::Pushed);
    check_commit_status(&mr_commit_statuses[1], &mr, TestRefState::Removed);

    assert_eq!(mr2_commit_statuses.len(), 2);

    check_commit_status(&mr2_commit_statuses[0], &mr2, TestRefState::Pushed);
    check_commit_status(&mr2_commit_statuses[1], &mr2, TestRefState::Removed);
}

// Clearing the remote should delete invalid refs.
#[test]
fn test_clear_all_invalid() {
    let tempdir = test_workspace_dir();
    let ctx = git_context(tempdir.path());
    let service = test_refs_service();
    let test = create_test(&ctx, &service);
    let id = "abc";

    let update_ref = ctx
        .git()
        .arg("update-ref")
        .arg(format!("refs/test-topics/{id}"))
        .arg("HEAD")
        .output()
        .unwrap();
    if !update_ref.status.success() {
        panic!(
            "update-ref failed: {}",
            String::from_utf8_lossy(&update_ref.stderr),
        );
    }

    test.clear_all_mrs().unwrap();

    assert!(!test_ref_exists(&ctx, "test-topics", id));

    assert_eq!(service.remaining_data(), 0);
}

// Clearing the remote should delete refs for which there is no merge request.
#[test]
fn test_clear_all_no_mr() {
    let tempdir = test_workspace_dir();
    let ctx = git_context(tempdir.path());
    let service = test_refs_service();
    let test = create_test(&ctx, &service);
    let id = 100;

    let update_ref = ctx
        .git()
        .arg("update-ref")
        .arg(format!("refs/test-topics/{id}"))
        .arg("HEAD")
        .output()
        .unwrap();
    if !update_ref.status.success() {
        panic!(
            "update-ref failed: {}",
            String::from_utf8_lossy(&update_ref.stderr),
        );
    }

    test.clear_all_mrs().unwrap();

    assert!(!test_ref_exists(&ctx, "test-topics", id));

    assert_eq!(service.remaining_data(), 0);
}