Radish alpha
r
rad:z2UcCU1LgMshWvXj6hXSDDrwB8q8M
Radicle Job Collaborative Object
Radicle
Git
feat: add program to manipulate and inspect job COBs
Merged liw opened 9 months ago

Signed-off-by: Lars Wirzenius liw@liw.fi

3 files changed +619 -0 574129a2 8530300d
modified Cargo.lock
@@ -82,6 +82,56 @@ dependencies = [
]

[[package]]
+
name = "anstream"
+
version = "0.6.19"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933"
+
dependencies = [
+
 "anstyle",
+
 "anstyle-parse",
+
 "anstyle-query",
+
 "anstyle-wincon",
+
 "colorchoice",
+
 "is_terminal_polyfill",
+
 "utf8parse",
+
]
+

+
[[package]]
+
name = "anstyle"
+
version = "1.0.11"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
+

+
[[package]]
+
name = "anstyle-parse"
+
version = "0.2.7"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+
dependencies = [
+
 "utf8parse",
+
]
+

+
[[package]]
+
name = "anstyle-query"
+
version = "1.1.3"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9"
+
dependencies = [
+
 "windows-sys",
+
]
+

+
[[package]]
+
name = "anstyle-wincon"
+
version = "3.0.9"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882"
+
dependencies = [
+
 "anstyle",
+
 "once_cell_polyfill",
+
 "windows-sys",
+
]
+

+
[[package]]
name = "ascii"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -237,6 +287,53 @@ dependencies = [
]

[[package]]
+
name = "clap"
+
version = "4.5.41"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9"
+
dependencies = [
+
 "clap_builder",
+
 "clap_derive",
+
]
+

+
[[package]]
+
name = "clap_builder"
+
version = "4.5.41"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d"
+
dependencies = [
+
 "anstream",
+
 "anstyle",
+
 "clap_lex",
+
 "strsim",
+
 "terminal_size",
+
]
+

+
[[package]]
+
name = "clap_derive"
+
version = "4.5.41"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491"
+
dependencies = [
+
 "heck",
+
 "proc-macro2",
+
 "quote",
+
 "syn 2.0.101",
+
]
+

+
[[package]]
+
name = "clap_lex"
+
version = "0.7.5"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
+

+
[[package]]
+
name = "colorchoice"
+
version = "1.0.4"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+

+
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -596,6 +693,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3"

[[package]]
+
name = "heck"
+
version = "0.5.0"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+

+
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -733,6 +836,12 @@ dependencies = [
]

[[package]]
+
name = "is_terminal_polyfill"
+
version = "1.70.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
+

+
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -915,6 +1024,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"

[[package]]
+
name = "once_cell_polyfill"
+
version = "1.70.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
+

+
[[package]]
name = "opaque-debug"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1218,12 +1333,14 @@ dependencies = [
name = "radicle-job"
version = "0.1.0"
dependencies = [
+
 "clap",
 "indexmap",
 "nonempty 0.11.0",
 "once_cell",
 "qcheck",
 "radicle",
 "serde",
+
 "serde_json",
 "thiserror 2.0.12",
 "url",
 "uuid",
@@ -1546,6 +1663,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"

[[package]]
+
name = "strsim"
+
version = "0.11.1"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+

+
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1598,6 +1721,16 @@ dependencies = [
]

[[package]]
+
name = "terminal_size"
+
version = "0.4.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed"
+
dependencies = [
+
 "rustix",
+
 "windows-sys",
+
]
+

+
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1712,6 +1845,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"

[[package]]
+
name = "utf8parse"
+
version = "0.2.2"
+
source = "registry+https://github.com/rust-lang/crates.io-index"
+
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+

+
[[package]]
name = "uuid"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
modified Cargo.toml
@@ -12,12 +12,14 @@ edition = "2021"
rust-version = "1.84.0"

[dependencies]
+
clap = { version = "4.5.41", features = ["derive", "wrap_help"] }
indexmap = { version = "2.7.1", features = ["serde"] }
nonempty = "0.11.0"
once_cell = "1.20.3"
qcheck = "1.0.0"
radicle = { version = "0.15" }
serde = { version = "1.0", features = ["derive"] }
+
serde_json = "1.0.140"
thiserror = "2.0.11"
url = { version = "2.5.4", features = ["serde"] }
uuid = { version = "1.13.1", features = ["serde", "v4"] }
added src/bin/rad-job.rs
@@ -0,0 +1,478 @@
+
//! 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, time::Duration};
+

+
use clap::Parser;
+
use serde::Serialize;
+
use url::Url;
+
use uuid::Uuid;
+

+
use radicle::{
+
    git::Oid,
+
    node::{
+
        sync::{Announcer, AnnouncerConfig, ReplicationFactor},
+
        Handle, Node,
+
    },
+
    prelude::{NodeId, Profile, ReadStorage, RepoId},
+
    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<(), JobError> {
+
    let args = Args::parse();
+
    match &args.cmd {
+
        Cmd::Failed(x) => x.run(&args)?,
+
        Cmd::New(x) => x.run(&args)?,
+
        Cmd::List(x) => x.run(&args)?,
+
        Cmd::Run(x) => x.run(&args)?,
+
        Cmd::Show(x) => x.run(&args)?,
+
        Cmd::Succeeded(x) => x.run(&args)?,
+
    }
+
    Ok(())
+
}
+

+
/// Create, update, and query Radicle job COBs.
+
///
+
/// A job COB records 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)]
+
struct Args {
+
    /// Use this repository. Default is the current working directory.
+
    #[clap(short, long)]
+
    repository: Option<RepoId>,
+

+
    #[clap(subcommand)]
+
    cmd: Cmd,
+
}
+

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

+
#[derive(Parser)]
+
enum Cmd {
+
    New(NewCmd),
+
    List(ListCmd),
+
    Run(RunCmd),
+
    Show(ShowCmd),
+
    Failed(FailedCmd),
+
    Succeeded(SucceededCmd),
+
}
+

+
/// List all job COBs for a repository.
+
#[derive(Parser)]
+
struct ListCmd {
+
    /// Format output in a more human oriented way than JSON.
+
    #[clap(long)]
+
    pretty: bool,
+
}
+

+
impl ListCmd {
+
    fn run(&self, args: &Args) -> Result<(), JobError> {
+
        let profile = profile()?;
+
        let repo = args.repository(&profile)?;
+

+
        let jobs = jobs(&repo)?;
+
        let jobslist = JobsList::new(&jobs)?;
+

+
        if self.pretty {
+
            println!("{}", jobslist.pretty());
+
        } else {
+
            println!(
+
                "{}",
+
                serde_json::to_string_pretty(&jobslist).map_err(JobError::ToJson)?
+
            );
+
        }
+

+
        Ok(())
+
    }
+
}
+

+
#[derive(Serialize)]
+
struct JobsList {
+
    count: usize,
+
    jobs: Vec<ShownJob>,
+
}
+

+
impl JobsList {
+
    fn new(jobs: &Jobs<Repository>) -> Result<Self, JobError> {
+
        let mut all_jobs = vec![];
+
        for item in jobs.all().map_err(JobError::AllJobs)? {
+
            let (job_id, job) = item.map_err(JobError::AllJobsJob)?;
+
            let job_id = JobId::from(job_id);
+
            all_jobs.push(ShownJob::new(job_id, &job));
+
        }
+

+
        all_jobs.sort_by_cached_key(|job| job.job_id);
+

+
        Ok(Self {
+
            count: jobs.counts().map_err(JobError::JobCount)?,
+
            jobs: all_jobs,
+
        })
+
    }
+

+
    fn pretty(&self) -> String {
+
        fn line(s: &mut String, line: String) {
+
            s.push_str(&line);
+
            s.push('\n');
+
        }
+

+
        let mut s = String::new();
+

+
        line(&mut s, format!("count: {}", self.count));
+
        for shown in self.jobs.iter() {
+
            line(
+
                &mut s,
+
                format!("job {} (commit {})", shown.job_id, shown.oid),
+
            );
+
            for run in shown.runs.iter() {
+
                line(&mut s, format!("  node {}", run.node_id));
+
                for run2 in run.runs.iter() {
+
                    line(&mut s, format!("    run {} {:?}", run2.run_id, run2.status));
+
                    line(&mut s, format!("      log  {}", run2.log));
+
                }
+
            }
+
        }
+

+
        s
+
    }
+
}
+

+
/// 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)]
+
struct NewCmd {
+
    /// Git object id for the commit.
+
    oid: Oid,
+
}
+

+
impl NewCmd {
+
    fn run(&self, args: &Args) -> Result<(), JobError> {
+
        let profile = profile()?;
+
        let repo = args.repository(&profile)?;
+
        let signer = profile.signer().map_err(JobError::Signer)?;
+

+
        let mut jobs = jobs(&repo)?;
+
        let job = jobs
+
            .create(self.oid, &signer)
+
            .map_err(JobError::CreateJob)?;
+
        announce(&profile, repo.id)?;
+
        println!("{}", job.id());
+

+
        Ok(())
+
    }
+
}
+

+
/// Show the job COB for a Git commit.
+
#[derive(Parser)]
+
struct ShowCmd {
+
    /// Git object for the commit.
+
    oid: Oid,
+
}
+

+
impl ShowCmd {
+
    fn run(&self, args: &Args) -> Result<(), JobError> {
+
        let profile = profile()?;
+
        let repo = args.repository(&profile)?;
+

+
        let jobs = jobs(&repo)?;
+
        let job_id = job_for_commit(&jobs, self.oid)?;
+
        let job = jobs
+
            .get(&job_id)
+
            .map_err(JobError::GetJob)?
+
            .ok_or(JobError::NoSuchJob(job_id))?;
+

+
        let shown = ShownJob::new(job_id, &job);
+
        println!(
+
            "{}",
+
            serde_json::to_string_pretty(&shown).map_err(JobError::ToJson)?
+
        );
+

+
        Ok(())
+
    }
+
}
+

+
#[derive(Serialize)]
+
struct ShownJob {
+
    job_id: JobId,
+
    oid: Oid,
+
    runs: Vec<ShownRuns>,
+
}
+

+
impl ShownJob {
+
    fn new(job_id: JobId, job: &Job) -> Self {
+
        let mut runs: Vec<_> = job
+
            .runs()
+
            .iter()
+
            .map(|(node_id, runs)| {
+
                let mut runs: Vec<ShownRun> = runs
+
                    .iter()
+
                    .map(|(run_id, run)| ShownRun {
+
                        run_id: *run_id,
+
                        status: *run.status(),
+
                        log: run.log().clone(),
+
                    })
+
                    .collect();
+
                runs.sort_by_cached_key(|run| run.run_id);
+
                ShownRuns {
+
                    node_id: *node_id,
+
                    runs,
+
                }
+
            })
+
            .collect();
+
        runs.sort_by_cached_key(|run| run.node_id);
+

+
        Self {
+
            job_id,
+
            oid: *job.oid(),
+
            runs,
+
        }
+
    }
+
}
+

+
#[derive(Serialize)]
+
struct ShownRuns {
+
    node_id: NodeId,
+
    runs: Vec<ShownRun>,
+
}
+

+
#[derive(Serialize)]
+
struct ShownRun {
+
    run_id: Uuid,
+
    status: Status,
+
    log: Url,
+
}
+

+
/// 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)]
+
struct RunCmd {
+
    /// Git commit which the run is processing.
+
    oid: Oid,
+
    /// URL to information about the run, such a a run log.
+
    url: Url,
+
}
+

+
impl RunCmd {
+
    fn run(&self, args: &Args) -> Result<(), JobError> {
+
        let profile = profile()?;
+
        let repo = args.repository(&profile)?;
+
        let signer = profile.signer().map_err(JobError::Signer)?;
+

+
        let mut jobs = jobs(&repo)?;
+
        let job_id = job_for_commit(&jobs, self.oid)?;
+
        let uuid = Uuid::new_v4();
+
        let mut job = jobs.get_mut(&job_id).map_err(JobError::GetJobMut)?;
+
        job.run(uuid, self.url.clone(), &signer)
+
            .map_err(JobError::AddRun)?;
+
        announce(&profile, repo.id)?;
+
        println!("{uuid}");
+
        Ok(())
+
    }
+
}
+

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

+
impl SucceededCmd {
+
    fn run(&self, args: &Args) -> Result<(), JobError> {
+
        let profile = profile()?;
+
        let repo = args.repository(&profile)?;
+
        let signer = profile.signer().map_err(JobError::Signer)?;
+

+
        let mut jobs = jobs(&repo)?;
+
        let job_id = job_for_commit(&jobs, self.oid)?;
+
        let mut job = jobs.get_mut(&job_id).map_err(JobError::GetJobMut)?;
+
        job.finish(self.run, Reason::Succeeded, &signer)
+
            .map_err(JobError::Finish)?;
+
        announce(&profile, repo.id)?;
+

+
        Ok(())
+
    }
+
}
+

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

+
impl FailedCmd {
+
    fn run(&self, args: &Args) -> Result<(), JobError> {
+
        let profile = profile()?;
+
        let repo = args.repository(&profile)?;
+
        let signer = profile.signer().map_err(JobError::Signer)?;
+

+
        let mut jobs = jobs(&repo)?;
+
        let job_id = job_for_commit(&jobs, self.oid)?;
+
        let mut job = jobs.get_mut(&job_id).map_err(JobError::GetJobMut)?;
+
        job.finish(self.run, Reason::Failed, &signer)
+
            .map_err(JobError::Finish)?;
+
        announce(&profile, repo.id)?;
+

+
        Ok(())
+
    }
+
}
+

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

+
fn jobs<'a>(repo: &'a Repository) -> Result<Jobs<'a, Repository>, JobError> {
+
    Jobs::open(repo).map_err(JobError::Jobs)
+
}
+

+
fn job_for_commit<'a>(jobs: &Jobs<'a, Repository>, wanted: Oid) -> Result<JobId, JobError> {
+
    eprintln!("job_for_commit: wanted={wanted}");
+
    for item in jobs.all().map_err(JobError::AllJobs)? {
+
        let (job_id, job) = item.map_err(JobError::AllJobsJob)?;
+
        let job_id = JobId::from(job_id);
+
        eprintln!("job_for_commit: consider {job_id} with oid {}", job.oid());
+
        if job.oid() == &wanted {
+
            eprintln!("job_for_commit: wanted={wanted} => {job_id}");
+
            return Ok(job_id);
+
        }
+
    }
+

+
    Err(JobError::NoJob(wanted))
+
}
+

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

+
    let (synced, unsynced) = node.seeds(repo_id).map_err(JobError::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(|_| JobError::Announcer)?;
+

+
    node.announce(repo_id, TIMEOUT, announcer, |_, _| ())
+
        .map_err(JobError::Announce)?;
+

+
    Ok(())
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
enum JobError {
+
    #[error("failed to open repository in current directory")]
+
    Cwd(#[source] radicle::rad::RemoteError),
+

+
    #[error("failed to load Radicle profile")]
+
    Profile(#[source] radicle::profile::Error),
+

+
    #[error("failed to open repository in Radicle node storage")]
+
    OpenRepository(#[source] radicle::storage::RepositoryError),
+

+
    #[error("failed to list job COBs in repository")]
+
    Jobs(#[source] radicle::storage::RepositoryError),
+

+
    #[error("failed to get job COB count")]
+
    JobCount(#[source] radicle::cob::store::Error),
+

+
    #[error("failed to get all job COBs")]
+
    AllJobs(#[source] radicle::cob::store::Error),
+

+
    #[error("failed to create a new job COB")]
+
    CreateJob(#[source] radicle::cob::store::Error),
+

+
    #[error("failed to get a job COB")]
+
    GetJob(#[source] radicle::cob::store::Error),
+

+
    #[error("failed to create a signer for Radicle repository")]
+
    Signer(#[source] radicle::profile::Error),
+

+
    #[error("didn't find job COB {0}")]
+
    NoSuchJob(JobId),
+

+
    #[error("couldn't get job when iterating")]
+
    AllJobsJob(#[source] radicle::cob::store::Error),
+

+
    #[error("failed to get mutable job COB")]
+
    GetJobMut(#[source] radicle::cob::store::Error),
+

+
    #[error("failed to add a run to a job COB")]
+
    AddRun(#[source] radicle::cob::store::Error),
+

+
    #[error("could not mark a run as finished")]
+
    Finish(#[source] radicle::cob::store::Error),
+

+
    #[error("can't serialize to JSON")]
+
    ToJson(#[source] serde_json::Error),
+

+
    #[error("failed to find job COB for oid {0}")]
+
    NoJob(Oid),
+

+
    #[error("failed to announce COB change")]
+
    Announce(#[source] radicle::node::Error),
+

+
    #[error("failed to get seeds for node")]
+
    Seeds(#[source] radicle::node::Error),
+

+
    #[error("failed to create a COB announcer")]
+
    Announcer,
+
}