mirror of
https://seed.radicle.dev/rad:z3qg5TKmN83afz2fj9z3fQjU8vaYE.git
synced 2026-11-16 00:10:39 +08:00
368 lines
12 KiB
Rust
368 lines
12 KiB
Rust
use std::{
|
|
path::{Path, PathBuf},
|
|
time::{Duration, SystemTime},
|
|
};
|
|
|
|
use html_page::{Element, HtmlPage, Tag};
|
|
use time::{macros::format_description, OffsetDateTime};
|
|
|
|
use radicle_ci_broker::msg::{Oid, RepoId, Request, RunId};
|
|
|
|
use crate::{
|
|
run::RUNSPEC_PATH,
|
|
runspec::{RunSpec, RunSpecError},
|
|
};
|
|
|
|
const CSS: &str = include_str!("native-ci.css");
|
|
|
|
#[derive(Debug, Default)]
|
|
pub struct RunLog {
|
|
filename: PathBuf,
|
|
title: Option<String>,
|
|
request: Option<Request>,
|
|
rid: Option<RepoId>,
|
|
repo_name: Option<String>,
|
|
commit: Option<Oid>,
|
|
branch: Option<String>,
|
|
patch: Option<(Oid, String)>,
|
|
commands: Vec<Command>,
|
|
runspec: Option<RunSpec>,
|
|
runspec_error: Option<String>,
|
|
adapter_run_id: Option<RunId>,
|
|
started: Option<SystemTime>,
|
|
duration: Option<Duration>,
|
|
}
|
|
|
|
impl RunLog {
|
|
pub fn new(filename: &Path) -> Self {
|
|
Self {
|
|
filename: filename.into(),
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
pub fn adapter_run_id(&mut self, id: RunId) {
|
|
self.adapter_run_id = Some(id);
|
|
}
|
|
|
|
pub fn title(&mut self, title: &str) {
|
|
self.title = Some(title.into());
|
|
}
|
|
|
|
pub fn request(&mut self, request: Request) {
|
|
self.request = Some(request);
|
|
}
|
|
|
|
pub fn rid(&mut self, rid: RepoId, name: &str) {
|
|
self.rid = Some(rid);
|
|
self.repo_name = Some(name.into());
|
|
}
|
|
|
|
pub fn commit(&mut self, commit: Oid) {
|
|
self.commit = Some(commit);
|
|
}
|
|
|
|
pub fn branch(&mut self, branch: &str) {
|
|
self.branch = Some(branch.into());
|
|
}
|
|
|
|
pub fn patch(&mut self, patch: Oid, title: &str) {
|
|
self.patch = Some((patch, title.into()));
|
|
}
|
|
|
|
pub fn all_commands_succeeded(&self) -> bool {
|
|
self.commands.iter().all(|c| c.exit == 0) && self.runspec_error.is_none()
|
|
}
|
|
|
|
pub fn runspec(&mut self, runspec: RunSpec) {
|
|
self.runspec = Some(runspec);
|
|
}
|
|
|
|
pub fn runspec_error(&mut self, e: &RunSpecError) {
|
|
self.runspec_error = Some(format!("{}", e));
|
|
}
|
|
|
|
pub fn started(&mut self, ts: SystemTime) {
|
|
self.started = Some(ts);
|
|
}
|
|
|
|
pub fn duration(&mut self, dur: Duration) {
|
|
self.duration = Some(dur);
|
|
}
|
|
|
|
// FIXME: refactor this to maybe use a builder pattern?
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn runcmd(
|
|
&mut self,
|
|
argv: &[&str],
|
|
cwd: &Path,
|
|
exit: i32,
|
|
stdout: &[u8],
|
|
started: SystemTime,
|
|
ended: SystemTime,
|
|
) {
|
|
self.commands.push(Command {
|
|
started,
|
|
ended,
|
|
duration: ended.duration_since(started).expect("compute duration"),
|
|
argv: argv.iter().map(|a| a.to_string()).collect(),
|
|
cwd: cwd.into(),
|
|
exit,
|
|
stdout: stdout.to_vec(),
|
|
});
|
|
}
|
|
|
|
pub fn write(&self) -> Result<(), RunLogError> {
|
|
let title = self.title.as_ref().ok_or(RunLogError::Missing("title"))?;
|
|
let rid = self.rid.as_ref().ok_or(RunLogError::Missing("rid"))?;
|
|
let repo_name = self
|
|
.repo_name
|
|
.as_ref()
|
|
.ok_or(RunLogError::Missing("repo_name"))?;
|
|
let commit = self.commit.as_ref().ok_or(RunLogError::Missing("commit"))?;
|
|
|
|
let mut doc = HtmlPage::default();
|
|
|
|
doc.push_to_head(Element::new(Tag::Title).with_text(title));
|
|
doc.push_to_head(Element::new(Tag::Style).with_text(CSS));
|
|
doc.push_to_body(Element::new(Tag::H1).with_text(title));
|
|
|
|
eprintln!("adapter run id: {:?}", self.adapter_run_id);
|
|
let ul = Element::new(Tag::Ul)
|
|
.with_child(
|
|
Element::new(Tag::Li)
|
|
.with_text("Adapter run ID: ")
|
|
.with_child(if let Some(id) = &self.adapter_run_id {
|
|
Element::new(Tag::Span)
|
|
.with_class("run_id")
|
|
.with_text(id.as_str())
|
|
} else {
|
|
Element::new(Tag::Span).with_class("run_id")
|
|
}),
|
|
)
|
|
.with_child(
|
|
Element::new(Tag::Li)
|
|
.with_text("Repository id: ")
|
|
.with_child(Element::new(Tag::Code).with_text(&rid.to_string()))
|
|
.with_text(" ")
|
|
.with_text(repo_name),
|
|
)
|
|
.with_child(
|
|
Element::new(Tag::Li)
|
|
.with_text("Commit: ")
|
|
.with_child(Element::new(Tag::Code).with_text(&commit.to_string())),
|
|
)
|
|
.with_child(if let Some(branch) = &self.branch {
|
|
Element::new(Tag::Li)
|
|
.with_text("Branch: ")
|
|
.with_child(Element::new(Tag::Code).with_text(branch))
|
|
} else if let Some((patch, title)) = &self.patch {
|
|
Element::new(Tag::Li)
|
|
.with_text("Patch: ")
|
|
.with_child(Element::new(Tag::Code).with_text(&patch.to_string()))
|
|
.with_text(" ")
|
|
.with_text(title)
|
|
} else {
|
|
Element::new(Tag::Span)
|
|
})
|
|
.with_child({
|
|
let ts = if let Some(ts) = &self.started {
|
|
Element::new(Tag::Span).with_text(×tamp(ts))
|
|
} else {
|
|
Element::new(Tag::Span)
|
|
};
|
|
Element::new(Tag::Li).with_text("Started: ").with_child(ts)
|
|
})
|
|
.with_child({
|
|
let dur = if let Some(dur) = &self.duration {
|
|
Element::new(Tag::Span).with_text(&format!("{:1}", dur.as_secs_f64()))
|
|
} else {
|
|
Element::new(Tag::Span)
|
|
};
|
|
Element::new(Tag::Li)
|
|
.with_text("Duration: ")
|
|
.with_child(dur)
|
|
.with_text(" s")
|
|
})
|
|
.with_child(
|
|
Element::new(Tag::Li)
|
|
.with_text("Result: ")
|
|
.with_child(Element::new(Tag::B).with_text(self.result())),
|
|
);
|
|
doc.push_to_body(ul);
|
|
|
|
let mut toc = ToC::default();
|
|
let mut body = Element::new(Tag::Div);
|
|
|
|
body.push_child(
|
|
toc.push(
|
|
Element::new(Tag::Span)
|
|
.with_child(Element::new(Tag::Code).with_text("Request message")),
|
|
),
|
|
);
|
|
let req = serde_json::to_string_pretty(&self.request).unwrap();
|
|
let req = Element::new(Tag::Pre).with_text(&req);
|
|
body.push_child(req);
|
|
|
|
if let Some(e) = &self.runspec_error {
|
|
let error = Element::new(Tag::P)
|
|
.with_text("Failed to load .radicle/native.yaml: ")
|
|
.with_child(Element::new(Tag::Code).with_text(e));
|
|
body.push_child(error);
|
|
}
|
|
|
|
if let Some(runspec) = &self.runspec {
|
|
let text = serde_yaml::to_string(&runspec).map_err(RunLogError::RunSpec)?;
|
|
body.push_child(toc.push(Element::new(Tag::Span).with_child(
|
|
Element::new(Tag::Code).with_child(Element::new(Tag::Code).with_text(RUNSPEC_PATH)),
|
|
)));
|
|
body.push_child(
|
|
Element::new(Tag::Blockquote).with_child(Element::new(Tag::Pre).with_text(&text)),
|
|
);
|
|
}
|
|
|
|
for cmd in self.commands.iter() {
|
|
cmd.format(&mut body, &mut toc);
|
|
}
|
|
|
|
doc.push_to_body(toc.as_html());
|
|
doc.push_to_body(body);
|
|
|
|
let html = format!("{}", doc);
|
|
std::fs::write(&self.filename, html.as_bytes())
|
|
.map_err(|e| RunLogError::Write(self.filename.clone(), e))?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn result(&self) -> &'static str {
|
|
if self.all_commands_succeeded() {
|
|
"SUCCESS"
|
|
} else {
|
|
"FAILURE"
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Command {
|
|
started: SystemTime,
|
|
ended: SystemTime,
|
|
duration: Duration,
|
|
argv: Vec<String>,
|
|
cwd: PathBuf,
|
|
exit: i32,
|
|
stdout: Vec<u8>,
|
|
}
|
|
|
|
impl Command {
|
|
fn command(&self) -> String {
|
|
self.argv.as_slice().join(" ")
|
|
}
|
|
|
|
fn format(&self, body: &mut Element, toc: &mut ToC) {
|
|
body.push_child(
|
|
toc.push(
|
|
Element::new(Tag::Span)
|
|
.with_text("Run: ")
|
|
.with_child(Element::new(Tag::Code).with_text(&self.command())),
|
|
),
|
|
);
|
|
|
|
body.push_child(
|
|
Element::new(Tag::Ul)
|
|
.with_child(
|
|
Element::new(Tag::Li)
|
|
.with_text(&format!("Started: {}", timestamp(&self.started))),
|
|
)
|
|
.with_child(
|
|
Element::new(Tag::Li).with_text(&format!("Ended: {}", timestamp(&self.ended))),
|
|
)
|
|
.with_child(
|
|
Element::new(Tag::Li)
|
|
.with_text(&format!("Duration: {} s", self.duration.as_secs())),
|
|
),
|
|
);
|
|
|
|
body.push_child(Element::new(Tag::P).with_text("Command arguments:"));
|
|
let mut ul = Element::new(Tag::Ul);
|
|
for arg in self.argv.iter() {
|
|
ul.push_child(
|
|
Element::new(Tag::Li)
|
|
.with_child(Element::new(Tag::Code).with_text(&format!("{:?}", arg))),
|
|
);
|
|
}
|
|
body.push_child(ul);
|
|
|
|
body.push_child(
|
|
Element::new(Tag::P)
|
|
.with_text("In directory: ")
|
|
.with_child(Element::new(Tag::Code).with_text(&format!("{}", self.cwd.display()))),
|
|
);
|
|
|
|
body.push_child(Element::new(Tag::P).with_text(&format!("Exit code: {}", self.exit)));
|
|
|
|
self.output(body, "Output (stdout and stderr):", &self.stdout);
|
|
}
|
|
|
|
fn output(&self, body: &mut Element, stream: &str, data: &[u8]) {
|
|
if !data.is_empty() {
|
|
body.push_child(Element::new(Tag::P).with_text(stream));
|
|
body.push_child(
|
|
Element::new(Tag::Blockquote)
|
|
.with_child(Element::new(Tag::Pre).with_text(&String::from_utf8_lossy(data))),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn timestamp(when: &SystemTime) -> String {
|
|
let fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second]Z");
|
|
OffsetDateTime::from(*when).format(fmt).ok().unwrap()
|
|
}
|
|
|
|
#[derive(Debug, Default)]
|
|
struct ToC {
|
|
counter: usize,
|
|
headings: Vec<(String, Element)>,
|
|
}
|
|
|
|
impl ToC {
|
|
fn push(&mut self, content: Element) -> Element {
|
|
self.counter += 1;
|
|
let anchor = format!("h{}", self.counter);
|
|
self.headings.push((anchor.clone(), content.clone()));
|
|
Element::new(Tag::H2)
|
|
.with_attribute("id", &format!("h{}", self.counter))
|
|
.with_child(content)
|
|
}
|
|
|
|
fn as_html(&self) -> Element {
|
|
let mut toc =
|
|
Element::new(Tag::Div).with_child(Element::new(Tag::H2).with_text("Table of contents"));
|
|
let mut list = Element::new(Tag::Ul);
|
|
for (anchor, content) in self.headings.iter() {
|
|
let entry = Element::new(Tag::Li).with_child(
|
|
Element::new(Tag::A)
|
|
.with_attribute("href", &format!("#{anchor}"))
|
|
.with_child(content.clone()),
|
|
);
|
|
list.push_child(entry);
|
|
}
|
|
toc.push_child(list);
|
|
toc
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum RunLogError {
|
|
#[error("programming error: missing field for run log {0}")]
|
|
Missing(&'static str),
|
|
|
|
#[error("failed to write HTML run log to {0}")]
|
|
Write(PathBuf, #[source] std::io::Error),
|
|
|
|
#[error("failed to serialize run spec to YAML")]
|
|
RunSpec(#[source] serde_yaml::Error),
|
|
}
|