summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2024-02-27 16:49:12 +0200
committerLars Wirzenius <liw@liw.fi>2024-03-04 16:43:51 +0200
commit443bf0b184aa4cd4842d787dd4e8b0874784d5e6 (patch)
treeccfa5c3c2ce4258bb34faa3599adb9829b6fa072
parentd96861408c0dbf59622228e4b795ffe231a449be (diff)
downloadradicle-ci-broker-443bf0b184aa4cd4842d787dd4e8b0874784d5e6.tar.gz
feat! generate HTML report pages
Instead of only a single JSON file, updated every so often, also generate HTML pages that show CI broker status, and lists every repository for which the broker has run CI. This currently assumes the native CI adapter, but that can be fixed to be more generic later. Signed-off-by: Lars Wirzenius <liw@liw.fi>
-rw-r--r--Cargo.lock87
-rw-r--r--Cargo.toml4
-rw-r--r--src/adapter.rs94
-rw-r--r--src/bin/ci-broker.rs46
-rw-r--r--src/bin/pagegen.rs77
-rw-r--r--src/bin/status.rs19
-rw-r--r--src/broker.rs69
-rw-r--r--src/config.rs6
-rw-r--r--src/error.rs4
-rw-r--r--src/lib.rs2
-rw-r--r--src/msg.rs7
-rw-r--r--src/pages.rs451
-rw-r--r--src/radicle-ci.css50
-rw-r--r--src/run.rs66
-rw-r--r--src/status.rs128
15 files changed, 889 insertions, 221 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 140c1ba..0ce6d99 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -664,6 +664,25 @@ dependencies = [
]
[[package]]
+name = "html-escape"
+version = "0.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476"
+dependencies = [
+ "utf8-width",
+]
+
+[[package]]
+name = "html-page"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f8314b0ea57e9e3fc648213a02315e8a16154bb86da7516fec7a09ec4d7417c"
+dependencies = [
+ "html-escape",
+ "line-col",
+]
+
+[[package]]
name = "humantime"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -780,6 +799,12 @@ dependencies = [
]
[[package]]
+name = "line-col"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e69cdf6b85b5c8dce514f694089a2cf8b1a702f6cd28607bcb3cf296c9778db"
+
+[[package]]
name = "linux-raw-sys"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1135,10 +1160,12 @@ dependencies = [
name = "radicle-ci-broker"
version = "0.1.0"
dependencies = [
+ "html-page",
"log",
"pretty_env_logger",
"radicle",
"radicle-git-ext",
+ "radicle-native-ci",
"radicle-surf",
"serde",
"serde_json",
@@ -1150,6 +1177,24 @@ dependencies = [
]
[[package]]
+name = "radicle-ci-broker"
+version = "0.1.0"
+source = "git+https://radicle.liw.fi/zwTxygwuz5LDGBq255RA2CbNGrz8.git?branch=main#d96861408c0dbf59622228e4b795ffe231a449be"
+dependencies = [
+ "log",
+ "pretty_env_logger",
+ "radicle",
+ "radicle-git-ext",
+ "radicle-surf",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "thiserror",
+ "time",
+ "uuid",
+]
+
+[[package]]
name = "radicle-cob"
version = "0.2.0"
source = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?branch=master#292ff01923de86b9139eae538962e98d76178b9a"
@@ -1210,6 +1255,23 @@ dependencies = [
]
[[package]]
+name = "radicle-native-ci"
+version = "0.1.0"
+dependencies = [
+ "html-page",
+ "radicle",
+ "radicle-ci-broker 0.1.0 (git+https://radicle.liw.fi/zwTxygwuz5LDGBq255RA2CbNGrz8.git?branch=main)",
+ "radicle-git-ext",
+ "serde",
+ "serde_yaml",
+ "tempfile",
+ "thiserror",
+ "time",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
name = "radicle-ssh"
version = "0.2.0"
source = "git+https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git?branch=master#292ff01923de86b9139eae538962e98d76178b9a"
@@ -1363,6 +1425,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c"
[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1756,6 +1827,12 @@ dependencies = [
]
[[package]]
+name = "utf8-width"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3"
+
+[[package]]
name = "uuid"
version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1777,6 +1854,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
[[package]]
+name = "walkdir"
+version = "2.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71d857dc86794ca4c280d616f7da00d2dbfd8cd788846559a6813e6aa4b54ee"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 427b509..899227b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -15,11 +15,15 @@ thiserror = "1.0.50"
radicle-surf = { version = "0.18.0", default-features = false, features = ["serde"] }
uuid = { version = "1.7.0", features = ["v4"] }
time = { version = "0.3.34", features = ["formatting", "macros"] }
+html-page = "0.1.0"
[dependencies.radicle]
git = "https://seed.radicle.xyz/z3gqcJUoA1n9HaHKufZs5FCSGazv5.git"
branch = "master"
features = ["default", "test"]
+[dependencies.radicle-native-ci]
+path = "../radicle-native-ci"
+
[dev-dependencies]
tempfile = { version = "3.9.0" }
diff --git a/src/adapter.rs b/src/adapter.rs
index 9d479fb..328ead4 100644
--- a/src/adapter.rs
+++ b/src/adapter.rs
@@ -18,8 +18,8 @@ use std::{
use crate::{
msg::{MessageError, Request, Response},
+ pages::StatusPage,
run::{Run, RunState},
- status::Status,
};
/// An external executable that runs CI on request.
@@ -52,13 +52,13 @@ impl Adapter {
&self,
trigger: &Request,
run: &mut Run,
- status: &mut Status,
+ status: &mut StatusPage,
) -> Result<(), AdapterError> {
run.set_state(RunState::Triggered);
- status.ci_run(run);
+ status.push_run(run.clone());
let x = self.run_helper(trigger, run, status);
run.set_state(RunState::Finished);
- status.ci_run(run);
+ status.push_run(run.clone());
x
}
@@ -66,7 +66,7 @@ impl Adapter {
&self,
trigger: &Request,
run: &mut Run,
- status: &mut Status,
+ status: &mut StatusPage,
) -> Result<(), AdapterError> {
assert!(matches!(trigger, Request::Trigger { .. }));
@@ -99,7 +99,7 @@ impl Adapter {
Response::Triggered { run_id } => {
run.set_state(RunState::Running);
run.set_adapter_run_id(run_id);
- status.ci_run(run);
+ status.push_run(run.clone());
}
_ => return Err(AdapterError::NotTriggered(resp)),
}
@@ -111,7 +111,7 @@ impl Adapter {
match resp {
Response::Finished { result } => {
run.set_result(result);
- status.ci_run(run);
+ status.push_run(run.clone());
}
_ => return Err(AdapterError::NotTriggered(resp)),
}
@@ -173,18 +173,42 @@ pub enum AdapterError {
#[cfg(test)]
mod test {
- use std::{fs::write, io::ErrorKind, path::Path};
+ use std::{fs::write, io::ErrorKind};
use tempfile::tempdir;
- use super::{Adapter, Run};
+ use radicle::git::Oid;
+ use radicle::prelude::RepoId;
+
+ use super::{Adapter, Run, StatusPage};
use crate::{
adapter::AdapterError,
- msg::{MessageError, Response, RunResult},
- status::Status,
+ msg::{MessageError, Response, RunId, RunResult},
+ pages::PageBuilder,
+ run::Whence,
test::{mock_adapter, trigger_request, TestResult},
};
+ fn run() -> Run {
+ Run::new(
+ RunId::default(),
+ RepoId::from_urn("rad:zwTxygwuz5LDGBq255RA2CbNGrz8").unwrap(),
+ "test.repo",
+ Whence::branch(
+ "main",
+ Oid::try_from("ff3099ba5de28d954c41d0b5a84316f943794ea4").unwrap(),
+ ),
+ "2024-02-29T12:58:12+02:00".into(),
+ )
+ }
+
+ fn status_page() -> StatusPage {
+ PageBuilder::default()
+ .node_alias("test.alias")
+ .build()
+ .unwrap()
+ }
+
#[test]
fn adapter_reports_success() -> TestResult<()> {
const ADAPTER: &str = r#"#!/bin/bash
@@ -196,8 +220,8 @@ echo '{"response":"finished","result":"success"}'
let bin = tmp.path().join("adapter.sh");
mock_adapter(&bin, ADAPTER)?;
- let mut run = Run::default();
- let mut status = Status::new(Path::new("/dev/null"));
+ let mut run = run();
+ let mut status = status_page();
Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status)?;
assert_eq!(run.result(), Some(&RunResult::Success));
@@ -215,8 +239,8 @@ echo '{"response":"finished","result":"failure"}'
let bin = tmp.path().join("adapter.sh");
mock_adapter(&bin, ADAPTER)?;
- let mut run = Run::default();
- let mut status = Status::new(Path::new("/dev/null"));
+ let mut run = run();
+ let mut status = status_page();
Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status)?;
assert_eq!(run.result(), Some(&RunResult::Failure));
@@ -234,8 +258,8 @@ echo '{"response":"finished","result":{"error":"error message\nsecond line"}}'
let bin = tmp.path().join("adapter.sh");
mock_adapter(&bin, ADAPTER)?;
- let mut run = Run::default();
- let mut status = Status::new(Path::new("/dev/null"));
+ let mut run = run();
+ let mut status = status_page();
Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status)?;
assert_eq!(
run.result(),
@@ -255,8 +279,8 @@ kill -9 $BASHPID
let bin = tmp.path().join("adapter.sh");
mock_adapter(&bin, ADAPTER)?;
- let mut run = Run::default();
- let mut status = Status::new(Path::new("/dev/null"));
+ let mut run = run();
+ let mut status = status_page();
let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
assert!(matches!(x, Err(AdapterError::Failed(_))));
@@ -274,8 +298,8 @@ kill -9 $BASHPID
let bin = tmp.path().join("adapter.sh");
mock_adapter(&bin, ADAPTER)?;
- let mut run = Run::default();
- let mut status = Status::new(Path::new("/dev/null"));
+ let mut run = run();
+ let mut status = status_page();
let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
assert!(matches!(x, Err(AdapterError::Failed(_))));
@@ -294,8 +318,8 @@ kill -9 $BASHPID
let bin = tmp.path().join("adapter.sh");
mock_adapter(&bin, ADAPTER)?;
- let mut run = Run::default();
- let mut status = Status::new(Path::new("/dev/null"));
+ let mut run = run();
+ let mut status = status_page();
let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
assert!(matches!(x, Err(AdapterError::Failed(_))));
@@ -313,8 +337,8 @@ echo '{"response":"finished","result":"success","bad":"field"}'
let bin = tmp.path().join("adapter.sh");
mock_adapter(&bin, ADAPTER)?;
- let mut run = Run::default();
- let mut status = Status::new(Path::new("/dev/null"));
+ let mut run = run();
+ let mut status = status_page();
let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
assert!(matches!(
x,
@@ -334,8 +358,8 @@ echo '{"response":"finished","result":"success"}'
let bin = tmp.path().join("adapter.sh");
mock_adapter(&bin, ADAPTER)?;
- let mut run = Run::default();
- let mut status = Status::new(Path::new("/dev/null"));
+ let mut run = run();
+ let mut status = status_page();
let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
assert!(matches!(
x,
@@ -359,8 +383,8 @@ echo '{"response":"finished","result":"success"}'
let bin = tmp.path().join("adapter.sh");
mock_adapter(&bin, ADAPTER)?;
- let mut run = Run::default();
- let mut status = Status::new(Path::new("/dev/null"));
+ let mut run = run();
+ let mut status = status_page();
let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
assert!(matches!(
x,
@@ -377,8 +401,8 @@ echo '{"response":"finished","result":"success"}'
let tmp = tempdir()?;
let bin = tmp.path().join("adapter.sh");
- let mut run = Run::default();
- let mut status = Status::new(Path::new("/dev/null"));
+ let mut run = run();
+ let mut status = status_page();
let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
match x {
Err(AdapterError::SpawnAdapter(filename, e)) => {
@@ -402,8 +426,8 @@ echo '{"response":"finished","result":"success"}'
let bin = tmp.path().join("adapter.sh");
write(&bin, ADAPTER)?;
- let mut run = Run::default();
- let mut status = Status::new(Path::new("/dev/null"));
+ let mut run = run();
+ let mut status = status_page();
let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
match x {
Err(AdapterError::SpawnAdapter(filename, e)) => {
@@ -431,8 +455,8 @@ echo '{"response":"finished","result":"success"}'
let bin = tmp.path().join("adapter.sh");
mock_adapter(&bin, ADAPTER)?;
- let mut run = Run::default();
- let mut status = Status::new(Path::new("/dev/null"));
+ let mut run = run();
+ let mut status = status_page();
let x = Adapter::new(&bin).run(&trigger_request()?, &mut run, &mut status);
match x {
Err(AdapterError::SpawnAdapter(filename, e)) => {
diff --git a/src/bin/ci-broker.rs b/src/bin/ci-broker.rs
index 26c506f..5ce7104 100644
--- a/src/bin/ci-broker.rs
+++ b/src/bin/ci-broker.rs
@@ -1,6 +1,6 @@
use std::{
error::Error,
- path::{Path, PathBuf},
+ path::PathBuf,
thread::{sleep, spawn},
time::Duration,
};
@@ -9,8 +9,13 @@ use log::{debug, info};
use radicle::prelude::Profile;
use radicle_ci_broker::{
- adapter::Adapter, broker::Broker, config::Config, error::BrokerError, event::NodeEventSource,
- msg::Request, status::Status,
+ adapter::Adapter,
+ broker::Broker,
+ config::Config,
+ error::BrokerError,
+ event::NodeEventSource,
+ msg::Request,
+ pages::{PageBuilder, StatusPage},
};
fn main() {
@@ -72,29 +77,46 @@ fn fallible_main() -> Result<(), BrokerError> {
}
debug!("added filters to node event source");
- // Spawn a thread that updates the status page.
- let mut status = Status::new(config.status_page().unwrap_or(Path::new("/dev/null")));
- let s2 = status.clone();
+ // Spawn a thread that updates the status pages.
+ let mut page = PageBuilder::default().node_alias("fixme.alias").build()?;
+ let page2 = page.clone();
+ let report_dir = if let Some(dir) = &config.report_dir {
+ dir.to_path_buf()
+ } else {
+ PathBuf::from(".")
+ };
let interval = Duration::from_secs(config.status_page_update_interval());
- let _status_thread = spawn(move || status_updater(s2, interval));
+ let status_thread = spawn(move || status_updater(report_dir, page2, interval));
+ debug!(
+ "started thread to update status pages in the background: {:?}",
+ status_thread.thread().id()
+ );
// This loop ends when there's an error, e.g., failure to read an
// event from the node.
loop {
debug!("waiting for event from node");
for e in source.event()? {
- status.broker_event(&e);
+ page.broker_event(&e);
debug!("broker event {e:#?}");
let req = Request::trigger(&profile, &e)?;
- broker.execute_ci(&req, &mut status)?;
+ broker.execute_ci(&req, &mut page)?;
}
}
}
-fn status_updater(mut status: Status, interval: Duration) {
+fn status_updater(dirname: PathBuf, mut page: StatusPage, interval: Duration) {
+ let filename = dirname.join("status.json");
loop {
- if let Err(e) = status.write() {
- eprintln!("ERROR: failed to update status page: {e}");
+ page.update_timestamp();
+ if let Err(e) = page.write_json(&filename) {
+ eprintln!("ERROR: failed to update {}: {e}", filename.display());
+ }
+ if let Err(e) = page.write(&dirname) {
+ eprintln!(
+ "ERROR: failed to update repot pages in {}: {e}",
+ dirname.display()
+ );
}
sleep(interval);
}
diff --git a/src/bin/pagegen.rs b/src/bin/pagegen.rs
new file mode 100644
index 0000000..34b9742
--- /dev/null
+++ b/src/bin/pagegen.rs
@@ -0,0 +1,77 @@
+use std::{path::Path, str::FromStr};
+
+use radicle::git::Oid;
+use radicle::prelude::RepoId;
+
+use radicle_ci_broker::{
+ msg::{RunId, RunResult},
+ pages::{PageBuilder, PageError},
+ run::{Run, RunState, Whence},
+};
+
+const DIR: &str = "html";
+
+fn main() -> Result<(), PageError> {
+ let mut page = PageBuilder::default()
+ .node_alias("radicle.liw.fi")
+ .build()?;
+
+ let runid1 = RunId::default();
+ let rid1 = RepoId::from_urn("rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5").unwrap();
+ let alias1 = "heartwood";
+ let mut run1 = Run::new(
+ runid1.clone(),
+ rid1,
+ alias1,
+ Whence::branch(
+ "master",
+ Oid::from_str("a48081f2717f069d456ec09f31d9e639b232dbed").unwrap(),
+ ),
+ "2024-02-27T18:29:25+02:00".into(),
+ // RunState::Running,
+ // RunResult::Success,
+ );
+ run1.set_state(RunState::Running);
+ run1.set_result(RunResult::Success);
+ page.push_run(run1.clone());
+
+ let mut run2 = run1.clone();
+ run2.set_state(RunState::Finished);
+ page.push_run(run2);
+
+ let mut run3 = Run::new(
+ RunId::default(),
+ rid1,
+ alias1,
+ Whence::patch(
+ Oid::from_str("60abd513e0fb858c0dfe95ad6c4aaeace9c25d60").unwrap(),
+ Oid::from_str("091f7b7e986d05381718e2aeed2497c55dd0179a").unwrap(),
+ ),
+ "2024-02-27T18:29:09+02:00".into(),
+ // RunState::Finished,
+ // RunResult::Failure,
+ );
+ run3.set_state(RunState::Finished);
+ run3.set_result(RunResult::Failure);
+ page.push_run(run3);
+
+ let rid2 = RepoId::from_urn("rad:zwTxygwuz5LDGBq255RA2CbNGrz8").unwrap();
+ let alias2 = "radicle-ci-broker";
+ let mut run4 = Run::new(
+ RunId::default(),
+ rid2,
+ alias2,
+ Whence::branch(
+ "master",
+ Oid::from_str("79469d57841632ec4c0041f564e0b2b024abe7ec").unwrap(),
+ ),
+ "2024-02-27T18:29:25+02:00".into(),
+ );
+ run4.set_state(RunState::Finished);
+ run4.set_result(RunResult::Success);
+ page.push_run(run4);
+
+ page.write(Path::new(DIR)).unwrap();
+
+ Ok(())
+}
diff --git a/src/bin/status.rs b/src/bin/status.rs
deleted file mode 100644
index bcf592c..0000000
--- a/src/bin/status.rs
+++ /dev/null
@@ -1,19 +0,0 @@
-use std::{error::Error, path::Path};
-
-use radicle_ci_broker::status::*;
-
-fn main() {
- if let Err(e) = fallible_main() {
- eprintln!("ERROR: {e}");
- let mut e = e.source();
- while let Some(source) = e {
- eprintln!("caused by: {}", source);
- e = source.source();
- }
- }
-}
-
-fn fallible_main() -> Result<(), StatusError> {
- Status::new(Path::new("status.json")).write()?;
- Ok(())
-}
diff --git a/src/broker.rs b/src/broker.rs
index b7669c4..5194a93 100644
--- a/src/broker.rs
+++ b/src/broker.rs
@@ -5,9 +5,17 @@
use std::collections::HashMap;
+use time::{macros::format_description, OffsetDateTime};
+
use radicle::prelude::RepoId;
-use crate::{adapter::Adapter, error::BrokerError, msg::Request, run::Run, status::Status};
+use crate::{
+ adapter::Adapter,
+ error::BrokerError,
+ msg::{PatchEvent, PushEvent, Request, RunId},
+ pages::StatusPage,
+ run::{Run, Whence},
+};
/// A CI broker.
///
@@ -38,40 +46,66 @@ impl Broker {
}
#[allow(clippy::result_large_err)]
- pub fn execute_ci(&self, trigger: &Request, status: &mut Status) -> Result<Run, BrokerError> {
- let adapter = match trigger {
+ pub fn execute_ci(
+ &self,
+ trigger: &Request,
+ status: &mut StatusPage,
+ ) -> Result<Run, BrokerError> {
+ let run = match trigger {
Request::Trigger {
common,
- push: _,
- patch: _,
+ push,
+ patch,
} => {
let rid = &common.repository.id;
if let Some(adapter) = self.adapter(rid) {
- adapter
+ let whence = if let Some(PushEvent {
+ pusher: _,
+ before: _,
+ after,
+ commits: _,
+ }) = push
+ {
+ Whence::branch("push-event-has-no-branch-name", *after)
+ } else if let Some(PatchEvent { action: _, patch }) = patch {
+ Whence::patch(patch.id, patch.after)
+ } else {
+ panic!("neither push not patch event");
+ };
+
+ let mut run = Run::new(
+ RunId::default(),
+ *rid,
+ &common.repository.name,
+ whence,
+ now(),
+ );
+ adapter.run(trigger, &mut run, status)?;
+ run
} else {
return Err(BrokerError::NoAdapter(*rid));
}
}
};
- let mut run = Run::default();
- adapter.run(trigger, &mut run, status)?;
-
Ok(run)
}
}
+fn now() -> String {
+ let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]Z");
+ OffsetDateTime::now_utc().format(fmt).expect("format time")
+}
+
#[cfg(test)]
mod test {
- use std::path::Path;
-
use tempfile::tempdir;
use super::{Adapter, Broker, RepoId};
use crate::{
msg::{RunId, RunResult},
+ pages::{PageBuilder, StatusPage},
run::RunState,
- status::Status,
test::{mock_adapter, trigger_request, TestResult},
};
@@ -85,6 +119,13 @@ mod test {
RepoId::from_urn(RID).unwrap()
}
+ fn status_page() -> StatusPage {
+ PageBuilder::default()
+ .node_alias("test.alias")
+ .build()
+ .unwrap()
+ }
+
#[test]
fn has_no_adapters_initially() -> TestResult<()> {
let broker = Broker::default();
@@ -142,7 +183,7 @@ mod test {
}
#[test]
- fn exectues_adapter() -> TestResult<()> {
+ fn executes_adapter() -> TestResult<()> {
const ADAPTER: &str = r#"#!/bin/bash
echo '{"response":"triggered","run_id":{"id":"xyzzy"}}'
echo '{"response":"finished","result":"success"}'
@@ -157,7 +198,7 @@ echo '{"response":"finished","result":"success"}'
let trigger = trigger_request()?;
- let mut status = Status::new(Path::new("/dev/null"));
+ let mut status = status_page();
let x = broker.execute_ci(&trigger, &mut status);
assert!(x.is_ok());
let run = x.unwrap();
diff --git a/src/config.rs b/src/config.rs
index be4de35..3823a9b 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -16,7 +16,7 @@ pub struct Config {
pub default_adapter: String,
pub adapters: HashMap<String, Adapter>,
pub filters: Vec<EventFilter>,
- pub status_page: Option<PathBuf>,
+ pub report_dir: Option<PathBuf>,
pub status_update_interval_seconds: Option<u64>,
}
@@ -31,10 +31,6 @@ impl Config {
self.adapters.get(name)
}
- pub fn status_page(&self) -> Option<&Path> {
- self.status_page.as_deref()
- }
-
pub fn status_page_update_interval(&self) -> u64 {
self.status_update_interval_seconds
.unwrap_or(DEFAULT_STATUS_PAGE_UPDATE_INTERVAL)
diff --git a/src/error.rs b/src/error.rs
index 5682474..2c4c1a1 100644
--- a/src/error.rs
+++ b/src/error.rs
@@ -12,7 +12,7 @@ use crate::{
adapter::AdapterError,
config::ConfigError,
msg::{MessageError, Request},
- status::StatusError,
+ pages::PageError,
};
/// All possible errors from the CI broker messages.
@@ -65,5 +65,5 @@ pub enum BrokerError {
/// Status page error.
#[error(transparent)]
- Status(#[from] StatusError),
+ StatusPage(#[from] PageError),
}
diff --git a/src/lib.rs b/src/lib.rs
index a39ff25..2855d5a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -11,7 +11,7 @@ pub mod config;
pub mod error;
pub mod event;
pub mod msg;
+pub mod pages;
pub mod run;
-pub mod status;
#[cfg(test)]
pub mod test;
diff --git a/src/msg.rs b/src/msg.rs
index df67cb8..1591eee 100644
--- a/src/msg.rs
+++ b/src/msg.rs
@@ -12,6 +12,7 @@
use std::{
fmt,
+ hash::{Hash, Hasher},
io::{BufRead, BufReader, Read, Write},
};
@@ -50,6 +51,12 @@ impl Default for RunId {
}
}
+impl Hash for RunId {
+ fn hash<H: Hasher>(&self, h: &mut H) {
+ self.id.hash(h);
+ }
+}
+
impl From<&str> for RunId {
fn from(id: &str) -> Self {
Self { id: id.into() }
diff --git a/src/pages.rs b/src/pages.rs
new file mode 100644
index 0000000..bc61243
--- /dev/null
+++ b/src/pages.rs
@@ -0,0 +1,451 @@
+//! Status and report pages for CI broker.
+//!
+//! This module generates an HTML status page for the CI broker, as
+//! well as per-repository pages for any repository for which the CI
+//! broker has mediated to run CI. The status page gives the latest
+//! known status of the broker, plus lists the repositories that CI
+//! has run for. The per-repository pages lists all the runs for that
+//! repository.
+
+use std::{
+ collections::{HashMap, HashSet},
+ fs::write,
+ path::{Path, PathBuf},
+ sync::{Arc, Mutex, MutexGuard},
+};
+
+use html_page::{Document, Element, Tag};
+use serde::Serialize;
+use time::{macros::format_description, OffsetDateTime};
+
+use radicle::prelude::RepoId;
+
+use crate::{
+ event::BrokerEvent,
+ msg::RunId,
+ run::{Run, RunState, Whence},
+};
+
+const CSS: &str = include_str!("radicle-ci.css");
+
+/// All possible errors returned from the status page module.
+#[derive(Debug, thiserror::Error)]
+pub enum PageError {
+ #[error("failed to write status page to {0}")]
+ Write(PathBuf, #[source] std::io::Error),
+
+ #[error("no node alias has been set for builder")]
+ NoAlias,
+
+ #[error("no status data has been set for builder")]
+ NoStatusData,
+}
+
+/// A builder for constructing a [`StatusPage`] value. It will only
+/// construct a valid value.
+#[derive(Default)]
+pub struct PageBuilder {
+ node_alias: Option<String>,
+}
+
+impl PageBuilder {
+ pub fn node_alias(mut self, alias: &str) -> Self {
+ self.node_alias = Some(alias.into());
+ self
+ }
+
+ pub fn build(self) -> Result<StatusPage, PageError> {
+ Ok(StatusPage::new(PageData {
+ timestamp: now(),
+ ci_broker_version: env!("CARGO_PKG_VERSION"),
+ ci_broker_git_commit: env!("GIT_HEAD"),
+ node_alias: self.node_alias.ok_or(PageError::NoAlias)?,
+ runs: HashMap::new(),
+ broker_event_counter: 0,
+ latest_broker_event: None,
+ latest_ci_run: None,
+ }))
+ }
+}
+
+fn now() -> String {
+ let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]Z");
+ OffsetDateTime::now_utc().format(fmt).ok().unwrap()
+}
+
+struct PageData {
+ timestamp: String,
+ ci_broker_version: &'static str,
+ ci_broker_git_commit: &'static str,
+ node_alias: String,
+ runs: HashMap<RunId, Run>,
+ broker_event_counter: usize,
+ latest_broker_event: Option<BrokerEvent>,
+ latest_ci_run: Option<Run>,
+}
+
+impl PageData {
+ fn status_page_as_html(&self) -> Document {
+ let mut doc = Document::default();
+
+ doc.push_to_head(
+ &Element::new(Tag::Title)
+ .with_text("CI for Radicle node ")
+ .with_text(&self.node_alias),
+ );
+
+ doc.push_to_head(&Element::new(Tag::Style).with_text(CSS));
+
+ doc.push_to_body(
+ &Element::new(Tag::H1)
+ .with_text("CI for Radicle node ")
+ .with_text(&self.node_alias),
+ );
+
+ doc.push_to_body(&Element::new(Tag::H2).with_text("Broker status"));
+ doc.push_to_body(
+ &Element::new(Tag::P)
+ .with_text("Last updated: ")
+ .with_text(&self.timestamp),
+ );
+ doc.push_to_body(
+ &Element::new(Tag::P)
+ .with_text("CI broker version: ")
+ .with_text(self.ci_broker_version)
+ .with_text(" (commit ")
+ .with_child(Element::new(Tag::Code).with_text(self.ci_broker_git_commit))
+ .with_text(")"),
+ );
+
+ doc.push_to_body(&Element::new(Tag::H2).with_text("Repositories"));
+
+ doc.push_to_body(&Element::new(Tag::P).with_text("Latest CI run for each repository."));
+
+ let mut list = Element::new(Tag::Ul).with_attribute("class", "repolist");
+ for (alias, rid) in self.repos() {
+ let mut item = Element::new(Tag::Li);
+
+ item.push_child(
+ &Element::new(Tag::Span).with_child(
+ Element::new(Tag::A)
+ .with_attribute("href", &format!("{}.html", rid_to_basename(rid)))
+ .with_text("Repository ")
+ .with_child(
+ Element::new(Tag::Span)
+ .with_attribute("class", "alias")
+ .with_text(&alias),
+ )
+ .with_text(" (")
+ .with_child(
+ Element::new(Tag::Code)
+ .with_attribute("class", "repoid")
+ .with_text(&rid.to_string()),
+ )
+ .with_text(")"),
+ ),
+ );
+
+ if let Some(run) = self.latest_run(rid) {
+ item.push_child(&Element::new(Tag::Br));
+ item.push_child(
+ &Element::new(Tag::Span)
+ .with_text(run.timestamp())
+ .with_child(Element::new(Tag::Br))
+ .with_text(" ")
+ .with_child(Self::whence_as_html(run.whence())),
+ );
+ item.push_child(&Element::new(Tag::Br));
+
+ let state = run.state().to_string();
+ item.push_child(
+ &Element::new(Tag::Span)
+ .with_attribute("class", &state)
+ .with_text(&state),
+ );
+ }
+
+ list.push_child(&item);
+ }
+ doc.push_to_body(&list);
+
+ doc
+ }
+
+ fn whence_as_html(whence: &Whence) -> Element {
+ match whence {
+ Whence::Branch { name, commit } => Element::new(Tag::Span)
+ .with_text("branch ")
+ .with_child(
+ Element::new(Tag::Code)
+ .with_attribute("class", "branch")
+ .with_text(name),
+ )
+ .with_text(", commit ")
+ .with_child(
+ Element::new(Tag::Code)
+ .with_attribute("class", "commit")
+ .with_text(&commit.to_string()),
+ ),
+ Whence::Patch { patch, commit } => Element::new(Tag::Span)
+ .with_text("patch ")
+ .with_child(
+ Element::new(Tag::Code)
+ .with_attribute("class", "branch")
+ .with_text(&patch.to_string()),
+ )
+ .with_text(", commit ")
+ .with_child(
+ Element::new(Tag::Code)
+ .with_attribute("class", "commit")
+ .with_text(&commit.to_string()),
+ ),
+ }
+ }
+
+ fn per_repo_page_as_html(&self, rid: RepoId, alias: &str, timestamp: &str) -> Document {
+ let mut doc = Document::default();
+
+ doc.push_to_head(
+ &Element::new(Tag::Title)
+ .with_text("CI runs for repository ")
+ .with_text(alias),
+ );
+
+ doc.push_to_head(&Element::new(Tag::Style).with_text(CSS));
+
+ doc.push_to_body(
+ &Element::new(Tag::H1)
+ .with_text("CI runs for repository ")
+ .with_text(alias),
+ );
+
+ doc.push_to_body(
+ &Element::new(Tag::P)
+ .with_text("Last updated: ")
+ .with_text(timestamp),
+ );
+
+ doc.push_to_body(
+ &Element::new(Tag::P)
+ .with_text("Repository ID ")
+ .with_child(Element::new(Tag::Code).with_text(&rid.to_string())),
+ );
+
+ let mut runs = self.runs(rid);
+ runs.sort_by_cached_key(|run| run.timestamp());
+ runs.reverse();
+ let mut list = Element::new(Tag::Ol).with_attribute("class", "runlist");
+ for run in runs {
+ let current = match run.state() {
+ RunState::Triggered => Element::new(Tag::Span)
+ .with_attribute("state", "triggered")
+ .with_text("triggered"),
+ RunState::Running => Element::new(Tag::Span)
+ .with_attribute("class", "running")
+ .with_text("running"),
+ RunState::Finished => {
+ let result = if let Some(result) = run.result() {
+ result.to_string()
+ } else {
+ "unknown".into()
+ };
+ Element::new(Tag::Span)
+ .with_attribute("class", &result)
+ .with_text(&result)
+ }
+ };
+
+ let link = Element::new(Tag::A)
+ .with_attribute("href", &format!("{}/log.html", run.broker_run_id()))
+ .with_text("log");
+
+ list.push_child(
+ &Element::new(Tag::Li)
+ .with_text(run.timestamp())
+ .with_text(" ")
+ .with_child(current)
+ .with_text(" ")
+ .with_child(link)
+ .with_child(Element::new(Tag::Br))
+ .with_child(Self::whence_as_html(run.whence())),
+ );
+ }
+
+ doc.push_to_body(&list);
+
+ doc
+ }
+
+ fn repos(&self) -> Vec<(String, RepoId)> {
+ let rids: HashSet<(String, RepoId)> = self
+ .runs
+ .values()
+ .map(|run| (run.repo_alias().to_string(), run.repo_id()))
+ .collect();
+ let mut repos: Vec<(String, RepoId)> = rids.iter().cloned().collect();
+ repos.sort();
+ repos
+ }
+
+ fn repo_alias(&self, wanted: RepoId) -> Option<String> {
+ self.repos().iter().find_map(|(alias, rid)| {
+ if *rid == wanted {
+ Some(alias.into())
+ } else {
+ None
+ }
+ })
+ }
+
+ fn runs(&self, repoid: RepoId) -> Vec<&Run> {
+ self.runs
+ .iter()
+ .filter_map(|(_, run)| {
+ if run.repo_id() == repoid {
+ Some(run)
+ } else {
+ None
+ }
+ })
+ .collect()
+ }
+
+ fn latest_run(&self, repoid: RepoId) -> Option<&Run> {
+ let mut value: Option<&Run> = None;
+ for run in self.runs(repoid) {
+ if let Some(latest) = value {
+ if run.timestamp() > latest.timestamp() {
+ value = Some(run);
+ }
+ } else {
+ value = Some(run);
+ }
+ }
+ value
+ }
+}
+
+/// Data for status pages for CI broker.
+///
+/// There is a "front page" with status about the broker, and a list
+/// of repositories for which the broker has run CI. Then there is a
+/// page per such repository, with a list of CI runs for that
+/// repository.
+pub struct StatusPage {
+ data: Arc<Mutex<PageData>>,
+}
+
+impl StatusPage {
+ fn new(data: PageData) -> Self {
+ Self {
+ data: Arc::new(Mutex::new(data)),
+ }
+ }
+
+ fn lock(&mut self) -> MutexGuard<PageData> {
+ self.data.lock().expect("lock StatusPage::data")
+ }
+
+ pub fn update_timestamp(&mut self) {
+ let mut data = self.lock();
+ data.timestamp = now();
+ }
+
+ pub fn broker_event(&mut self, event: &BrokerEvent) {
+ let mut data = self.lock();
+ data.latest_broker_event = Some(event.clone());
+ data.broker_event_counter += 1;
+ }
+
+ /// Add a new CI run to the status page.
+ pub fn push_run(&mut self, new: Run) {
+ let mut data = self.lock();
+ data.latest_ci_run = Some(new.clone());
+ data.runs.insert(new.broker_run_id().clone(), new);
+ }
+
+ /// Write the status page (as index.html) and per-repository pages
+ /// (`<RID>.html`) into the directory given as an argument. The directory must exist.
+ pub fn write(&mut self, dirname: &Path) -> Result<(), PageError> {
+ let nameless = String::from("nameless repo");
+
+ // We avoid writing while keeping the lock, to reduce
+ // contention.
+ let (status, repos) = {
+ let data = self.lock();
+
+ let status = data.status_page_as_html().to_string();
+
+ let mut repos = vec![];
+ for (_, rid) in data.repos() {
+ let basename = rid_to_basename(rid);
+ let filename = dirname.join(format!("{basename}.html"));
+ let alias = data.repo_alias(rid).unwrap_or(nameless.clone());
+ let repopage = data.per_repo_page_as_html(rid, &alias, &data.timestamp);
+ repos.push((filename, repopage.to_string()));
+ }
+
+ (status, repos)
+ };
+
+ write(dirname.join("index.html"), status).unwrap();
+
+ for (filename, repopage) in repos {
+ write(filename, repopage).unwrap();
+ }
+
+ Ok(())
+ }
+
+ /// Write the JSON status file.
+ pub fn write_json(&mut self, filename: &Path) -> Result<(), PageError> {
+ // We avoid writing while keeping the lock, to reduce
+ // contention.
+ let status = {
+ let data = self.lock();
+ serde_json::to_string(&StatusData::from(&*data)).unwrap()
+ };
+
+ write(filename, status).unwrap();
+
+ Ok(())
+ }
+}
+
+impl Clone for StatusPage {
+ fn clone(&self) -> Self {
+ Self {
+ data: Arc::clone(&self.data),
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize)]
+struct StatusData {
+ timestamp: String,
+ broker_event_counter: usize,
+ ci_broker_version: &'static str,
+ ci_broker_git_commit: &'static str,
+ latest_broker_event: Option<BrokerEvent>,
+ latest_ci_run: Option<Run>,
+}
+
+impl From<&PageData> for StatusData {
+ fn from(page: &PageData) -> Self {
+ Self {
+ timestamp: page.timestamp.clone(),
+ broker_event_counter: page.broker_event_counter,
+ ci_broker_version: page.ci_broker_version,
+ ci_broker_git_commit: page.ci_broker_git_commit,
+ latest_broker_event: page.latest_broker_event.clone(),
+ latest_ci_run: page.latest_ci_run.clone(),
+ }
+ }
+}
+
+fn rid_to_basename(repoid: RepoId) -> String {
+ let mut basename = repoid.to_string();
+ assert!(basename.starts_with("rad:"));
+ basename.drain(..4);
+ basename
+}
diff --git a/src/radicle-ci.css b/src/radicle-ci.css
new file mode 100644
index 0000000..ab8c5fe
--- /dev/null
+++ b/src/radicle-ci.css
@@ -0,0 +1,50 @@
+ul.repolist li {
+ margin-top: 1em;
+}
+
+ol.runlist li {
+ margin-top: 1em;
+}
+span.success {
+ color: white;
+ background-color: green;
+}
+span.failure {
+ color: red;
+ background-color: white;
+}
+span.unknown {
+ color: white;
+ background-color: grey;
+}
+
+span.alias {
+ font-weight: bold;
+}
+
+code.branch {
+ font-weight: bold;
+}
+
+code.patch {
+ font-weight: bold;
+}
+
+code.commit {
+ font-weight: bold;
+}
+
+code.repoid {
+ font-weight: bold;
+}
+
+span.triggered {
+ font-weight: bold;
+}
+
+span.running {
+ color: red;
+}
+
+span.finished {
+}
diff --git a/src/run.rs b/src/run.rs
index 0b0ee58..94a93b4 100644
--- a/src/run.rs
+++ b/src/run.rs
@@ -2,28 +2,64 @@ use std::fmt;
use serde::{Deserialize, Serialize};
+use radicle::git::Oid;
+use radicle::prelude::RepoId;
+
use crate::msg::{RunId, RunResult};
#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
pub struct Run {
broker_run_id: RunId,
adapter_run_id: Option<RunId>,
+ repo_id: RepoId,
+ repo_alias: String,
+ timestamp: String,
+ whence: Whence,
state: RunState,
result: Option<RunResult>,
}
-impl Default for Run {
- fn default() -> Self {
+impl Run {
+ /// Create a new run.
+ pub fn new(
+ run_id: RunId,
+ repo_id: RepoId,
+ alias: &str,
+ whence: Whence,
+ timestamp: String,
+ ) -> Self {
Self {
- broker_run_id: RunId::default(),
+ broker_run_id: run_id,
adapter_run_id: None,
+ repo_id,
+ repo_alias: alias.into(),
+ timestamp,
+ whence,
state: RunState::Triggered,
result: None,
}
}
-}
-impl Run {
+ /// Return the repo alias.
+ pub fn repo_alias(&self) -> &str {
+ &self.repo_alias
+ }
+
+ /// Return the repo id.
+ pub fn repo_id(&self) -> RepoId {
+ self.repo_id
+ }
+
+ /// Return timestamp of run.
+ pub fn timestamp(&self) -> &str {
+ &self.timestamp
+ }
+
+ /// Return where the commit came from.
+ pub fn whence(&self) -> &Whence {
+ &self.whence
+ }
+
/// Return the run id assigned by the broker. This is set when the
/// run is created and can't be changed.
pub fn broker_run_id(&self) -> &RunId {
@@ -86,6 +122,26 @@ impl fmt::Display for RunState {
}
}
+/// Where did the commit come that CI is run for?
+#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)]
+pub enum Whence {
+ Branch { name: String, commit: Oid },
+ Patch { patch: Oid, commit: Oid },
+}
+
+impl Whence {
+ pub fn branch(name: &str, commit: Oid) -> Self {
+ Self::Branch {
+ name: name.into(),
+ commit,
+ }
+ }
+
+ pub fn patch(patch: Oid, commit: Oid) -> Self {
+ Self::Patch { patch, commit }
+ }
+}
+
#[cfg(test)]
mod test {
use super::*;
diff --git a/src/status.rs b/src/status.rs
deleted file mode 100644
index f79c0bb..0000000
--- a/src/status.rs
+++ /dev/null
@@ -1,128 +0,0 @@
-use std::{
- path::{Path, PathBuf},
- sync::{Arc, Mutex, MutexGuard},
-};
-
-use serde::Serialize;
-use time::{macros::format_description, OffsetDateTime};
-
-use crate::{event::BrokerEvent, run::Run};
-
-#[derive(Debug, Serialize)]
-struct StatusData {
- timestamp: String,
- broker_event_counter: usize,
- ci_broker_version: &'static str,
- ci_broker_git_commit: &'static str,
- latest_broker_event: Option<BrokerEvent>,
- latest_ci_run: Option<Run>,
-}
-
-impl Default for StatusData {
- fn default() -> Self {
- Self {
- timestamp: "".into(),
- broker_event_counter: 0,
- ci_broker_version: env!("CARGO_PKG_VERSION"),
- ci_broker_git_commit: env!("GIT_HEAD"),
- latest_broker_event: None,
- latest_ci_run: None,
- }
- }
-}
-
-impl StatusData {
- fn write(&self, filename: &Path) -> Result<(), StatusError> {
- let tmp = filename.with_extension("update");
- let s = serde_json::to_string_pretty(&self).map_err(StatusError::serialize)?;
- std::fs::write(&tmp, s.as_bytes()).map_err(|e| StatusError::status_write(filename, e))?;
- std::fs::rename(&tmp, filename).map_err(|e| StatusError::status_rename(filename, e))?;
- Ok(())
- }
-}
-
-pub struct Status {
- filename: PathBuf,
- status: Arc<Mutex<StatusData>>,
-}
-
-impl Status {
- pub fn new(filename: &Path) -> Self {
- Self {
- filename: filename.into(),
- status: Arc::new(Mutex::new(StatusData::default())),
- }
- }
-
- fn lock(&mut self) -> MutexGuard<StatusData> {
- self.status.lock().expect("lock StatusGuard::status")
- }
-
- pub fn broker_event(&mut self, event: &BrokerEvent) {
- let mut status = self.lock();
- status.latest_broker_event = Some(event.clone());
- status.broker_event_counter += 1;
- }
-
- pub fn ci_run(&mut self, run: &Run) {
- let mut status = self.lock();
- status.latest_ci_run = Some(run.clone());
- }
-
- pub fn write(&mut self) -> Result<(), StatusError> {
- let filename = self.filename.clone();
- let mut status = self.lock();
- status.timestamp = Self::now()?;
- status.write(&filename)?;
- Ok(())
- }
-
- fn now() -> Result<String, StatusError> {
- let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]Z");
- OffsetDateTime::now_utc()
- .format(fmt)
- .map_err(StatusError::format_now)
- }
-}
-
-impl Clone for Status {
- fn clone(&self) -> Self {
- Self {
- filename: self.filename.clone(),
- status: Arc::clone(&self.status),
- }
- }
-}
-
-#[derive(Debug, thiserror::Error)]
-pub enum StatusError {
- #[error("failed to format current time stamp")]
- FormatNow(#[source] time::error::Format),
-
- #[error("failed to serialize status as JSON")]
- Serizalize(#[source] serde_json::Error),
-
- #[error("failed to write status to file {0}")]
- StatusWrite(PathBuf, #[source] std::io::Error),
-
- #[error("failed to rename status to file {0}")]
- StatusRename(PathBuf, #[source] std::io::Error),
-}
-
-impl StatusError {
- fn format_now(err: time::error::Format) -> Self {
- Self::FormatNow(err)
- }
-
- fn serialize(err: serde_json::Error) -> Self {
- Self::Serizalize(err)
- }
-
- fn status_write(filename: &Path, err: std::io::Error) -> Self {
- Self::StatusWrite(filename.into(), err)
- }
-
- fn status_rename(filename: &Path, err: std::io::Error) -> Self {
- Self::StatusRename(filename.into(), err)
- }
-}