Radish alpha
r
Radicle CI broker
Radicle
Git (anonymous pull)
Log in to clone via SSH
refactor(cibtool): structure for subcommand implementation
Lars Wirzenius committed 1 year ago
commit 413fd68642e79dbf7a3cacb35a75b17ae99355f3
parent f75a78e82f81f7f384c7e858fa3cea4a11692820
7 files changed +805 -772
modified src/bin/cibtool.rs
@@ -6,6 +6,8 @@
//! The tool is also used as part of the CI broker's acceptance test
//! suite (see the `ci-broker.subplot` document).

+
#![allow(clippy::result_large_err)]
+

use std::{
    error::Error,
    fs::{read, write},
@@ -35,6 +37,8 @@ use radicle_ci_broker::{
    run::{Run, RunState, Whence},
};

+
mod cibtoolcmd;
+

fn main() {
    if let Err(e) = fallible_main() {
        eprintln!("ERROR: {}", e);
@@ -52,18 +56,32 @@ fn fallible_main() -> Result<(), CibToolError> {
    env_logger::init_from_env("RADICLE_CI_BROKER_LOG");

    let args = Args::parse();
-
    args.run()?;
+
    args.cmd.run(&args)?;

    Ok(())
}

-
/// Radicle CI broker management tool for node operators.
-
///
-
/// Query and update the CI broker database file: the queue of events
-
/// waiting to be processed, the list of CI runs. Also, generate HTML
-
/// report pages from the database.
-
///
-
/// This tool can be used whether the CI broker is running or not.
+
// A trait for subcommands with subcommands. The implementation of
+
// this trait would pick the subcommand value and execute its `run`
+
// method: either via this same trait for the subcommand value, or via
+
// the [`Leaf`] trait.
+
//
+
// Ideally, documentation comments meant to be used as help text by
+
// `clap` would be added to the enum types that list the subcommands,
+
// and types that implement `Leaf` trait, but that's only for
+
// consistency.
+
trait Subcommand {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError>;
+
}
+

+
// A trait for subcommands with no subcommands. These do actual work,
+
// rather than providing a multi-level structure of subcommands. For
+
// `cibtool`, the leaf commands are in the `cibtoolcmd` module, and
+
// the higher level subcommands are in `cbitool.rs`.
+
trait Leaf {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError>;
+
}
+

#[derive(Parser)]
#[command(version)]
struct Args {
@@ -77,43 +95,38 @@ struct Args {
}

impl Args {
-
    #[allow(clippy::result_large_err)]
-
    fn run(&self) -> Result<(), CibToolError> {
-
        match &self.cmd {
-
            Cmd::Counter(x) => x.run(self)?,
-
            Cmd::Event(x) => x.run(self)?,
-
            Cmd::Run(x) => x.run(self)?,
-
            Cmd::Report(x) => x.run(self)?,
-
            Cmd::Trigger(x) => x.run(self)?,
-
        }
-
        Ok(())
-
    }
-

-
    #[allow(clippy::result_large_err)]
    fn open_db(&self) -> Result<Db, CibToolError> {
        Ok(Db::new(&self.db)?)
    }
}

+
/// Radicle CI broker management tool for node operators.
+
///
+
/// Query and update the CI broker database file: the queue of events
+
/// waiting to be processed, the list of CI runs. Also, generate HTML
+
/// report pages from the database.
+
///
+
/// This tool can be used whether the CI broker is running or not.
#[derive(Parser)]
enum Cmd {
-
    /// Manage a counter in the database. This is meant to be used
-
    /// only by the CI broker test suite, not by people.
    #[clap(hide = true)]
    Counter(CounterCmd),
-

-
    /// Manage the event queue. The events are for Git refs having
-
    /// changed.
    Event(EventCmd),
-

-
    /// Manage the list of CI runs.
    Run(RunCmd),
+
    Report(cibtoolcmd::ReportCmd),
+
    Trigger(cibtoolcmd::TriggerCmd),
+
}

-
    /// Produce HTML reports based on database contents.
-
    Report(ReportCmd),
-

-
    /// Trigger a CI run.
-
    Trigger(TriggerCmd),
+
impl Subcommand for Cmd {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        match self {
+
            Self::Counter(x) => x.run(args),
+
            Self::Event(x) => x.run(args),
+
            Self::Run(x) => x.run(args),
+
            Self::Report(x) => x.run(args),
+
            Self::Trigger(x) => x.run(args),
+
        }
+
    }
}

#[derive(Parser)]
@@ -123,7 +136,6 @@ struct CounterCmd {
}

impl CounterCmd {
-
    #[allow(clippy::result_large_err)]
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
        match &self.cmd {
            CounterSubCmd::Show(x) => x.run(args)?,
@@ -133,77 +145,20 @@ impl CounterCmd {
    }
}

+
/// Manage a counter in the database. This is meant to be used
+
/// only by the CI broker test suite, not by people.
#[derive(Parser)]
enum CounterSubCmd {
-
    /// Show the current value of the counter.
-
    Show(ShowCounter),
-

-
    /// Count until the counter reaches a minimum value.
-
    Count(CountCounter),
-
}
-

-
#[derive(Parser)]
-
struct ShowCounter {}
-

-
impl ShowCounter {
-
    #[allow(clippy::result_large_err)]
-
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
-
        let db = args.open_db()?;
-
        let counter = db.get_counter()?;
-
        println!("{}", counter.unwrap_or(0));
-
        Ok(())
-
    }
+
    Show(cibtoolcmd::ShowCounter),
+
    Count(cibtoolcmd::CountCounter),
}

-
#[derive(Parser)]
-
struct CountCounter {
-
    /// The minimum value which counting aims at.
-
    #[clap(long)]
-
    goal: i64,
-
}
-

-
impl CountCounter {
-
    #[allow(clippy::result_large_err)]
+
impl Subcommand for CounterSubCmd {
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
-
        let db = args.open_db()?;
-
        Self::inc(&db, self.goal)?;
-

-
        Ok(())
-
    }
-

-
    fn inc(db: &Db, goal: i64) -> Result<(), DbError> {
-
        let mut prev: i64 = -1;
-
        loop {
-
            db.begin()?;
-
            println!("BEGIN");
-

-
            let current = db.get_counter()?;
-
            println!("  current as read={current:?}");
-
            let current = current.unwrap_or(0);
-
            println!("  current: {current}; prev={prev}");
-
            if current < prev {
-
                panic!("current < prev");
-
            }
-
            if current >= goal {
-
                println!("GOAL");
-
                db.rollback()?;
-
                println!("ROLLBACK");
-
                break;
-
            }
-

-
            let new = current + 1;
-
            if (new == 1 && db.create_counter(new).is_err()) || db.update_counter(new).is_err() {
-
                db.rollback()?;
-
                println!("ROLLBACK");
-
            } else {
-
                println!("  increment to {new}");
-
                db.commit()?;
-
                println!("COMMIT");
-
                prev = new;
-
            }
+
        match self {
+
            Self::Show(x) => x.run(args),
+
            Self::Count(x) => x.run(args),
        }
-

-
        Ok(())
    }
}

@@ -213,8 +168,7 @@ struct EventCmd {
    cmd: EventSubCmd,
}

-
impl EventCmd {
-
    #[allow(clippy::result_large_err)]
+
impl Subcommand for EventCmd {
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
        match &self.cmd {
            EventSubCmd::Add(x) => x.run(args)?,
@@ -228,300 +182,16 @@ impl EventCmd {
    }
}

+
/// Manage the event queue. The events are for Git refs having
+
/// changed.
#[derive(Parser)]
enum EventSubCmd {
-
    /// List events in the queue, waiting to be processed.
-
    List(ListEvents),
-

-
    /// Show the number of events in the queue.
-
    Count(CountEvents),
-

-
    /// Add an event to the queue.
-
    Add(AddEvent),
-

-
    /// Add a shutdown event to the queue.
-
    ///
-
    /// The shutdown event causes the CI broker to terminate.
-
    Shutdown(Shutdown),
-

-
    /// Show an event in the queue.
-
    Show(ShowEvent),
-

-
    /// Remove an event from the queue.
-
    Remove(RemoveEvent),
-
}
-

-
#[derive(Parser)]
-
struct ListEvents {
-
    /// Show more details about the event.
-
    #[clap(long)]
-
    verbose: bool,
-
}
-

-
impl ListEvents {
-
    #[allow(clippy::result_large_err)]
-
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
-
        let db = args.open_db()?;
-
        for id in db.queued_events()? {
-
            if self.verbose {
-
                if let Some(e) = db.get_queued_event(&id)? {
-
                    println!("{id}: {:?}", e);
-
                } else {
-
                    println!("{id}: No such event");
-
                }
-
            } else {
-
                println!("{id}");
-
            }
-
        }
-
        Ok(())
-
    }
-
}
-

-
#[derive(Parser)]
-
struct CountEvents {}
-

-
impl CountEvents {
-
    #[allow(clippy::result_large_err)]
-
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
-
        let db = args.open_db()?;
-
        println!("{}", db.queued_events()?.len());
-
        Ok(())
-
    }
-
}
-

-
#[derive(Parser)]
-
struct AddEvent {
-
    /// Set the repository the event refers to. Can be a RID, or the
-
    /// repository name.
-
    #[clap(long)]
-
    repo: String,
-

-
    /// Set the name of the ref the event refers to.
-
    #[clap(long, alias = "ref")]
-
    name: String,
-

-
    /// Set the commit the event refers to. Can be the SHA1 commit id,
-
    /// or a symbolic Git revision, as understood by `git rev-parse`.
-
    /// For example, `HEAD`.
-
    #[clap(long)]
-
    commit: String,
-

-
    /// The base commit referred to by the event. Optional, but must
-
    /// be a SHA commit id.
-
    #[clap(long)]
-
    base: Option<Oid>,
-

-
    /// Write the event to this file, as JSON, instead of adding it to
-
    /// the queue.
-
    #[clap(long)]
-
    output: Option<PathBuf>,
-

-
    /// Write the event ID to this file, after adding the event to the
-
    /// queue.
-
    #[clap(long)]
-
    id_file: Option<PathBuf>,
-
}
-

-
impl AddEvent {
-
    #[allow(clippy::result_large_err)]
-
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
-
        let rid = if let Ok(rid) = RepoId::from_urn(&self.repo) {
-
            rid
-
        } else {
-
            self.lookup_rid(&self.repo)?
-
        };
-

-
        let oid = if let Ok(rid) = Oid::from_str(&self.commit) {
-
            rid
-
        } else {
-
            self.lookup_commit(rid, &self.commit)?
-
        };
-

-
        let name = format!(
-
            "refs/namespaces/{}/refs/heads/{}",
-
            self.lookup_nid()?,
-
            self.name.as_str()
-
        );
-
        let name =
-
            RefString::try_from(name.clone()).map_err(|e| CibToolError::RefString(name, e))?;
-

-
        let event = BrokerEvent::new(&rid, &name, &oid, self.base);
-

-
        if let Some(output) = &self.output {
-
            let json = serde_json::to_string_pretty(&event)
-
                .map_err(|e| CibToolError::EventToJson(event.clone(), e))?;
-
            std::fs::write(output, json.as_bytes())
-
                .map_err(|e| CibToolError::Write(output.into(), e))?;
-
        } else {
-
            let db = args.open_db()?;
-
            let id = db.push_queued_event(event)?;
-
            println!("{id}");
-

-
            if let Some(filename) = &self.id_file {
-
                write(filename, id.to_string().as_bytes())
-
                    .map_err(|e| CibToolError::WriteEventId(filename.into(), e))?;
-
            }
-
        }
-
        Ok(())
-
    }
-

-
    #[allow(clippy::result_large_err)]
-
    fn lookup_nid(&self) -> Result<NodeId, CibToolError> {
-
        let profile = Profile::load().map_err(CibToolError::Profile)?;
-
        Ok(*profile.id())
-
    }
-

-
    #[allow(clippy::result_large_err)]
-
    fn lookup_rid(&self, wanted: &str) -> Result<RepoId, CibToolError> {
-
        let profile = Profile::load().map_err(CibToolError::Profile)?;
-
        let storage =
-
            Storage::open(profile.storage(), profile.info()).map_err(CibToolError::Storage)?;
-

-
        let mut rid = None;
-
        let repo_infos = storage.repositories().map_err(CibToolError::Repositories)?;
-
        for ri in repo_infos {
-
            let project = ri
-
                .doc
-
                .project()
-
                .map_err(|e| CibToolError::Project(ri.rid, e))?;
-

-
            if project.name() == wanted {
-
                if rid.is_some() {
-
                    return Err(CibToolError::DuplicateRepositories(wanted.into()));
-
                }
-
                rid = Some(ri.rid);
-
            }
-
        }
-

-
        if let Some(rid) = rid {
-
            Ok(rid)
-
        } else {
-
            Err(CibToolError::NotFound(wanted.into()))
-
        }
-
    }
-

-
    #[allow(clippy::result_large_err)]
-
    fn lookup_commit(&self, rid: RepoId, gitref: &str) -> Result<Oid, CibToolError> {
-
        let profile = Profile::load().map_err(CibToolError::Profile)?;
-
        let storage =
-
            Storage::open(profile.storage(), profile.info()).map_err(CibToolError::Storage)?;
-
        let repo = storage
-
            .repository(rid)
-
            .map_err(|e| CibToolError::RepoOpen(rid, e))?;
-
        let object = repo
-
            .backend
-
            .revparse_single(gitref)
-
            .map_err(|e| CibToolError::RevParse(gitref.into(), e))?;
-

-
        Ok(object.id().into())
-
    }
-
}
-

-
#[derive(Parser)]
-
struct ShowEvent {
-
    /// ID of event to show.
-
    #[clap(long, required_unless_present = "id_file")]
-
    id: Option<QueueId>,
-

-
    /// Show event as JSON? Default is a debugging format useful for
-
    /// programmers.
-
    #[clap(long)]
-
    json: bool,
-

-
    /// Write output to this file.
-
    #[clap(long)]
-
    output: Option<PathBuf>,
-

-
    /// Read ID of event to show from this file.
-
    #[clap(long)]
-
    id_file: Option<PathBuf>,
-
}
-

-
impl ShowEvent {
-
    #[allow(clippy::result_large_err)]
-
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
-
        let db = args.open_db()?;
-

-
        let id = if let Some(id) = &self.id {
-
            id.clone()
-
        } else if let Some(filename) = &self.id_file {
-
            let id = read(filename).map_err(|e| CibToolError::ReadEventId(filename.into(), e))?;
-
            let id = String::from_utf8_lossy(&id).to_string();
-
            QueueId::from(&id)
-
        } else {
-
            return Err(CibToolError::MissingId);
-
        };
-

-
        if let Some(event) = db.get_queued_event(&id)? {
-
            if self.json {
-
                let json = serde_json::to_string_pretty(&event.event())
-
                    .map_err(|e| CibToolError::EventToJson(event.event().clone(), e))?;
-
                if let Some(filename) = &self.output {
-
                    std::fs::write(filename, json.as_bytes())
-
                        .map_err(|e| CibToolError::Write(filename.into(), e))?;
-
                } else {
-
                    println!("{json}");
-
                }
-
            } else {
-
                println!("{event:#?}");
-
            }
-
        }
-
        Ok(())
-
    }
-
}
-

-
#[derive(Parser)]
-
struct RemoveEvent {
-
    /// ID of event to remove.
-
    #[clap(long, required_unless_present = "id_file")]
-
    id: Option<QueueId>,
-

-
    /// Read ID of event to remove from this file.
-
    #[clap(long)]
-
    id_file: Option<PathBuf>,
-
}
-

-
impl RemoveEvent {
-
    #[allow(clippy::result_large_err)]
-
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
-
        let db = args.open_db()?;
-

-
        let id = if let Some(id) = &self.id {
-
            id.clone()
-
        } else if let Some(filename) = &self.id_file {
-
            let id = read(filename).map_err(|e| CibToolError::ReadEventId(filename.into(), e))?;
-
            let id = String::from_utf8_lossy(&id).to_string();
-
            QueueId::from(&id)
-
        } else {
-
            return Err(CibToolError::MissingId);
-
        };
-

-
        db.remove_queued_event(&id)?;
-
        Ok(())
-
    }
-
}
-

-
#[derive(Parser)]
-
struct Shutdown {
-
    /// Write ID of the event to this file, after adding the event to
-
    /// the queue.
-
    #[clap(long)]
-
    id_file: Option<PathBuf>,
-
}
-

-
impl Shutdown {
-
    #[allow(clippy::result_large_err)]
-
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
-
        let db = args.open_db()?;
-
        let id = db.push_queued_event(BrokerEvent::Shutdown)?;
-

-
        if let Some(filename) = &self.id_file {
-
            write(filename, id.to_string().as_bytes())
-
                .map_err(|e| CibToolError::WriteEventId(filename.into(), e))?;
-
        }
-

-
        Ok(())
-
    }
+
    List(cibtoolcmd::ListEvents),
+
    Count(cibtoolcmd::CountEvents),
+
    Add(cibtoolcmd::AddEvent),
+
    Shutdown(cibtoolcmd::Shutdown),
+
    Show(cibtoolcmd::ShowEvent),
+
    Remove(cibtoolcmd::RemoveEvent),
}

#[derive(Parser)]
@@ -530,8 +200,7 @@ struct RunCmd {
    cmd: RunSubCmd,
}

-
impl RunCmd {
-
    #[allow(clippy::result_large_err)]
+
impl Subcommand for RunCmd {
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
        match &self.cmd {
            RunSubCmd::Add(x) => x.run(args)?,
@@ -543,392 +212,20 @@ impl RunCmd {
    }
}

+
/// Manage the list of CI runs.
#[derive(Parser)]
enum RunSubCmd {
    /// Add information about a CI run to the database.
-
    Add(AddRun),
+
    Add(cibtoolcmd::AddRun),

    /// Update information about a CI run to the database.
-
    Update(UpdateRun),
+
    Update(cibtoolcmd::UpdateRun),

    /// Show a CI run as JSON.
-
    Show(ShowRun),
+
    Show(cibtoolcmd::ShowRun),

    /// List known CI runs on this node to the database.
-
    List(ListRuns),
-
}
-

-
#[derive(Parser)]
-
struct AddRun {
-
    /// Set the adapter run ID.
-
    #[clap(long)]
-
    id: Option<RunId>,
-

-
    /// Set alias of node that performed the CI run.
-
    #[clap(long)]
-
    alias: String,
-

-
    /// Set optional URL to information about the CI run.
-
    #[clap(long)]
-
    url: Option<String>,
-

-
    /// Set the repository ID that the CI run for.
-
    #[clap(long)]
-
    repo: RepoId,
-

-
    /// Set timestamp of the CI run.
-
    #[clap(long)]
-
    timestamp: String,
-

-
    /// Set the Git branch used by the CI run.
-
    #[clap(long)]
-
    branch: String,
-

-
    /// Set the commit SHA ID used by the CI run.
-
    #[clap(long)]
-
    commit: Oid,
-

-
    /// Set the author of the commit used by the CI run.
-
    #[clap(long)]
-
    who: Option<String>,
-

-
    /// Set the state of the CI run to "triggered".
-
    #[clap(long, required_unless_present_any = ["running", "success", "failure"])]
-
    triggered: bool,
-

-
    /// Set the state of the CI run to "running".
-
    #[clap(long)]
-
    #[clap(long, required_unless_present_any = ["triggered", "success", "failure"])]
-
    running: bool,
-

-
    /// Mark the CI run as finished and successful.
-
    #[clap(long, required_unless_present_any = ["triggered", "running", "failure"])]
-
    success: bool,
-

-
    /// Mark the CI run as finished and failed.
-
    #[clap(long, required_unless_present_any = ["triggered", "running", "success"])]
-
    failure: bool,
-
}
-

-
impl AddRun {
-
    #[allow(clippy::result_large_err)]
-
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
-
        let db = args.open_db()?;
-

-
        let whence = Whence::Branch {
-
            name: self.branch.clone(),
-
            commit: self.commit,
-
            who: self.who.clone(),
-
        };
-
        let mut run = Run::new(self.repo, &self.alias, whence, self.timestamp.clone());
-

-
        let id = self.id.clone().unwrap_or_default();
-
        run.set_adapter_run_id(id);
-

-
        if let Some(url) = &self.url {
-
            run.set_adapter_info_url(url);
-
        }
-

-
        if self.success {
-
            run.set_result(RunResult::Success);
-
        } else if self.failure {
-
            run.set_result(RunResult::Failure);
-
        } else {
-
            run.unset_result();
-
        }
-

-
        db.push_run(&run)?;
-

-
        Ok(())
-
    }
-
}
-

-
#[derive(Parser)]
-
struct UpdateRun {
-
    /// ID of run to update.
-
    #[clap(long)]
-
    id: RunId,
-

-
    /// Set the state of the CI run to "triggered".
-
    #[clap(long, required_unless_present_any = ["running", "success", "failure"])]
-
    triggered: bool,
-

-
    /// Set the state of the CI run to "running".
-
    #[clap(long)]
-
    #[clap(long, required_unless_present_any = ["triggered", "success", "failure"])]
-
    running: bool,
-

-
    /// Mark the CI run as finished and successful.
-
    #[clap(long, required_unless_present_any = ["triggered", "running", "failure"])]
-
    success: bool,
-

-
    /// Mark the CI run as finished and failed.
-
    #[clap(long, required_unless_present_any = ["triggered", "running", "success"])]
-
    failure: bool,
-
}
-

-
impl UpdateRun {
-
    #[allow(clippy::result_large_err)]
-
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
-
        let db = args.open_db()?;
-

-
        let runs = db.find_runs(&self.id)?;
-
        if runs.is_empty() {
-
            Err(CibToolError::RunNotFound(self.id.clone()))
-
        } else {
-
            let mut run = runs.first().unwrap().clone(); // this is safe: runs is not empty
-

-
            run.unset_result();
-
            if self.triggered {
-
                run.set_state(RunState::Triggered);
-
            } else if self.running {
-
                run.set_state(RunState::Running);
-
            } else if self.success {
-
                run.set_state(RunState::Finished);
-
                run.set_result(RunResult::Success);
-
            } else if self.failure {
-
                run.set_state(RunState::Finished);
-
                run.set_result(RunResult::Failure);
-
            }
-

-
            db.update_run(&run)?;
-
            Ok(())
-
        }
-
    }
-
}
-

-
#[derive(Parser)]
-
struct ShowRun {
-
    /// Broker or adapter run IC.
-
    id: RunId,
-
}
-

-
impl ShowRun {
-
    #[allow(clippy::result_large_err)]
-
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
-
        let db = args.open_db()?;
-
        let runs = db.find_runs(&self.id)?;
-
        let json = serde_json::to_string_pretty(&runs).map_err(CibToolError::RunToJson)?;
-
        println!("{json}");
-

-
        Ok(())
-
    }
-
}
-

-
#[derive(Parser)]
-
struct ListRuns {
-
    #[clap(long)]
-
    json: bool,
-

-
    #[clap(long)]
-
    adapter_run_id: Option<RunId>,
-
}
-

-
impl ListRuns {
-
    #[allow(clippy::result_large_err)]
-
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
-
        let db = args.open_db()?;
-

-
        let runs = if let Some(wanted) = &self.adapter_run_id {
-
            db.find_runs(wanted)?
-
        } else {
-
            db.get_all_runs()?
-
        };
-

-
        if self.json {
-
            println!(
-
                "{}",
-
                serde_json::to_string_pretty(&runs).map_err(CibToolError::RunToJson)?
-
            );
-
        } else {
-
            for run in runs {
-
                println!(
-
                    "{} {}",
-
                    run.broker_run_id(),
-
                    run.adapter_run_id()
-
                        .map(|id| id.to_string())
-
                        .unwrap_or("unknown".into())
-
                );
-
            }
-
        }
-

-
        Ok(())
-
    }
-
}
-

-
#[derive(Serialize)]
-
struct RunInfo {
-
    run_id: String,
-
    timestamp: String,
-
    repo_id: String,
-
    repo_alias: String,
-
    whence: Whence,
-
    info_url: Option<String>,
-
    state: RunState,
-
    result: Option<RunResult>,
-
}
-

-
impl From<&Run> for RunInfo {
-
    fn from(run: &Run) -> Self {
-
        RunInfo {
-
            run_id: run
-
                .adapter_run_id()
-
                .map(|id| id.to_string())
-
                .unwrap_or("unknown".into()),
-
            timestamp: run.timestamp().into(),
-
            repo_id: run.repo_id().to_string(),
-
            repo_alias: run.repo_alias().into(),
-
            whence: run.whence().clone(),
-
            info_url: run.adapter_info_url().map(|url| url.into()),
-
            state: run.state(),
-
            result: run.result().cloned(),
-
        }
-
    }
-
}
-

-
#[derive(Parser)]
-
struct ReportCmd {
-
    /// Write HTML files to this directory. The directory must exist:
-
    /// it is not created automatically.
-
    #[clap(long)]
-
    output_dir: PathBuf,
-
}
-

-
impl ReportCmd {
-
    #[allow(clippy::result_large_err)]
-
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
-
        let profile = Profile::load().map_err(CibToolError::Profile)?;
-

-
        let db = args.open_db()?;
-

-
        let mut page = PageBuilder::default()
-
            .node_alias(&profile.config.node.alias)
-
            .runs(db.get_all_runs()?)
-
            .build()?;
-

-
        page.set_output_dir(&self.output_dir);
-

-
        let mut run_notification = NotificationChannel::default();
-
        let thread =
-
            page.update_in_thread(run_notification.rx(), db, &profile.config.node.alias, true);
-
        thread.join().unwrap()?;
-

-
        Ok(())
-
    }
-
}
-

-
#[derive(Parser)]
-
struct TriggerCmd {
-
    /// Set the repository the event refers to. Can be a RID, or the
-
    /// repository name.
-
    #[clap(long)]
-
    repo: String,
-

-
    /// Set the name of the ref the event refers to.
-
    #[clap(long, alias = "ref")]
-
    name: String,
-

-
    /// Set the commit the event refers to. Can be the SHA1 commit id,
-
    /// or a symbolic Git revision, as understood by `git rev-parse`.
-
    /// For example, `HEAD`.
-
    #[clap(long)]
-
    commit: String,
-

-
    /// Write the event ID to this file, after adding the event to the
-
    /// queue.
-
    #[clap(long)]
-
    id_file: Option<PathBuf>,
-
}
-

-
impl TriggerCmd {
-
    #[allow(clippy::result_large_err)]
-
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
-
        let rid = if let Ok(rid) = RepoId::from_urn(&self.repo) {
-
            rid
-
        } else {
-
            self.lookup_rid(&self.repo)?
-
        };
-

-
        let oid = if let Ok(rid) = Oid::from_str(&self.commit) {
-
            rid
-
        } else {
-
            self.lookup_commit(rid, &self.commit)?
-
        };
-

-
        let name = format!(
-
            "refs/namespaces/{}/refs/heads/{}",
-
            self.lookup_nid()?,
-
            self.name.as_str()
-
        );
-
        let name =
-
            RefString::try_from(name.clone()).map_err(|e| CibToolError::RefString(name, e))?;
-

-
        let base = self.lookup_commit(rid, &format!("{oid}^")).unwrap_or(oid);
-

-
        let event = BrokerEvent::new(&rid, &name, &oid, Some(base));
-

-
        let db = args.open_db()?;
-
        let id = db.push_queued_event(event)?;
-
        println!("{id}");
-

-
        if let Some(filename) = &self.id_file {
-
            write(filename, id.to_string().as_bytes())
-
                .map_err(|e| CibToolError::WriteEventId(filename.into(), e))?;
-
        }
-

-
        Ok(())
-
    }
-

-
    #[allow(clippy::result_large_err)]
-
    fn lookup_nid(&self) -> Result<NodeId, CibToolError> {
-
        let profile = Profile::load().map_err(CibToolError::Profile)?;
-
        Ok(*profile.id())
-
    }
-

-
    #[allow(clippy::result_large_err)]
-
    fn lookup_rid(&self, wanted: &str) -> Result<RepoId, CibToolError> {
-
        let profile = Profile::load().map_err(CibToolError::Profile)?;
-
        let storage =
-
            Storage::open(profile.storage(), profile.info()).map_err(CibToolError::Storage)?;
-

-
        let mut rid = None;
-
        let repo_infos = storage.repositories().map_err(CibToolError::Repositories)?;
-
        for ri in repo_infos {
-
            let project = ri
-
                .doc
-
                .project()
-
                .map_err(|e| CibToolError::Project(ri.rid, e))?;
-

-
            if project.name() == wanted {
-
                if rid.is_some() {
-
                    return Err(CibToolError::DuplicateRepositories(wanted.into()));
-
                }
-
                rid = Some(ri.rid);
-
            }
-
        }
-

-
        if let Some(rid) = rid {
-
            Ok(rid)
-
        } else {
-
            Err(CibToolError::NotFound(wanted.into()))
-
        }
-
    }
-

-
    #[allow(clippy::result_large_err)]
-
    fn lookup_commit(&self, rid: RepoId, gitref: &str) -> Result<Oid, CibToolError> {
-
        let profile = Profile::load().map_err(CibToolError::Profile)?;
-
        let storage =
-
            Storage::open(profile.storage(), profile.info()).map_err(CibToolError::Storage)?;
-
        let repo = storage
-
            .repository(rid)
-
            .map_err(|e| CibToolError::RepoOpen(rid, e))?;
-
        let object = repo
-
            .backend
-
            .revparse_single(gitref)
-
            .map_err(|e| CibToolError::RevParse(gitref.into(), e))?;
-

-
        Ok(object.id().into())
-
    }
+
    List(cibtoolcmd::ListRuns),
}

#[derive(Debug, thiserror::Error)]
added src/bin/cibtoolcmd/counter.rs
@@ -0,0 +1,68 @@
+
use super::*;
+

+
/// Show the current value of the counter.
+
#[derive(Parser)]
+
pub struct ShowCounter {}
+

+
impl Leaf for ShowCounter {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        let db = args.open_db()?;
+
        let counter = db.get_counter()?;
+
        println!("{}", counter.unwrap_or(0));
+
        Ok(())
+
    }
+
}
+

+
/// Count until the counter reaches a minimum value.
+
#[derive(Parser)]
+
pub struct CountCounter {
+
    /// The minimum value which counting aims at.
+
    #[clap(long)]
+
    goal: i64,
+
}
+

+
impl CountCounter {
+
    fn inc(db: &Db, goal: i64) -> Result<(), DbError> {
+
        let mut prev: i64 = -1;
+
        loop {
+
            db.begin()?;
+
            println!("BEGIN");
+

+
            let current = db.get_counter()?;
+
            println!("  current as read={current:?}");
+
            let current = current.unwrap_or(0);
+
            println!("  current: {current}; prev={prev}");
+
            if current < prev {
+
                panic!("current < prev");
+
            }
+
            if current >= goal {
+
                println!("GOAL");
+
                db.rollback()?;
+
                println!("ROLLBACK");
+
                break;
+
            }
+

+
            let new = current + 1;
+
            if (new == 1 && db.create_counter(new).is_err()) || db.update_counter(new).is_err() {
+
                db.rollback()?;
+
                println!("ROLLBACK");
+
            } else {
+
                println!("  increment to {new}");
+
                db.commit()?;
+
                println!("COMMIT");
+
                prev = new;
+
            }
+
        }
+

+
        Ok(())
+
    }
+
}
+

+
impl Leaf for CountCounter {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        let db = args.open_db()?;
+
        Self::inc(&db, self.goal)?;
+

+
        Ok(())
+
    }
+
}
added src/bin/cibtoolcmd/event.rs
@@ -0,0 +1,275 @@
+
use super::*;
+

+
/// List events in the queue, waiting to be processed.
+
#[derive(Parser)]
+
pub struct ListEvents {
+
    /// Show more details about the event.
+
    #[clap(long)]
+
    verbose: bool,
+
}
+

+
impl Leaf for ListEvents {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        let db = args.open_db()?;
+
        for id in db.queued_events()? {
+
            if self.verbose {
+
                if let Some(e) = db.get_queued_event(&id)? {
+
                    println!("{id}: {:?}", e);
+
                } else {
+
                    println!("{id}: No such event");
+
                }
+
            } else {
+
                println!("{id}");
+
            }
+
        }
+
        Ok(())
+
    }
+
}
+

+
/// Show the number of events in the queue.
+
#[derive(Parser)]
+
pub struct CountEvents {}
+

+
impl Leaf for CountEvents {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        let db = args.open_db()?;
+
        println!("{}", db.queued_events()?.len());
+
        Ok(())
+
    }
+
}
+

+
/// Add an event to the queue.
+
#[derive(Parser)]
+
pub struct AddEvent {
+
    /// Set the repository the event refers to. Can be a RID, or the
+
    /// repository name.
+
    #[clap(long)]
+
    repo: String,
+

+
    /// Set the name of the ref the event refers to.
+
    #[clap(long, alias = "ref")]
+
    name: String,
+

+
    /// Set the commit the event refers to. Can be the SHA1 commit id,
+
    /// or a symbolic Git revision, as understood by `git rev-parse`.
+
    /// For example, `HEAD`.
+
    #[clap(long)]
+
    commit: String,
+

+
    /// The base commit referred to by the event. Optional, but must
+
    /// be a SHA commit id.
+
    #[clap(long)]
+
    base: Option<Oid>,
+

+
    /// Write the event to this file, as JSON, instead of adding it to
+
    /// the queue.
+
    #[clap(long)]
+
    output: Option<PathBuf>,
+

+
    /// Write the event ID to this file, after adding the event to the
+
    /// queue.
+
    #[clap(long)]
+
    id_file: Option<PathBuf>,
+
}
+

+
impl AddEvent {
+
    fn lookup_nid(&self) -> Result<NodeId, CibToolError> {
+
        let profile = Profile::load().map_err(CibToolError::Profile)?;
+
        Ok(*profile.id())
+
    }
+

+
    fn lookup_rid(&self, wanted: &str) -> Result<RepoId, CibToolError> {
+
        let profile = Profile::load().map_err(CibToolError::Profile)?;
+
        let storage =
+
            Storage::open(profile.storage(), profile.info()).map_err(CibToolError::Storage)?;
+

+
        let mut rid = None;
+
        let repo_infos = storage.repositories().map_err(CibToolError::Repositories)?;
+
        for ri in repo_infos {
+
            let project = ri
+
                .doc
+
                .project()
+
                .map_err(|e| CibToolError::Project(ri.rid, e))?;
+

+
            if project.name() == wanted {
+
                if rid.is_some() {
+
                    return Err(CibToolError::DuplicateRepositories(wanted.into()));
+
                }
+
                rid = Some(ri.rid);
+
            }
+
        }
+

+
        if let Some(rid) = rid {
+
            Ok(rid)
+
        } else {
+
            Err(CibToolError::NotFound(wanted.into()))
+
        }
+
    }
+

+
    fn lookup_commit(&self, rid: RepoId, gitref: &str) -> Result<Oid, CibToolError> {
+
        let profile = Profile::load().map_err(CibToolError::Profile)?;
+
        let storage =
+
            Storage::open(profile.storage(), profile.info()).map_err(CibToolError::Storage)?;
+
        let repo = storage
+
            .repository(rid)
+
            .map_err(|e| CibToolError::RepoOpen(rid, e))?;
+
        let object = repo
+
            .backend
+
            .revparse_single(gitref)
+
            .map_err(|e| CibToolError::RevParse(gitref.into(), e))?;
+

+
        Ok(object.id().into())
+
    }
+
}
+

+
impl Leaf for AddEvent {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        let rid = if let Ok(rid) = RepoId::from_urn(&self.repo) {
+
            rid
+
        } else {
+
            self.lookup_rid(&self.repo)?
+
        };
+

+
        let oid = if let Ok(rid) = Oid::from_str(&self.commit) {
+
            rid
+
        } else {
+
            self.lookup_commit(rid, &self.commit)?
+
        };
+

+
        let name = format!(
+
            "refs/namespaces/{}/refs/heads/{}",
+
            self.lookup_nid()?,
+
            self.name.as_str()
+
        );
+
        let name =
+
            RefString::try_from(name.clone()).map_err(|e| CibToolError::RefString(name, e))?;
+

+
        let event = BrokerEvent::new(&rid, &name, &oid, self.base);
+

+
        if let Some(output) = &self.output {
+
            let json = serde_json::to_string_pretty(&event)
+
                .map_err(|e| CibToolError::EventToJson(event.clone(), e))?;
+
            std::fs::write(output, json.as_bytes())
+
                .map_err(|e| CibToolError::Write(output.into(), e))?;
+
        } else {
+
            let db = args.open_db()?;
+
            let id = db.push_queued_event(event)?;
+
            println!("{id}");
+

+
            if let Some(filename) = &self.id_file {
+
                write(filename, id.to_string().as_bytes())
+
                    .map_err(|e| CibToolError::WriteEventId(filename.into(), e))?;
+
            }
+
        }
+
        Ok(())
+
    }
+
}
+

+
/// Show an event in the queue.
+
#[derive(Parser)]
+
pub struct ShowEvent {
+
    /// ID of event to show.
+
    #[clap(long, required_unless_present = "id_file")]
+
    id: Option<QueueId>,
+

+
    /// Show event as JSON? Default is a debugging format useful for
+
    /// programmers.
+
    #[clap(long)]
+
    json: bool,
+

+
    /// Write output to this file.
+
    #[clap(long)]
+
    output: Option<PathBuf>,
+

+
    /// Read ID of event to show from this file.
+
    #[clap(long)]
+
    id_file: Option<PathBuf>,
+
}
+

+
impl Leaf for ShowEvent {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        let db = args.open_db()?;
+

+
        let id = if let Some(id) = &self.id {
+
            id.clone()
+
        } else if let Some(filename) = &self.id_file {
+
            let id = read(filename).map_err(|e| CibToolError::ReadEventId(filename.into(), e))?;
+
            let id = String::from_utf8_lossy(&id).to_string();
+
            QueueId::from(&id)
+
        } else {
+
            return Err(CibToolError::MissingId);
+
        };
+

+
        if let Some(event) = db.get_queued_event(&id)? {
+
            if self.json {
+
                let json = serde_json::to_string_pretty(&event.event())
+
                    .map_err(|e| CibToolError::EventToJson(event.event().clone(), e))?;
+
                if let Some(filename) = &self.output {
+
                    std::fs::write(filename, json.as_bytes())
+
                        .map_err(|e| CibToolError::Write(filename.into(), e))?;
+
                } else {
+
                    println!("{json}");
+
                }
+
            } else {
+
                println!("{event:#?}");
+
            }
+
        }
+
        Ok(())
+
    }
+
}
+

+
/// Remove an event from the queue.
+
#[derive(Parser)]
+
pub struct RemoveEvent {
+
    /// ID of event to remove.
+
    #[clap(long, required_unless_present = "id_file")]
+
    id: Option<QueueId>,
+

+
    /// Read ID of event to remove from this file.
+
    #[clap(long)]
+
    id_file: Option<PathBuf>,
+
}
+

+
impl Leaf for RemoveEvent {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        let db = args.open_db()?;
+

+
        let id = if let Some(id) = &self.id {
+
            id.clone()
+
        } else if let Some(filename) = &self.id_file {
+
            let id = read(filename).map_err(|e| CibToolError::ReadEventId(filename.into(), e))?;
+
            let id = String::from_utf8_lossy(&id).to_string();
+
            QueueId::from(&id)
+
        } else {
+
            return Err(CibToolError::MissingId);
+
        };
+

+
        db.remove_queued_event(&id)?;
+
        Ok(())
+
    }
+
}
+

+
/// Add a shutdown event to the queue.
+
///
+
/// The shutdown event causes the CI broker to terminate.
+
#[derive(Parser)]
+
pub struct Shutdown {
+
    /// Write ID of the event to this file, after adding the event to
+
    /// the queue.
+
    #[clap(long)]
+
    id_file: Option<PathBuf>,
+
}
+

+
impl Leaf for Shutdown {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        let db = args.open_db()?;
+
        let id = db.push_queued_event(BrokerEvent::Shutdown)?;
+

+
        if let Some(filename) = &self.id_file {
+
            write(filename, id.to_string().as_bytes())
+
                .map_err(|e| CibToolError::WriteEventId(filename.into(), e))?;
+
        }
+

+
        Ok(())
+
    }
+
}
added src/bin/cibtoolcmd/mod.rs
@@ -0,0 +1,22 @@
+
//! This module implements leaf commands for cibtool.
+
//!
+
//! Each set of leaf commands, grouped by the top level subcommand, is
+
//! in its own module. Thus, commands related to events are in the
+
//! `event` module.
+

+
use super::*;
+

+
mod counter;
+
pub use counter::*;
+

+
mod event;
+
pub use event::*;
+

+
mod report;
+
pub use report::*;
+

+
mod run;
+
pub use run::*;
+

+
mod trigger;
+
pub use trigger::*;
added src/bin/cibtoolcmd/report.rs
@@ -0,0 +1,32 @@
+
use super::*;
+

+
/// Produce HTML reports based on database contents.
+
#[derive(Parser)]
+
pub struct ReportCmd {
+
    /// Write HTML files to this directory. The directory must exist:
+
    /// it is not created automatically.
+
    #[clap(long)]
+
    output_dir: PathBuf,
+
}
+

+
impl Leaf for ReportCmd {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        let profile = Profile::load().map_err(CibToolError::Profile)?;
+

+
        let db = args.open_db()?;
+

+
        let mut page = PageBuilder::default()
+
            .node_alias(&profile.config.node.alias)
+
            .runs(db.get_all_runs()?)
+
            .build()?;
+

+
        page.set_output_dir(&self.output_dir);
+

+
        let mut run_notification = NotificationChannel::default();
+
        let thread =
+
            page.update_in_thread(run_notification.rx(), db, &profile.config.node.alias, true);
+
        thread.join().unwrap()?;
+

+
        Ok(())
+
    }
+
}
added src/bin/cibtoolcmd/run.rs
@@ -0,0 +1,225 @@
+
use super::*;
+

+
#[derive(Parser)]
+
pub struct AddRun {
+
    /// Set the adapter run ID.
+
    #[clap(long)]
+
    id: Option<RunId>,
+

+
    /// Set alias of node that performed the CI run.
+
    #[clap(long)]
+
    alias: String,
+

+
    /// Set optional URL to information about the CI run.
+
    #[clap(long)]
+
    url: Option<String>,
+

+
    /// Set the repository ID that the CI run for.
+
    #[clap(long)]
+
    repo: RepoId,
+

+
    /// Set timestamp of the CI run.
+
    #[clap(long)]
+
    timestamp: String,
+

+
    /// Set the Git branch used by the CI run.
+
    #[clap(long)]
+
    branch: String,
+

+
    /// Set the commit SHA ID used by the CI run.
+
    #[clap(long)]
+
    commit: Oid,
+

+
    /// Set the author of the commit used by the CI run.
+
    #[clap(long)]
+
    who: Option<String>,
+

+
    /// Set the state of the CI run to "triggered".
+
    #[clap(long, required_unless_present_any = ["running", "success", "failure"])]
+
    triggered: bool,
+

+
    /// Set the state of the CI run to "running".
+
    #[clap(long)]
+
    #[clap(long, required_unless_present_any = ["triggered", "success", "failure"])]
+
    running: bool,
+

+
    /// Mark the CI run as finished and successful.
+
    #[clap(long, required_unless_present_any = ["triggered", "running", "failure"])]
+
    success: bool,
+

+
    /// Mark the CI run as finished and failed.
+
    #[clap(long, required_unless_present_any = ["triggered", "running", "success"])]
+
    failure: bool,
+
}
+

+
impl Leaf for AddRun {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        let db = args.open_db()?;
+

+
        let whence = Whence::Branch {
+
            name: self.branch.clone(),
+
            commit: self.commit,
+
            who: self.who.clone(),
+
        };
+
        let mut run = Run::new(self.repo, &self.alias, whence, self.timestamp.clone());
+

+
        let id = self.id.clone().unwrap_or_default();
+
        run.set_adapter_run_id(id);
+

+
        if let Some(url) = &self.url {
+
            run.set_adapter_info_url(url);
+
        }
+

+
        if self.success {
+
            run.set_result(RunResult::Success);
+
        } else if self.failure {
+
            run.set_result(RunResult::Failure);
+
        } else {
+
            run.unset_result();
+
        }
+

+
        db.push_run(&run)?;
+

+
        Ok(())
+
    }
+
}
+

+
#[derive(Parser)]
+
pub struct UpdateRun {
+
    /// ID of run to update.
+
    #[clap(long)]
+
    id: RunId,
+

+
    /// Set the state of the CI run to "triggered".
+
    #[clap(long, required_unless_present_any = ["running", "success", "failure"])]
+
    triggered: bool,
+

+
    /// Set the state of the CI run to "running".
+
    #[clap(long)]
+
    #[clap(long, required_unless_present_any = ["triggered", "success", "failure"])]
+
    running: bool,
+

+
    /// Mark the CI run as finished and successful.
+
    #[clap(long, required_unless_present_any = ["triggered", "running", "failure"])]
+
    success: bool,
+

+
    /// Mark the CI run as finished and failed.
+
    #[clap(long, required_unless_present_any = ["triggered", "running", "success"])]
+
    failure: bool,
+
}
+

+
impl Leaf for UpdateRun {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        let db = args.open_db()?;
+

+
        let runs = db.find_runs(&self.id)?;
+
        if runs.is_empty() {
+
            Err(CibToolError::RunNotFound(self.id.clone()))
+
        } else {
+
            let mut run = runs.first().unwrap().clone(); // this is safe: runs is not empty
+

+
            run.unset_result();
+
            if self.triggered {
+
                run.set_state(RunState::Triggered);
+
            } else if self.running {
+
                run.set_state(RunState::Running);
+
            } else if self.success {
+
                run.set_state(RunState::Finished);
+
                run.set_result(RunResult::Success);
+
            } else if self.failure {
+
                run.set_state(RunState::Finished);
+
                run.set_result(RunResult::Failure);
+
            }
+

+
            db.update_run(&run)?;
+
            Ok(())
+
        }
+
    }
+
}
+

+
#[derive(Parser)]
+
pub struct ShowRun {
+
    /// Broker or adapter run IC.
+
    id: RunId,
+
}
+

+
impl Leaf for ShowRun {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        let db = args.open_db()?;
+
        let runs = db.find_runs(&self.id)?;
+
        let json = serde_json::to_string_pretty(&runs).map_err(CibToolError::RunToJson)?;
+
        println!("{json}");
+

+
        Ok(())
+
    }
+
}
+

+
#[derive(Parser)]
+
pub struct ListRuns {
+
    #[clap(long)]
+
    json: bool,
+

+
    #[clap(long)]
+
    adapter_run_id: Option<RunId>,
+
}
+

+
impl Leaf for ListRuns {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        let db = args.open_db()?;
+

+
        let runs = if let Some(wanted) = &self.adapter_run_id {
+
            db.find_runs(wanted)?
+
        } else {
+
            db.get_all_runs()?
+
        };
+

+
        if self.json {
+
            println!(
+
                "{}",
+
                serde_json::to_string_pretty(&runs).map_err(CibToolError::RunToJson)?
+
            );
+
        } else {
+
            for run in runs {
+
                println!(
+
                    "{} {}",
+
                    run.broker_run_id(),
+
                    run.adapter_run_id()
+
                        .map(|id| id.to_string())
+
                        .unwrap_or("unknown".into())
+
                );
+
            }
+
        }
+

+
        Ok(())
+
    }
+
}
+

+
#[derive(Serialize)]
+
struct RunInfo {
+
    run_id: String,
+
    timestamp: String,
+
    repo_id: String,
+
    repo_alias: String,
+
    whence: Whence,
+
    info_url: Option<String>,
+
    state: RunState,
+
    result: Option<RunResult>,
+
}
+

+
impl From<&Run> for RunInfo {
+
    fn from(run: &Run) -> Self {
+
        RunInfo {
+
            run_id: run
+
                .adapter_run_id()
+
                .map(|id| id.to_string())
+
                .unwrap_or("unknown".into()),
+
            timestamp: run.timestamp().into(),
+
            repo_id: run.repo_id().to_string(),
+
            repo_alias: run.repo_alias().into(),
+
            whence: run.whence().clone(),
+
            info_url: run.adapter_info_url().map(|url| url.into()),
+
            state: run.state(),
+
            result: run.result().cloned(),
+
        }
+
    }
+
}
added src/bin/cibtoolcmd/trigger.rs
@@ -0,0 +1,114 @@
+
use super::*;
+

+
/// Trigger a CI run.
+
#[derive(Parser)]
+
pub struct TriggerCmd {
+
    /// Set the repository the event refers to. Can be a RID, or the
+
    /// repository name.
+
    #[clap(long)]
+
    repo: String,
+

+
    /// Set the name of the ref the event refers to.
+
    #[clap(long, alias = "ref")]
+
    name: String,
+

+
    /// Set the commit the event refers to. Can be the SHA1 commit id,
+
    /// or a symbolic Git revision, as understood by `git rev-parse`.
+
    /// For example, `HEAD`.
+
    #[clap(long)]
+
    commit: String,
+

+
    /// Write the event ID to this file, after adding the event to the
+
    /// queue.
+
    #[clap(long)]
+
    id_file: Option<PathBuf>,
+
}
+

+
impl TriggerCmd {
+
    fn lookup_nid(&self) -> Result<NodeId, CibToolError> {
+
        let profile = Profile::load().map_err(CibToolError::Profile)?;
+
        Ok(*profile.id())
+
    }
+

+
    fn lookup_rid(&self, wanted: &str) -> Result<RepoId, CibToolError> {
+
        let profile = Profile::load().map_err(CibToolError::Profile)?;
+
        let storage =
+
            Storage::open(profile.storage(), profile.info()).map_err(CibToolError::Storage)?;
+

+
        let mut rid = None;
+
        let repo_infos = storage.repositories().map_err(CibToolError::Repositories)?;
+
        for ri in repo_infos {
+
            let project = ri
+
                .doc
+
                .project()
+
                .map_err(|e| CibToolError::Project(ri.rid, e))?;
+

+
            if project.name() == wanted {
+
                if rid.is_some() {
+
                    return Err(CibToolError::DuplicateRepositories(wanted.into()));
+
                }
+
                rid = Some(ri.rid);
+
            }
+
        }
+

+
        if let Some(rid) = rid {
+
            Ok(rid)
+
        } else {
+
            Err(CibToolError::NotFound(wanted.into()))
+
        }
+
    }
+

+
    fn lookup_commit(&self, rid: RepoId, gitref: &str) -> Result<Oid, CibToolError> {
+
        let profile = Profile::load().map_err(CibToolError::Profile)?;
+
        let storage =
+
            Storage::open(profile.storage(), profile.info()).map_err(CibToolError::Storage)?;
+
        let repo = storage
+
            .repository(rid)
+
            .map_err(|e| CibToolError::RepoOpen(rid, e))?;
+
        let object = repo
+
            .backend
+
            .revparse_single(gitref)
+
            .map_err(|e| CibToolError::RevParse(gitref.into(), e))?;
+

+
        Ok(object.id().into())
+
    }
+
}
+

+
impl Leaf for TriggerCmd {
+
    fn run(&self, args: &Args) -> Result<(), CibToolError> {
+
        let rid = if let Ok(rid) = RepoId::from_urn(&self.repo) {
+
            rid
+
        } else {
+
            self.lookup_rid(&self.repo)?
+
        };
+

+
        let oid = if let Ok(rid) = Oid::from_str(&self.commit) {
+
            rid
+
        } else {
+
            self.lookup_commit(rid, &self.commit)?
+
        };
+

+
        let name = format!(
+
            "refs/namespaces/{}/refs/heads/{}",
+
            self.lookup_nid()?,
+
            self.name.as_str()
+
        );
+
        let name =
+
            RefString::try_from(name.clone()).map_err(|e| CibToolError::RefString(name, e))?;
+

+
        let base = self.lookup_commit(rid, &format!("{oid}^")).unwrap_or(oid);
+

+
        let event = BrokerEvent::new(&rid, &name, &oid, Some(base));
+

+
        let db = args.open_db()?;
+
        let id = db.push_queued_event(event)?;
+
        println!("{id}");
+

+
        if let Some(filename) = &self.id_file {
+
            write(filename, id.to_string().as_bytes())
+
                .map_err(|e| CibToolError::WriteEventId(filename.into(), e))?;
+
        }
+

+
        Ok(())
+
    }
+
}