Radish alpha
r
rad:z2UcCU1LgMshWvXj6hXSDDrwB8q8M
Radicle Job Collaborative Object
Radicle
Git
radicle-job src bin rad-job.rs
//! A program to create and inspect job COBs and the automated
//! processing runs they record, for a repository.
//!
//! Run `radicle-job --help` to see how to use the program.

use std::{collections::BTreeSet, error::Error as _, time::Duration};

use clap::Parser;
use uuid::Uuid;

use radicle::{
    cob, crypto,
    crypto::signature::Signer,
    git::Oid,
    node::{
        device::Device,
        sync::{Announcer, AnnouncerConfig, ReplicationFactor},
        Handle, Node,
    },
    prelude::{Profile, ReadStorage, RepoId},
    profile,
    storage::git::Repository,
};
use radicle_job::*;

const TIMEOUT: Duration = Duration::from_millis(5000);

fn main() {
    if let Err(err) = fallible_main() {
        eprintln!("ERROR: {err}");
        let mut err = err.source();
        while let Some(underlying) = err {
            eprintln!("caused by: {underlying}");
            err = underlying.source();
        }
        std::process::exit(1);
    }
}

fn fallible_main() -> Result<(), RadJobError> {
    let args = Args::parse();
    run(args)
}

/// Create, update, and query Radicle job COBs.
///
/// A job COB records results of automated processing of a
/// repository for a given Git commit, by one or more nodes. For
/// example, CI runs would be recorded as job COBs.
///
/// This program is a utility for Radicle developers to experiment
/// with and investigate job COBs. It allows you to see what jobs have
/// jobs and runs have been recorded. Output is JSON, for developer
/// convenience.
#[derive(Parser)]
#[clap(version)]
struct Args {
    /// Use this repository. Default is the current working directory.
    #[clap(short, long)]
    repository: Option<RepoId>,

    /// Use this to not sync with the network after any modifications.
    ///
    /// Note that if `--no-sync` was used, you can use `rad sync -a` to announce
    /// at a later point.
    #[clap(long)]
    no_sync: bool,

    #[clap(subcommand)]
    command: command::Command,
}

impl Args {
    fn repository(&self, profile: &Profile) -> Result<Repository, error::Repository> {
        let repo_id = if let Some(repo_id) = self.repository {
            repo_id
        } else {
            let (_repo, repo_id) = radicle::rad::cwd().map_err(error::Repository::Cwd)?;
            repo_id
        };
        profile
            .storage
            .repository(repo_id)
            .map_err(|err| error::Repository::Open { rid: repo_id, err })
    }
}

fn profile() -> Result<Profile, error::Profile> {
    Profile::load().map_err(error::Profile)
}

fn jobs(repo: &Repository) -> Result<Jobs<'_, Repository>, error::Jobs> {
    Jobs::open(repo).map_err(|err| error::Jobs { rid: repo.id, err })
}

fn announce(profile: &Profile, repo_id: RepoId) -> Result<(), error::Announce> {
    let mut node = Node::new(profile.home.socket());

    let (synced, unsynced) = node
        .seeds_for(repo_id, [*profile.id()])
        .map_err(error::Announce::Seeds)?
        .iter()
        .fold(
            (BTreeSet::new(), BTreeSet::new()),
            |(mut synced, mut unsynced), seed| {
                if seed.is_synced() {
                    synced.insert(seed.nid);
                } else {
                    unsynced.insert(seed.nid);
                }
                (synced, unsynced)
            },
        );

    let announcer = Announcer::new(AnnouncerConfig::public(
        *profile.id(),
        ReplicationFactor::MustReach(1),
        BTreeSet::new(),
        synced,
        unsynced,
    ))
    .map_err(error::Announce::Announcer)?;

    node.announce(repo_id, [*profile.id()], TIMEOUT, announcer, |_, _| ())
        .map_err(error::Announce::Announcement)?;

    Ok(())
}

fn run(args: Args) -> Result<(), RadJobError> {
    use command::*;

    let profile = profile()?;
    let repo = args.repository(&profile)?;
    let mut jobs = jobs(&repo)?;
    match args.command {
        Command::New(command) => {
            let signer = profile.signer().map_err(error::Signer)?;
            new_job(command, &mut jobs, &signer)?;
            if !args.no_sync {
                announce(&profile, repo.id)?
            }
        }
        Command::List(command) => list_jobs(command, &jobs)?,
        Command::Run(command) => {
            let signer = profile.signer().map_err(error::Signer)?;
            run_job(command, &mut jobs, &signer)?;
            if !args.no_sync {
                announce(&profile, repo.id)?
            }
        }
        Command::Show(command) => show_job(command, &jobs)?,
        Command::Failed(command) => {
            let signer = profile.signer().map_err(error::Signer)?;
            failed_job(command, &mut jobs, &signer)?;
            if !args.no_sync {
                announce(&profile, repo.id)?
            }
        }
        Command::Succeeded(command) => {
            let signer = profile.signer().map_err(error::Signer)?;
            succeeded_job(command, &mut jobs, &signer)?;
            if !args.no_sync {
                announce(&profile, repo.id)?
            }
        }
    }

    Ok(())
}

fn run_job<G>(
    command::Run { oid, url }: command::Run,
    jobs: &mut Jobs<'_, Repository>,
    signer: &Device<G>,
) -> Result<(), error::Run>
where
    G: Signer<crypto::Signature>,
{
    let uuid = Uuid::new_v4();
    let (id, job) = find_by_commit(oid, jobs)?;
    let mut job = JobMut::new(id, job, jobs);
    job.run(uuid, url, signer)
        .map_err(|err| error::Run::Store { id, err })?;
    println!("{uuid}");
    Ok(())
}

fn succeeded_job<G>(
    command::Succeeded { oid, run }: command::Succeeded,
    jobs: &mut Jobs<'_, Repository>,
    signer: &Device<G>,
) -> Result<(), error::Succeeded>
where
    G: Signer<crypto::Signature>,
{
    let (id, job) = find_by_commit(oid, jobs)?;
    let mut job = JobMut::new(id, job, jobs);
    job.finish(run, Reason::Succeeded, signer)
        .map_err(|err| error::Succeeded::Store { id, err })?;
    Ok(())
}

fn failed_job<G>(
    command::Failed { oid, run }: command::Failed,
    jobs: &mut Jobs<'_, Repository>,
    signer: &Device<G>,
) -> Result<(), error::Failed>
where
    G: Signer<crypto::Signature>,
{
    let (id, job) = find_by_commit(oid, jobs)?;
    let mut job = JobMut::new(id, job, jobs);
    job.finish(run, Reason::Failed, signer)
        .map_err(|err| error::Failed::Store { id, err })?;
    Ok(())
}

fn list_jobs(
    command::List { pretty, verbose }: command::List,
    jobs: &Jobs<'_, Repository>,
) -> Result<(), error::List> {
    let iter = jobs
        .all()
        .map_err(error::List::All)?
        .filter_map(|res| match res {
            Ok((id, job)) => Some((JobId::from(id), job)),
            Err(err) => {
                if verbose {
                    eprintln!("Failed to retrieve job: {err}");
                }
                None
            }
        });
    let jobs = display::Jobs::new(iter);
    if pretty {
        println!("{}", jobs.pretty());
    } else {
        println!(
            "{}",
            serde_json::to_string_pretty(&jobs).map_err(error::List::Json)?
        )
    }
    Ok(())
}

fn new_job<G>(
    command::New { oid }: command::New,
    jobs: &mut Jobs<Repository>,
    signer: &Device<G>,
) -> Result<(), error::New>
where
    G: Signer<crypto::Signature>,
{
    let job = jobs
        .create(oid, signer)
        .map_err(|err| error::New { oid, err })?;
    println!("{}", job.id());
    Ok(())
}

fn show_job(
    command::Show { pretty, oid }: command::Show,
    jobs: &Jobs<Repository>,
) -> Result<(), error::Show> {
    let (id, job) = find_by_commit(oid, jobs)?;
    let show = radicle_job::display::Job::new(id, &job);
    if pretty {
        println!("{}", show.pretty());
    } else {
        println!(
            "{}",
            serde_json::to_string_pretty(&show).map_err(error::Show::Json)?
        );
    }
    Ok(())
}

fn find_by_commit(oid: Oid, jobs: &Jobs<Repository>) -> Result<(JobId, Job), error::Find> {
    jobs.find_by_commit(oid)
        .map_err(|err| error::Find::FindCommit { oid, err })?
        .next()
        .ok_or(error::Find::NoJob(oid))?
        .map_err(|err| error::Find::FindCommit { oid, err })
}

#[derive(Debug, thiserror::Error)]
enum RadJobError {
    #[error(transparent)]
    Profile(#[from] error::Profile),
    #[error(transparent)]
    Signer(#[from] error::Signer),
    #[error(transparent)]
    Repository(#[from] error::Repository),
    #[error(transparent)]
    Jobs(#[from] error::Jobs),
    #[error(transparent)]
    Announce(#[from] error::Announce),
    #[error(transparent)]
    Show(#[from] error::Show),
    #[error(transparent)]
    New(#[from] error::New),
    #[error(transparent)]
    List(#[from] error::List),
    #[error(transparent)]
    Run(#[from] error::Run),
    #[error(transparent)]
    Failed(#[from] error::Failed),
    #[error(transparent)]
    Succeeded(#[from] error::Succeeded),
}

mod command {
    use clap::Parser;
    use radicle::git::Oid;
    use url::Url;
    use uuid::Uuid;

    #[derive(Parser)]
    pub enum Command {
        New(New),
        List(List),
        Run(Run),
        Show(Show),
        Failed(Failed),
        Succeeded(Succeeded),
    }

    /// List all job COBs for a repository.
    #[derive(Parser)]
    pub struct List {
        /// Format output in a more human oriented way than JSON.
        #[clap(long)]
        pub pretty: bool,
        /// Output all information of listing jobs, including intermediate errors
        #[clap(long, short)]
        pub verbose: bool,
    }

    /// Create a job COB for a specific Git commit.
    ///
    /// Write the job ID to the standard output. It is not usually needed,
    /// as job COBs are found using the Git commit ID they refer to.
    #[derive(Parser)]
    pub struct New {
        /// Git object id for the commit.
        pub oid: Oid,
    }

    /// Show the job COB for a Git commit.
    #[derive(Parser)]
    pub struct Show {
        /// Format output in a more human oriented way than JSON.
        #[clap(long)]
        pub pretty: bool,
        /// Git object for the commit.
        pub oid: Oid,
    }

    /// Record a new run for an existing job COB.
    ///
    /// The new run is marked as started. See subcommands "succeeded" and
    /// "failed" to change status.
    ///
    /// Write the UUID of the new run to standard output. It is needed to
    /// change mark run as finished.
    #[derive(Parser)]
    pub struct Run {
        /// Git commit which the run is processing.
        pub oid: Oid,
        /// URL to information about the run, such a a run log.
        pub url: Url,
    }

    /// Mark a run as having finished in failure.
    #[derive(Parser)]
    pub struct Failed {
        /// Git commit which the run is processing.
        pub oid: Oid,
        /// Run identifier, a UUID.
        pub run: Uuid,
    }

    /// Mark a run as having finished successfully.
    #[derive(Parser)]
    pub struct Succeeded {
        /// Git commit which the run is processing.
        pub oid: Oid,
        /// Run identifier, a UUID.
        pub run: Uuid,
    }
}

mod error {
    use radicle::{
        node::{self, sync::AnnouncerError},
        rad::CwdError,
        storage::RepositoryError,
    };
    use thiserror::Error;

    use super::*;

    #[derive(Debug, Error)]
    pub enum Show {
        #[error(transparent)]
        Find(#[from] Find),
        #[error("failed to show job, could not serialize to JSON")]
        Json(#[source] serde_json::Error),
    }

    #[derive(Debug, Error)]
    #[error("failed to create a new job for the commit {oid}")]
    pub struct New {
        pub oid: Oid,
        #[source]
        pub err: cob::store::Error,
    }

    #[derive(Debug, Error)]
    pub enum List {
        #[error("failed to list jobs")]
        All(#[source] cob::store::Error),
        #[error("failed to list jobs, could not serialize to JSON")]
        Json(#[source] serde_json::Error),
    }

    #[derive(Debug, Error)]
    pub enum Run {
        #[error(transparent)]
        Find(#[from] Find),
        #[error("failed to add new run for job {id}")]
        Store {
            id: JobId,
            #[source]
            err: cob::store::Error,
        },
    }

    #[derive(Debug, Error)]
    pub enum Succeeded {
        #[error(transparent)]
        Find(#[from] Find),
        #[error("failed to mark job {id} as succeeded")]
        Store {
            id: JobId,
            #[source]
            err: cob::store::Error,
        },
    }

    #[derive(Debug, Error)]
    pub enum Failed {
        #[error(transparent)]
        Find(#[from] Find),
        #[error("failed to mark job {id} as failed")]
        Store {
            id: JobId,
            #[source]
            err: cob::store::Error,
        },
    }

    #[derive(Debug, Error)]
    pub enum Find {
        #[error("no job was found for the commit {0}")]
        NoJob(Oid),
        #[error("failed to find a job for the commit {oid}")]
        FindCommit {
            oid: Oid,
            #[source]
            err: cob::store::Error,
        },
    }

    #[derive(Debug, Error)]
    pub enum Repository {
        #[error("failed to find Radicle repository for current working directory")]
        Cwd(#[source] CwdError),
        #[error("failed to open Radicle repository {rid}")]
        Open {
            rid: RepoId,
            #[source]
            err: RepositoryError,
        },
    }

    #[derive(Debug, Error)]
    #[error("failed to load Radicle profile")]
    pub struct Profile(#[source] pub profile::Error);

    #[derive(Debug, Error)]
    #[error("failed to get the signing key of the Radicle profile")]
    pub struct Signer(#[source] pub profile::SignerError);

    #[derive(Debug, Error)]
    pub enum Announce {
        #[error("failed to get seeds for announcing changes")]
        Seeds(#[source] node::Error),
        #[error("failed to announce changes")]
        Announcer(AnnouncerError),
        #[error("failed to announce changes")]
        Announcement(#[source] node::Error),
    }

    #[derive(Debug, Error)]
    #[error("failed to open job store for {rid}")]
    pub struct Jobs {
        pub rid: RepoId,
        #[source]
        pub err: RepositoryError,
    }
}