Radish alpha
r
rad:zwTxygwuz5LDGBq255RA2CbNGrz8
Radicle CI broker
Radicle
Git
architecture documentation improvements
Merged liw opened 1 year ago
16 files changed +382 -267 c377b05a dc61d2bb
modified .gitignore
@@ -1,3 +1,4 @@
*.svg
*.html
/target
+
doc/messages.md

\ No newline at end of file
modified Cargo.toml
@@ -27,6 +27,7 @@ slog-scope = "4.4.0"
sqlite = "0.32.0"
sqlite3-sys = "0.15.0"
subplotlib = "0.11.0"
+
tempfile = { version = "3.10.1" }
thiserror = "1.0.63"
time = { version = "0.3.36", features = ["formatting", "macros"] }
uuid = { version = "1.10.0", features = ["v4"] }
@@ -38,7 +39,6 @@ features = ["default", "test"]
[dev-dependencies]
ctor = "0.2.8"
culpa = "1.0.2"
-
tempfile = { version = "3.10.1" }

[build-dependencies]
subplot-build = "0.11.0"
modified doc/Makefile
@@ -1,7 +1,7 @@
-
.SUFFIXES: .uml .svg .dot .pik .md .html
+
.SUFFIXES: .uml .svg .dot .pik .md .html .subplot

-
.md.html:
-
	pandoc -V date="Version: $$(git describe --long --dirty --all)" --toc --number-sections --standalone --self-contained $< -o $@
+
.subplot.html:
+
	subplot docgen --date "Version: $$(git describe --long --dirty)" $< -o $@

.dot.svg:
	dot -Tsvg $< > $@.tmp
@@ -19,7 +19,10 @@ all: architecture.html userguide.html
publish: all
	bash publish.sh

-
architecture.html: architecture.svg architecture-ext.svg comp.svg comp-ext.svg Makefile
+
architecture.html: architecture.subplot architecture.md Makefile messages.md
+

+
messages.md: messages.sh messages.txt
+
	./messages.sh > messages.md

userguide.html: userguide.subplot userguide.md Makefile
	subplot docgen $< --output $@
deleted doc/architecture-ext.uml
@@ -1,13 +0,0 @@
-
@startuml
-
node -> broker : RefsFetched event
-
broker -> adapter : spawn
-
broker -> adapter : send request
-
adapter-> engine : trigger run
-
engine -> worker : start run
-
note over worker : perform the run
-
engine -> adapter : response with run id
-
adapter -> broker : response: run id
-
worker -> engine : run finished
-
engine -> adapter : web hook?
-
adapter -> broker : response: result
-
@enduml
modified doc/architecture.md
@@ -1,13 +1,12 @@
-
---
-
title: Radicle CI architecture
-
author: The Radicle Team
-
...
-

# Overview

-
[Radicle](https://radicle.xyz/) is a peer-to-peer collaboration system
-
built on top of the git version control system. Radicle has support
-
for integrating with continuous integration (CI) systems, using an
+
[Radicle]: https://radicle.xyz/
+
[Git]: https://git-scm.com/
+
[CI]: https://en.wikipedia.org/wiki/Continuous_integration
+

+
[Radicle] is a peer-to-peer collaboration system
+
built on top of the [Git] version control system. Radicle has support
+
for integrating with continuous integration ([CI]) systems, using an
architecture where a "broker" listens to events about changes to
repositories stored in a node, and launching the appropriate "adapter"
for each change, according to its configuration.
@@ -17,29 +16,47 @@ changes according to the interests of the person whose node it is.

* The delegates for a repository might run CI on all patches to make
  merge decisions with more confidences.
-
* Someone whose contributing to a project might only care about
+
* Someone else, who is contributing to a project, might only care about
  patches they themselves created, and only run CI for those.
* A third party might run CI for projects they use, to know if it's OK
-
  to deploy to production.
+
  to deploy to their production systems.

Radicle provides its own, very simple "native CI" solution. It's just
good enough for the Radicle project to use itself. In addition, there
are adapters that integrate with external CI systems.

-
## Components in native CI
+
## Goal of Radicle CI

-
CI support in Radicle consists of several components. For native CI
-
they are:
+
Context: The user is a software developer working on a project that
+
uses Radicle for version control. The project has an automated test
+
suite, and in-repository configuration for how to build the project
+
and run the test suite, in a format suitable for the CI engine being
+
used.

-
* the Radicle node
-
* the CI broker
-
* the native CI executable
+
In the long run, the goal for CI in Radicle is "anything that makes it
+
easier, more fun, and faster to produce working software", but that's
+
not a concrete goal.

-
These all run on the same host.
+
At this stage in the development of Radicle CI has two concrete goals:
+

+
* When I create a patch to propose a change, I am automatically told
+
  if the project branch with my committed changes fails to build or
+
  pass its test suite. I can also manually check what the status of
+
  that process ("CI run") is, and find out what the build log is, to
+
  investigate any problems.
+

+
  - This is "build and test the patch branch".
+

+
* When a project delegate merges my patch, both they and I are
+
  automatically told if the merge fails due to a merge conflict, or
+
  if, after the merge the project no longer builds or its test suite
+
  fails.

-
![Components for native CI](comp.svg)
+
  - This is "build and test the master branch after the merge". This
+
    is useful, because sometimes a merged change breaks the build or
+
    the test suite, even when there are no merge conflicts.

-
![Sequence diagram for native CI](architecture.svg)
+
It is not yet clear how notifications will work.

## Components when integrating an external CI system

@@ -54,10 +71,25 @@ The first three of these run on the same host, but the external CI
instance can run anywhere. The adapter talks to the CI instance using
whatever protocol the CI instance supports, such as HTTP.

+
~~~dot
+
digraph "" {
+
   radicle_node [label="Radicle node"];
+
   broker [label="CI broker"];
+
   adapter [label="Adapter"];
+
   engine [label="External CI system"];
+

+
   radicle_node -> broker [label="change event"];
+
   broker -> adapter [label="invoke"];
+
   adapter -> engine [label="run"];
+
   engine -> adapter [label="web hook?"];
+
   adapter -> broker [label="result"];
+
}
+
~~~
+

External CI integration works like this:

* a repository known to the node changes
-
  - a git ref is updated
+
  - a Git ref is updated
  - the ref can be a branch, tag, or something else, such as a Radicle
    COB
  - the node emits an event describing the change
@@ -68,7 +100,7 @@ External CI integration works like this:
  configuration for the repository involved
* for an event that passes its filters, the CI broker spawns the
  appropriate adapter process
-
  - there are different adapter for different CI implementations
+
  - there is a different adapter for each different CI implementation
* the broker sends a request object to the adapter as a child process,
  via the child's stdin, and reads any responses from the child's
  stdout
@@ -81,9 +113,58 @@ External CI integration works like this:
  - it may involve the CI instance making a web hook request back to
    the adapter

-
![Components for external CI](comp-ext.svg)
+
~~~plantuml
+
@startuml
+
node -> broker : RefsFetched event
+
broker -> adapter : spawn
+
broker -> adapter : send request
+
adapter-> engine : trigger run
+
engine -> worker : start run
+
note over worker : perform the run
+
engine -> adapter : response with run id
+
adapter -> broker : response: run id
+
worker -> engine : run finished
+
engine -> adapter : web hook?
+
adapter -> broker : response: result
+
@enduml
+
~~~
+

+
## Components in native CI
+

+
CI support in Radicle consists of several components. For native CI
+
they are:
+

+
* the Radicle node
+
* the CI broker
+
* the native CI executable
+

+
These all run on the same host.
+

+
~~~dot
+
digraph "" {
+
   radicle_node [label="Radicle node"];
+
   broker [label="CI broker"];
+
   native [label="Native CI"];
+
   commands [label="Shell commands \n from .radicle/native.yaml"];
+

+
   radicle_node -> broker [label="change event"];
+
   broker -> native [label="invoke"];
+
   native -> commands [label="run"];
+
   native -> broker [label="result"];
+
}
+
~~~
+

+
~~~plantuml
+
@startuml
+
node -> broker : RefsFetched event
+
broker -> adapter : spawn
+
broker -> adapter : send request
+
adapter -> broker : response: run id
+
note over adapter : perform the run
+
adapter -> broker : response: result
+
@enduml
+
~~~

-
![Sequence diagram for external CI](architecture-ext.svg)

# The adapter

@@ -118,205 +199,18 @@ they are used to on other forges, we want the broker to pass on
whatever information it already has from the node events to the 
adapters, so they can pass it on to external CI systems, as appropriate. 

-
# Request and response messages
-

-
Note: the JSON objects below are formatted on multiple lines to make
-
them easier to read. The actual wire format is one line per message.
-

-
Run the `broker-messages` binary to get actual examples produced by
-
code.
-

-
~~~{.sh .numberLines}
-
$ cargo run -q --bin broker-messages
-
Trigger request:
-
{"request":"trigger","event_type":"push","version":<PROTOCOL_VERSION>,repository":{"id":"rad:zwTxygwuz5LDGBq255RA2CbNGrz8","name":"radicle-ci-broker","description":"Radicle CI broker","private":false,"default_branch":"main","delegates":["did:key:z6MkgEMYod7Hxfy9qCvDv5hYHkZ4ciWmLFgfvm3Wn1b2w2FV"]},"pusher":{"id":"did:key:z6MkgEMYod7Hxfy9qCvDv5hYHkZ4ciWmLFgfvm3Wn1b2w2FV","alias":"liw"},"before":"b4fb1e347be7db19f0859717062f94116b5bec9f","after":"b4fb1e347be7db19f0859717062f94116b5bec9f","branch":"patches/8d8232ddcb217fa1402eec4d955e227ef3bb5881","commits":[]}
-

-
Triggered response:
-
{"response":"triggered","run_id":{"id":"any-string-works-as-run-id"}}
-

-
Successful response:
-
{"response":"finished","result":"success"}
-

-
Failure response:
-
{"response":"finished","result":"failure"}
-
~~~
-

-
## Push Event Request
-

-
An example request that the broker sends looks like this:
-

-
~~~{.json .numberLines}
-
{
-
    "request": "trigger",
-
    "event_type": "push",
-
    "version":<PROTOCOL_VERSION>,
-
    "pusher": {
-
        "id": "did:key:z6MkltRpzcq2ybm13yQpyre58JUeMvZY6toxoZVpLZ8YabRa",
-
        "alias": "node_alias"
-
    },
-
    "before": "<BEFORE_COMMIT>",
-
    "after": "<AFTER_COMMIT>",
-
    "branch": "<BRANCH_NAME>",
-
    "commits": [
-
        "<SOME_OTHER_COMMIT_BEING_PUSHED>",
-
        "<AFTER_COMMIT>"
-
    ],
-
    "repository": {
-
        "id": "<RID>",
-
        "name": "heartwood",
-
        "description": "Radicle is a sovereign peer-to-peer network for 
-
        code collaboration, built on top of Git.",
-
        "private": false,
-
        "default_branch": "main",
-
        "delegates": [
-
          "did:key:z6MkltRpzcq2ybm13yQpyre58JUeMvZY6toxoZVpLZ8YabRa",
-
          "did:key:z6MkltRpzcq2ybm13yQpyre58JUeMvZY6toxoZVpLZ8YabRb"
-
        ]
-
    }
-
}
-
~~~
-

-
where:
-

-
  - `<PROTOCOL_VERSION>` is the version of the protocol messages that broker exchanges
-
  - `<RID>` is the repository ID, in its `rad:` URN format,
-
  - `<BRANCH_NAME>` is the branch name where the push occurred,
-
  - `<AFTER_COMMIT>` is the commit id of the last commit being pushed,
-
  - `<BEFORE_COMMIT>` is the commit id of the **parent** of the first
-
     commit being pushed (i.e. ` <SOME_OTHER_COMMIT_BEING_PUSHED>`),
-
     (the SHA checksum).
-

-
The `request` fields allows us to extend this in the future.
-

-
## Patch Event Request
-

-
An example request that the broker sends looks like this:
-

-
~~~{.json .numberLines}
-
{
-
    "request": "trigger",
-
    "event_type": "patch",
-
    "version":<PROTOCOL_VERSION>
-
    "action": "created|updated",
-
    "patch": {
-
        "id": "<PATCH_ID>",
-
        "author": {
-
            "id": "did:key:z6MkltRpzcq2ybm13yQpyre58JUeMvZY6toxoZVpLZ8YabRa",
-
            "alias": "node_alias"
-
        },
-
        "title": "Add description in README",
-
        "state": {
-
            "status": "Open",
-
            "conflicts": [
-
                {
-
                    "revision_id": "string",
-
                    "oid": "string"
-
                }
-
            ]
-
        },
-
        "before": "<BEFORE_COMMIT>",
-
        "after": "<AFTER_COMMIT>",
-
        "commits": [
-
            "<SOME_OTHER_COMMIT_BEING_PUSHED>",
-
            "<AFTER_COMMIT>"
-
        ],
-
        "target": "delegates",
-
        "labels": [
-
            "small",
-
            "goodFirstIssue",
-
            "enhancement",
-
            "bug"
-
        ],
-
        "assignees": [
-
            "did:key:z6MkltRpzcq2ybm13yQpyre58JUeMvZY6toxoZVpLZ8YabRa"
-
        ],
-
        "revisions": [
-
            {
-
                "id": "41aafe22200464bf905b143d4233f7f1fa4a9123",
-
                "author": {
-
                    "id": "did:key:z6MkltRpzcq2ybm13yQpyre58JUeMvZY6toxoZVpLZ8YabRa",
-
                    "alias": "my_alias"
-
                },
-
                "description": "The revision description",
-
                "base": "193ed2f675ac6b0d1ab79ed65057c8a56a4fab23",
-
                "oid": "f0f5d38ffa8d54a7cc737fc4e75ab1e2e178eaa1",
-
                "timestamp": 1699437445
-
            }
-
        ]
-
    },
-
    "repository": {
-
        "id": "<RID>",
-
        "name": "heartwood",
-
        "description": "Radicle is a sovereign peer-to-peer network for 
-
        code collaboration, built on top of Git.",
-
        "private": false,
-
        "default_branch": "main",
-
        "delegates": [
-
          "did:key:z6MkltRpzcq2ybm13yQpyre58JUeMvZY6toxoZVpLZ8YabRa",
-
          "did:key:z6MkltRpzcq2ybm13yQpyre58JUeMvZY6toxoZVpLZ8YabRb"
-
        ]
-
    }
-
}
-
~~~
-

-
where:
-

-
  - `<PROTOCOL_VERSION>` is the version of the protocol messages that broker exchanges
-
  - `<RID>` is the repository ID, in its `rad:` URN format,
-
  - `<AFTER_COMMIT>` is the commit id of the last commit being pushed,
-
  - `<BEFORE_COMMIT>` is the commit id of the **parent** of the first
-
    commit being pushed (i.e. ` <SOME_OTHER_COMMIT_BEING_PUSHED>`),
-
    (the SHA checksum).
-

-
The `request` fields allows us to extend this in the future.
-

-
## Responses
-

-
The first response from the adapter looks like this:
-

-
~~~{.json .numberLines}
-
{
-
    "response": "triggered",
-
    "run_id": "<RUNID>"
-
}
-
~~~
-

-
where `<RUNID>` is the id of the run that has been triggered.
-

-
The second response from the adapter looks like this:
-

-
~~~{.json .numberLines}
-
{
-
    "response": "finished",
-
    "result": "<STATUS>"
-
}
-
~~~
-

-
where `<STATUS>` is either the string `success` or `failure`. Note
-
that the run id is not repeated as the context makes this clear: the
-
response comes from the same process, via the same stdout pipe, as the
-
previous message.
-

# Report generation

The CI broker has an SQLite database file for persistent storage of
information of the CI runs it triggers. This is used to generate
-
report pages.
-

-
The report pages are HTML, generated from the information in the
-
database. The broker loads information for all runs when it starts,
-
from the database, and then pushed information about new runs when
-
they happen. This avoids the broker having to read everything every
-
time it updates the report pages.
+
report pages, among other things.

The report page generation is done in its own thread, separate from
the main thread of the CI broker. This allows the reporting to happens
independently of what the main thread is doing. In particular, it
-
means the main thread does not need to do anything to trigger reports
-
from being updated.
-

-
When it comes to per-run logs, for external CI these are kept by the
-
external CI instance and the broker never sees them. For native CI,
-
the native CI adapter writes them to the report directory, as
-
`$RUNID/log.html`, and the broker generates report pages that link to
-
those files.
+
means the report generation happens even while the main thread is busy
+
running an adapter.
+

+
When it comes to per-run logs, the adapter can include a URL to one in
+
the first response message. The URL will be included as a link in the
+
report HTML.
added doc/architecture.subplot
@@ -0,0 +1,9 @@
+
title: "Radicle CI broker"
+
subtitle: Architecture
+
authors:
+
  - The Radicle Project
+
markdowns:
+
  - architecture.md
+
  - messages.md
+
classes:
+
  - json
deleted doc/architecture.uml
@@ -1,8 +0,0 @@
-
@startuml
-
node -> broker : RefsFetched event
-
broker -> adapter : spawn
-
broker -> adapter : send request
-
adapter -> broker : response: run id
-
note over adapter : perform the run
-
adapter -> broker : response: result
-
@enduml
deleted doc/comp-ext.dot
@@ -1,12 +0,0 @@
-
digraph "" {
-
   radicle_node [label="Radicle node"];
-
   broker [label="CI broker"];
-
   adapter [label="Adapter"];
-
   engine [label="External CI system"];
-

-
   radicle_node -> broker [label="change event"];
-
   broker -> adapter [label="invoke"];
-
   adapter -> engine [label="run"];
-
   engine -> adapter [label="web hook?"];
-
   adapter -> broker [label="result"];
-
}

\ No newline at end of file
deleted doc/comp.dot
@@ -1,11 +0,0 @@
-
digraph "" {
-
   radicle_node [label="Radicle node"];
-
   broker [label="CI broker"];
-
   native [label="Native CI"];
-
   commands [label="Shell commands"];
-

-
   radicle_node -> broker [label="change event"];
-
   broker -> native [label="invoke"];
-
   native -> commands [label="run"];
-
   native -> broker [label="result"];
-
}

\ No newline at end of file
added doc/messages.sh
@@ -0,0 +1,39 @@
+
#!/bin/bash
+

+
set -euo pipefail
+

+
message() {
+
	echo "~~~json"
+
	cargo run -q --bin cibtool -- message "$@" | jq .
+
	echo "~~~"
+
}
+

+
while read -r line; do
+
	case "$line" in
+
	@PUSH)
+
		message --kind=push
+
		;;
+
	@PATCH)
+
		message --kind=patch
+
		;;
+
	@TRIGGERED)
+
		message --kind=triggered
+
		;;
+
	@TRIGGEREDURL)
+
		message --kind=triggered --info-url=https://ci.example.com/runid/log.html
+
		;;
+
	@SUCCESS)
+
		message --kind=success
+
		;;
+
	@FAILURE)
+
		message --kind=failure
+
		;;
+
	@*)
+
		echo "Unknown directive: $line" 1>&2
+
		a exit 1
+
		;;
+
	*)
+
		echo "$line"
+
		;;
+
	esac
+
done <messages.txt
added doc/messages.txt
@@ -0,0 +1,28 @@
+
# Sample messages to and from CI adapter
+

+
Note: the JSON objects below are formatted on multiple lines to make
+
them easier to read. The actual wire format is one line per message.
+

+
## Trigger on branch create or update
+

+
@PUSH
+

+
## Trigger on patch create or update
+

+
@PATCH
+

+
## Response when run has started
+

+
@TRIGGERED
+

+
## Response when run has started, with info URL
+

+
@TRIGGEREDURL
+

+
## Response on successful finish
+

+
@SUCCESS
+

+
## Response on failure finish
+

+
@FAILURE
modified src/bin/cibtool.rs
@@ -122,6 +122,8 @@ enum Cmd {
    Trigger(cibtoolcmd::TriggerCmd),
    #[clap(hide = true)]
    Timeout(cibtoolcmd::TimeoutCmd),
+
    #[clap(hide = true)]
+
    Message(cibtoolcmd::MessageCmd),
}

impl Subcommand for Cmd {
@@ -133,6 +135,7 @@ impl Subcommand for Cmd {
            Self::Report(x) => x.run(args),
            Self::Trigger(x) => x.run(args),
            Self::Timeout(x) => x.run(args),
+
            Self::Message(x) => x.run(args),
        }
    }
}
@@ -357,4 +360,7 @@ enum CibToolError {

    #[error(transparent)]
    Timeout(#[from] radicle_ci_broker::timeoutcmd::TimeoutError),
+

+
    #[error(transparent)]
+
    Message(#[from] cibtoolcmd::MessageError),
}
added src/bin/cibtoolcmd/message.rs
@@ -0,0 +1,165 @@
+
use clap::ValueEnum;
+

+
use radicle_ci_broker::msg::{EventType, Request, RequestBuilder, Response, RunId, RunResult};
+
use radicle_ci_broker::test::{MockNode, TestResult};
+

+
use radicle::crypto::ssh::Keystore;
+
use radicle::crypto::test::signer::MockSigner;
+
use radicle::crypto::Signer;
+
use radicle::git::RefString;
+
use radicle::patch::{MergeTarget, Patches};
+
use radicle::prelude::Did;
+
use radicle::profile::{Config, Home};
+
use radicle::storage::ReadRepository;
+
use radicle::test::setup::Node;
+
use radicle::Profile;
+

+
use super::*;
+

+
/// Output sample adapter messages.
+
#[derive(Parser)]
+
pub struct MessageCmd {
+
    /// What kind of message should be output?
+
    #[clap(long)]
+
    #[arg(value_enum)]
+
    kind: MessageKind,
+

+
    /// Info URL to include in a `triggered` message.
+
    #[clap(long)]
+
    info_url: Option<String>,
+
}
+

+
#[derive(Clone, Debug, Eq, PartialEq, ValueEnum)]
+
enum MessageKind {
+
    Push,
+
    Patch,
+
    Triggered,
+
    Success,
+
    Failure,
+
}
+

+
impl Leaf for MessageCmd {
+
    fn run(&self, _args: &Args) -> Result<(), CibToolError> {
+
        let msg = match self.kind {
+
            MessageKind::Push => request_json(push()?)?,
+
            MessageKind::Patch => request_json(patch()?)?,
+
            MessageKind::Triggered => response_json(triggered(&self.info_url)?)?,
+
            MessageKind::Success => response_json(success()?)?,
+
            MessageKind::Failure => response_json(failure()?)?,
+
        };
+

+
        println!("{}", msg);
+

+
        Ok(())
+
    }
+
}
+

+
fn request_json(req: Request) -> Result<String, MessageError> {
+
    req.to_json_pretty().map_err(MessageError::Message)
+
}
+

+
fn response_json(resp: Response) -> Result<String, MessageError> {
+
    resp.to_json_pretty().map_err(MessageError::Message)
+
}
+

+
fn push() -> Result<Request, MessageError> {
+
    let mock_node = MockNode::new().map_err(MessageError::Test)?;
+
    let profile = mock_node.profile().map_err(MessageError::Test)?;
+

+
    let project = mock_node.node().project();
+
    let (_, repo_head) = project.repo.head().map_err(MessageError::Repo)?;
+
    let cmt =
+
        radicle::test::fixtures::commit("my test commit", &[repo_head.into()], &project.backend);
+

+
    let ci_event = CiEvent::V1(CiEventV1::BranchCreated {
+
        from_node: *profile.id(),
+
        repo: project.id,
+
        branch: RefString::try_from(
+
            "refs/namespaces/$nid/refs/heads/master".replace("$nid", &profile.id().to_string()),
+
        )
+
        .map_err(MessageError::Git)?,
+
        tip: cmt,
+
    });
+

+
    let req = RequestBuilder::default()
+
        .profile(&profile)
+
        .ci_event(&ci_event)
+
        .build_trigger_from_ci_event()
+
        .map_err(MessageError::Message)?;
+
    Ok(req)
+
}
+

+
fn patch() -> Result<Request, MessageError> {
+
    let mock_node = MockNode::new()?;
+
    let profile = mock_node.profile()?;
+

+
    let project = mock_node.node().project();
+
    let (_, repo_head) = project.repo.head()?;
+
    let cmt =
+
        radicle::test::fixtures::commit("my test commit", &[repo_head.into()], &project.backend);
+

+
    let node = mock_node.node();
+

+
    let mut patches = Patches::open(&project.repo)?;
+
    let mut cache = radicle::cob::cache::NoCache;
+
    let patch_cob = patches
+
        .create(
+
            "my patch title",
+
            "my patch description",
+
            MergeTarget::Delegates,
+
            repo_head,
+
            cmt,
+
            &[],
+
            &mut cache,
+
            &node.signer,
+
        )
+
        .map_err(MessageError::Patch)?;
+

+
    let ci_event = CiEvent::V1(CiEventV1::PatchCreated {
+
        from_node: *profile.id(),
+
        repo: project.id,
+
        patch: *patch_cob.id(),
+
        new_tip: cmt,
+
    });
+

+
    let req = RequestBuilder::default()
+
        .profile(&profile)
+
        .ci_event(&ci_event)
+
        .build_trigger_from_ci_event()?;
+
    Ok(req)
+
}
+

+
fn triggered(info_url: &Option<String>) -> Result<Response, MessageError> {
+
    let run_id = RunId::from("xyzzy");
+
    if let Some(url) = info_url {
+
        Ok(Response::triggered_with_url(run_id, url))
+
    } else {
+
        Ok(Response::triggered(run_id))
+
    }
+
}
+

+
fn success() -> Result<Response, MessageError> {
+
    Ok(Response::finished(RunResult::Success))
+
}
+

+
fn failure() -> Result<Response, MessageError> {
+
    Ok(Response::finished(RunResult::Failure))
+
}
+

+
#[derive(Debug, thiserror::Error)]
+
pub enum MessageError {
+
    #[error(transparent)]
+
    Test(#[from] Box<dyn std::error::Error>),
+

+
    #[error(transparent)]
+
    Repo(#[from] radicle::storage::RepositoryError),
+

+
    #[error(transparent)]
+
    Git(#[from] radicle_git_ext::ref_format::Error),
+

+
    #[error(transparent)]
+
    Message(#[from] radicle_ci_broker::msg::MessageError),
+

+
    #[error(transparent)]
+
    Patch(#[from] radicle::patch::Error),
+
}
modified src/bin/cibtoolcmd/mod.rs
@@ -23,3 +23,6 @@ pub use trigger::*;

mod timeout;
pub use timeout::*;
+

+
mod message;
+
pub use message::*;
modified src/lib.rs
@@ -23,7 +23,6 @@ pub mod queueadd;
pub mod queueproc;
pub mod run;
pub mod sensitive;
-
#[cfg(test)]
pub mod test;
pub mod timeoutcmd;
pub mod util;
modified src/msg.rs
@@ -474,6 +474,12 @@ impl Request {
        }
    }

+
    /// Serialize the request as a pretty JSON, including the newline.
+
    /// This is meant for the broker to use.
+
    pub fn to_json_pretty(&self) -> Result<String, MessageError> {
+
        serde_json::to_string_pretty(&self).map_err(MessageError::SerializeRequest)
+
    }
+

    /// Serialize the request as a single-line JSON, including the
    /// newline. This is meant for the broker to use.
    pub fn to_writer<W: Write>(&self, mut writer: W) -> Result<(), MessageError> {
@@ -788,6 +794,12 @@ impl Response {
        Ok(())
    }

+
    /// Serialize the response as a pretty JSON, including the newline.
+
    /// This is meant for the broker to use.
+
    pub fn to_json_pretty(&self) -> Result<String, MessageError> {
+
        serde_json::to_string_pretty(&self).map_err(MessageError::SerializeResponse)
+
    }
+

    /// Read a response from a reader. This is meant for the broker to
    /// use.
    pub fn from_reader<R: Read + BufRead>(reader: &mut R) -> Result<Option<Self>, MessageError> {