/*
* Copyright (c) 2026 Jonathan Perkin <jonathan@perkin.org.uk>
*
* Permission to use, copy, modify, and distribute this software for any
* purpose with or without fee is hereby granted, provided that the above
* copyright notice and this permission notice appear in all copies.
*
* THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
* WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
* ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
* WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
* ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
* OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
*/
/*!
* Implementation of the `bob publish` command.
*
* Package publishing has two knobs in `publish.packages`:
*
* - `tmppath` (optional): if unset, rsync writes straight to `path`.
* If set, rsync writes to `tmppath` with `--link-dest=path` so that
* unchanged files become hardlinks against the live tree.
* - `swapcmd` (optional, requires `tmppath`): a shell script run on
* the remote host after rsync completes. Either a literal string,
* or a Lua function returning a `scriptenv(run, env)` bundle so the
* env values can reference other config sections. The script is
* piped to `ssh host sh -eu` on stdin; `set -eu` is mandatory so
* failures and unset variable references abort immediately rather
* than silently proceeding. No default -- if `swapcmd` is unset,
* the staged data is left in `tmppath` and the caller is
* responsible for whatever happens next.
*
* Restricted packages (those with `NO_BIN_ON_FTP` set) are excluded
* from the upload filter list.
*
* Report publishing generates HTML, text, and machine-readable reports
* then rsyncs the log directory to a remote server. The build ID is
* appended to the remote path so each run gets its own directory.
*/
use std::collections::{HashMap, HashSet};
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use glob::Pattern;
use strum::IntoEnumIterator;
use tracing::{debug, info};
use bob::build::{BuildResult, BuildSummary, PkgBuildStats};
use bob::config::{Config, Publish, PublishPackages, ScriptValue};
use bob::db::Database;
use bob::{PackageCounts, PackageState, PackageStateKind};
struct PublishResult {
uploaded: usize,
restricted: usize,
}
pub fn run(
config: &Config,
db: &Database,
packages: bool,
report: bool,
email: bool,
dry_run: bool,
baseline: Option<&str>,
) -> Result<()> {
let build_id = db.build_id()?;
if packages {
let result = publish_packages(config, db, dry_run)?;
println!(
"Published {} packages ({} restricted excluded)",
result.uploaded, result.restricted
);
}
if report || email {
generate_reports(config, db, &build_id, baseline)?;
}
if report {
publish_report(config, &build_id, dry_run)?;
}
if email {
send_email(config, db, &build_id, dry_run)?;
}
Ok(())
}
fn publish_packages(config: &Config, db: &Database, dry_run: bool) -> Result<PublishResult> {
let publish = config
.publish()
.ok_or_else(|| anyhow::anyhow!("No publish section in configuration"))?;
let packages = publish
.packages
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No publish.packages section in configuration"))?;
let pkgsrc_env = db.load_pkgsrc_env()?;
let successful = db.get_successful_packages()?;
validate_pre_publish(packages, &successful)?;
if dry_run {
println!("Publishing packages (dry-run)...");
} else {
println!("Publishing packages...");
}
let restricted = db.get_restricted_packages()?;
if !restricted.is_empty() {
info!(count = restricted.len(), "Excluding restricted packages");
}
let uploadable: Vec<&String> = successful
.iter()
.filter(|p| !restricted.contains_key(p.as_str()))
.collect();
info!(
total = successful.len(),
uploadable = uploadable.len(),
restricted = restricted.len(),
"Package counts"
);
let filter_path = config.dbdir().join("rsync-filter");
write_rsync_filter(&uploadable, &filter_path)?;
let result = run_rsync(
publish,
packages,
&filter_path,
&pkgsrc_env.packages,
dry_run,
);
let _ = std::fs::remove_file(&filter_path);
result?;
if let Some(swapcmd) = &packages.swapcmd {
let remote = format_remote(&packages.host, packages.user.as_deref());
let script = build_remote_script(swapcmd)?;
if dry_run {
info!("Dry run: would pipe to ssh {} sh -eu:", remote);
for line in script.lines() {
println!(" {}", line);
}
} else {
info!(remote = %remote, "Running swapcmd via ssh");
run_remote_script(&remote, &script)?;
}
}
Ok(PublishResult {
uploaded: uploadable.len(),
restricted: restricted.len(),
})
}
fn generate_reports(
config: &Config,
db: &Database,
build_id: &str,
baseline: Option<&str>,
) -> Result<()> {
let publish = config
.publish()
.ok_or_else(|| anyhow::anyhow!("No publish section in configuration"))?;
let report_cfg = publish
.report
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No publish.report section in configuration"))?;
let pkgsrc_env = db.load_pkgsrc_env()?;
let vcs_info = db.load_vcs_info().unwrap_or_default();
let logdir = config.logdir();
let mut states = PackageCounts::default();
for r in &db.get_all_build_results()? {
states.add(&r.state);
}
for (_, _, state) in &db.get_prefailskip_packages()? {
states.add(state);
}
for (_, _, failed_dep) in &db.get_indirect_failures()? {
states.add(&PackageState::IndirectFailed(failed_dep.clone()));
}
let duration = db.get_build_duration()?;
let report_url = report_cfg
.url
.as_ref()
.map(|u| format!("{}/{}", u, build_id));
let diff = resolve_baseline(db, build_id, baseline)?
.and_then(|b| db.compute_build_diff(&b, build_id).ok());
let commits = diff
.as_ref()
.and_then(|d| compute_diff_commits(db, config, &vcs_info, d));
std::fs::create_dir_all(logdir)
.with_context(|| format!("Failed to create {}", logdir.display()))?;
write_variables_json(
&pkgsrc_env,
&vcs_info,
&states,
build_id,
report_url.as_deref(),
duration,
logdir,
diff.as_ref(),
)?;
let report_path = logdir.join("report.html");
let report_meta = ReportMeta {
build_id,
pkgsrc_env: &pkgsrc_env,
vcs_info: &vcs_info,
};
println!("Generating report...");
write_html_report(
db,
logdir,
&report_path,
&report_meta,
diff.as_ref(),
commits.as_ref(),
)?;
write_machine_report(db, logdir)?;
write_text_report(
db,
logdir,
&report_meta,
report_url.as_deref(),
diff.as_ref(),
)?;
Ok(())
}
fn resolve_baseline(
db: &Database,
build_id: &str,
baseline: Option<&str>,
) -> Result<Option<String>> {
let builds = db.list_history_builds()?;
match baseline {
Some(b) => {
if b == build_id {
bail!("--baseline must differ from the current build ({build_id})");
}
if !builds.iter().any(|h| h.build_id == b) {
bail!("Build ID '{b}' not found in history");
}
Ok(Some(b.to_string()))
}
None => Ok(builds
.iter()
.find(|h| h.build_id != build_id)
.map(|h| h.build_id.clone())),
}
}
fn publish_report(config: &Config, build_id: &str, dry_run: bool) -> Result<()> {
let publish = config
.publish()
.ok_or_else(|| anyhow::anyhow!("No publish section in configuration"))?;
let report_cfg = publish
.report
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No publish.report section in configuration"))?;
let logdir = config.logdir();
let rsync_args = &report_cfg.rsync_args;
let target = format!(
"{}:{}/{}",
format_remote(&report_cfg.host, report_cfg.user.as_deref()),
report_cfg.path,
build_id
);
info!(
target = %target,
source = %logdir.display(),
build_id = %build_id,
"Publishing report"
);
let mut cmd = Command::new(&publish.rsync);
for arg in rsync_args.split_whitespace() {
cmd.arg(arg);
}
if dry_run {
cmd.arg("--dry-run");
}
cmd.arg(".").arg(&target);
cmd.current_dir(logdir);
debug!(cmd = ?cmd, "Running rsync for report");
let status = cmd.status().context("Failed to execute rsync")?;
if !status.success() {
bail!(
"rsync failed with exit code {}",
status.code().unwrap_or(-1)
);
}
if !dry_run {
let report_url = report_cfg
.url
.as_ref()
.map(|u| format!("{}/{}", u, build_id));
if let Some(url) = &report_url {
println!("Report available at: {}/report.html", url);
}
}
Ok(())
}
// ========================================================================
// PRE-PUBLISH VALIDATION
// ========================================================================
fn send_email(config: &Config, db: &Database, build_id: &str, dry_run: bool) -> Result<()> {
let publish = config
.publish()
.ok_or_else(|| anyhow::anyhow!("No publish section in configuration"))?;
let report_cfg = publish
.report
.as_ref()
.ok_or_else(|| anyhow::anyhow!("No publish.report section in configuration"))?;
let from_str = report_cfg
.from
.as_deref()
.ok_or_else(|| anyhow::anyhow!("publish.report.from is required for email"))?;
if report_cfg.to.is_empty() {
bail!("publish.report.to is required for email");
}
let pkgsrc_env = db.load_pkgsrc_env()?;
let vcs_info = db.load_vcs_info().unwrap_or_default();
let platform = pkgsrc_env.platform().unwrap_or_default();
let branch = report_cfg
.branch
.as_deref()
.or(vcs_info.remote_branch.as_deref())
.or(vcs_info.local_branch.as_deref())
.ok_or_else(|| {
anyhow::anyhow!(
"Cannot determine branch for email subject. \
Set publish.report.branch in config."
)
})?;
let date = chrono::NaiveDateTime::parse_from_str(build_id, "%Y%m%dT%H%M%SZ")
.map(|dt| dt.format("%Y-%m-%d %H:%M").to_string())
.context("Failed to parse build ID as timestamp")?;
let subject = format!("{} - {} - {}", branch, platform, date);
let logdir = config.logdir();
let body = std::fs::read_to_string(logdir.join("report.txt"))
.context("Failed to read report.txt -- was the report generated?")?;
use lettre::message::Mailbox;
use lettre::message::header::{HeaderName, HeaderValue};
use lettre::{Message, SendmailTransport, Transport};
let from: Mailbox = from_str
.parse()
.with_context(|| format!("Invalid from address: {}", from_str))?;
let mut builder = Message::builder().from(from.clone()).subject(&subject);
for addr in &report_cfg.to {
let to: Mailbox = addr
.parse()
.with_context(|| format!("Invalid to address: {}", addr))?;
builder = builder.to(to);
}
if let Some(url) = &report_cfg.url {
let base = format!("{}/{}", url, build_id);
builder = builder.raw_header(HeaderValue::new(
HeaderName::new_from_ascii_str("X-Bob-Report-URL"),
format!("{}/report.html", base),
));
builder = builder.raw_header(HeaderValue::new(
HeaderName::new_from_ascii_str("X-Bob-Report-Raw"),
format!("{}/report.zst", base),
));
}
let message = builder
.body(body)
.context("Failed to build email message")?;
if dry_run {
info!("Dry run: would send email");
println!(" From: {}", from);
println!(" To: {}", report_cfg.to.join(", "));
println!(" Subject: {}", subject);
return Ok(());
}
let recipients = report_cfg.to.join(", ");
println!("Sending report email to {}...", recipients);
info!(from = %from, to = %recipients, subject = %subject, "Sending email");
let mailer = SendmailTransport::new();
mailer
.send(&message)
.context("Failed to send email via sendmail")?;
Ok(())
}
fn validate_pre_publish(packages: &PublishPackages, successful: &[String]) -> Result<()> {
if let Some(minimum) = packages.minimum {
if successful.len() < minimum {
bail!(
"Only {} successful packages, minimum required is {}",
successful.len(),
minimum
);
}
}
for pattern_str in &packages.required {
let pattern = Pattern::new(pattern_str).with_context(|| {
format!(
"Invalid glob pattern in publish.packages.required: {}",
pattern_str
)
})?;
let matched = successful.iter().any(|p| pattern.matches(p));
if !matched {
bail!(
"Required pattern '{}' did not match any successful package",
pattern_str
);
}
}
Ok(())
}
// ========================================================================
// RSYNC AND SSH
// ========================================================================
fn write_rsync_filter(packages: &[&String], path: &Path) -> Result<()> {
let mut file = std::fs::File::create(path)
.with_context(|| format!("Failed to create {}", path.display()))?;
let mut lines: Vec<String> = vec![
"+ All/".to_string(),
"+ All/pkg_summary.bz2".to_string(),
"+ All/pkg_summary.gz".to_string(),
"+ All/pkg_summary.xz".to_string(),
"+ All/pkg_summary.zst".to_string(),
];
for pkgname in packages {
lines.push(format!("+ All/{}.tgz", pkgname));
}
lines.sort();
lines.push("- *".to_string());
for line in &lines {
writeln!(file, "{}", line)?;
}
file.flush()?;
debug!(
path = %path.display(),
entries = lines.len(),
"Wrote rsync filter"
);
Ok(())
}
fn run_rsync(
publish: &Publish,
packages: &PublishPackages,
filter_path: &Path,
packages_dir: &Path,
dry_run: bool,
) -> Result<()> {
let rsync_args = &packages.rsync_args;
let remote = format_remote(&packages.host, packages.user.as_deref());
let dest = packages.tmppath.as_ref().unwrap_or(&packages.path);
let mut cmd = Command::new(&publish.rsync);
cmd.arg("--exclude-from").arg(filter_path);
for arg in rsync_args.split_whitespace() {
cmd.arg(arg);
}
cmd.arg("--partial-dir=.rsync-partial");
if packages.tmppath.is_some() {
cmd.arg(format!("--link-dest={}", packages.path));
}
if dry_run {
cmd.arg("--dry-run");
}
cmd.arg(".");
cmd.arg(format!("{}:{}", remote, dest));
cmd.current_dir(packages_dir);
info!(
remote = %remote,
dest = %dest,
path = %packages.path,
mode = if packages.tmppath.is_some() { "staged" } else { "direct" },
"Running rsync"
);
debug!(cmd = ?cmd, "rsync command");
let status = cmd.status().context("Failed to execute rsync")?;
if !status.success() {
bail!(
"rsync failed with exit code {}",
status.code().unwrap_or(-1)
);
}
Ok(())
}
/**
* Build a shell script to pipe over ssh. Each env var becomes a
* `name=value` assignment on its own line (shell-quoted by shlex if
* the value needs it), followed by the user's script body.
*/
fn build_remote_script(sv: &ScriptValue) -> Result<String> {
let mut out = String::new();
for (k, v) in &sv.env {
let quoted = shlex::try_quote(v)
.with_context(|| format!("env value for '{}' cannot be shell-quoted", k))?;
out.push_str(k);
out.push('=');
out.push_str("ed);
out.push('\n');
}
out.push_str(&sv.run);
Ok(out)
}
/**
* Run a script on a remote host via ssh. The script body is sent on
* stdin and executed by `sh -eu`, so the script never appears as a
* shell-quoted argument. `set -eu` is mandatory: any failed command
* or unset variable reference aborts immediately rather than silently
* proceeding.
*/
fn run_remote_script(remote: &str, script: &str) -> Result<()> {
let mut child = Command::new("ssh")
.arg(remote)
.arg("sh")
.arg("-eu")
.stdin(Stdio::piped())
.spawn()
.context("Failed to spawn ssh")?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| anyhow::anyhow!("Failed to open ssh stdin"))?;
stdin
.write_all(script.as_bytes())
.context("Failed to write script to ssh stdin")?;
}
let status = child.wait().context("Failed to wait for ssh")?;
if !status.success() {
bail!(
"swapcmd failed with exit code {}",
status.code().unwrap_or(-1)
);
}
Ok(())
}
fn format_remote(host: &str, user: Option<&str>) -> String {
match user {
Some(user) => format!("{}@{}", user, host),
None => host.to_string(),
}
}
// ========================================================================
// HTML REPORT GENERATION
// ========================================================================
const BUILD_PHASES: &[(&str, &str)] = &[
("pre-clean", "pre-clean.log"),
("depends", "depends.log"),
("checksum", "checksum.log"),
("configure", "configure.log"),
("build", "build.log"),
("install", "install.log"),
("package", "package.log"),
("deinstall", "deinstall.log"),
("clean", "clean.log"),
];
struct FailedPackageInfo<'a> {
result: &'a BuildResult,
breaks_count: usize,
failed_log: Option<String>,
}
fn escape_html(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
struct ReportMeta<'a> {
build_id: &'a str,
pkgsrc_env: &'a bob::config::PkgsrcEnv,
vcs_info: &'a bob::vcs::VcsInfo,
}
fn write_text_report(
db: &Database,
logdir: &Path,
meta: &ReportMeta,
report_url: Option<&str>,
diff: Option<&bob::db::BuildDiff>,
) -> Result<()> {
let path = logdir.join("report.txt");
let mut file = std::fs::File::create(&path)
.with_context(|| format!("Failed to create {}", path.display()))?;
let m = &meta.pkgsrc_env.metadata;
let platform = meta
.pkgsrc_env
.platform()
.unwrap_or_else(|| "unknown".to_string());
let mut results = db.get_all_build_results()?;
let duration = db.get_build_duration()?;
let sched = bob::Scheduler::new(db)?;
let breaks_counts: HashMap<String, usize> = sched
.iter()
.map(|sp| (sp.pkg.to_string(), sp.dep_count))
.collect();
for (pkgname, pkgpath, state) in db.get_prefailskip_packages()? {
results.push(BuildResult {
pkgname: pkgsrc::PkgName::new(&pkgname),
pkgpath: pkgpath.and_then(|p| pkgsrc::PkgPath::new(&p).ok()),
state,
log_dir: None,
build_stats: PkgBuildStats::default(),
});
}
let mut scanfail: Vec<(pkgsrc::PkgPath, String)> = db
.get_scan_failures()?
.into_iter()
.filter_map(|(p, e)| pkgsrc::PkgPath::new(&p).ok().map(|pp| (pp, e)))
.collect();
for r in &results {
if let PackageState::Unresolved(reason) = &r.state {
if let Some(pp) = &r.pkgpath {
scanfail.push((pp.clone(), reason.clone()));
}
}
}
let summary = BuildSummary {
duration,
results,
scanfail,
};
let c = summary.counts();
let dur_secs = duration.as_secs();
let hours = dur_secs / 3600;
let minutes = (dur_secs % 3600) / 60;
let seconds = dur_secs % 60;
let duration_str = if hours > 0 {
format!("{}h {}m {}s", hours, minutes, seconds)
} else if minutes > 0 {
format!("{}m {}s", minutes, seconds)
} else {
format!("{}s", seconds)
};
if let Some(url) = report_url {
writeln!(file, "URL: {}/report.html", url)?;
writeln!(file)?;
}
let mut right: Vec<(&str, String)> = vec![("Platform", platform)];
if let Some(cc) = m.get("CC_VERSION") {
right.push(("Compiler", cc.clone()));
}
if let Some(url) = meta
.vcs_info
.web_url()
.or_else(|| meta.vcs_info.remote_url.clone())
{
right.push(("Repository", url));
}
if let Some(branch) = &meta.vcs_info.remote_branch {
match &meta.vcs_info.revision {
Some(rev) => right.push(("Branch", format!("{} (revision: {})", branch, rev))),
None => right.push(("Branch", branch.clone())),
}
}
right.push(("Duration", duration_str));
let left = [
("Total", c.states.total()),
("Successful", c.states.successful()),
("Failed", c.states.failed()),
("UpToDate", c.states.up_to_date()),
("Masked", c.states.masked()),
];
/* Width = longest "Label:" + 1 trailing space. */
let left_label_w = left.iter().map(|(k, _)| k.len()).fold(0, usize::max) + 2;
let right_label_w = right.iter().map(|(k, _)| k.len()).fold(0, usize::max) + 2;
for (i, (lk, lv)) in left.iter().enumerate() {
let right_part = if i < right.len() {
format!(
" {:<w$}{}",
format!("{}:", right[i].0),
right[i].1,
w = right_label_w
)
} else {
String::new()
};
writeln!(
file,
"{:<w$}{:>5}{}",
format!("{}:", lk),
lv,
right_part,
w = left_label_w
)?;
}
let mut maintainers: HashMap<String, String> = HashMap::new();
for (pkgname, idx, _, _) in db.get_report_data()? {
if let Some(m) = idx.maintainer {
maintainers.insert(pkgname, m);
}
}
if let Some(d) = diff {
if !d.new_failures.is_empty() {
let mut sorted: Vec<_> = d.new_failures.iter().collect();
let get_breaks = |e: &bob::db::DiffEntry| -> usize {
e.build2_pkgname
.as_deref()
.and_then(|n| breaks_counts.get(n))
.copied()
.unwrap_or(0)
};
let was_previously_ok = |e: &bob::db::DiffEntry| -> bool {
matches!(
e.build1_outcome,
None | Some(PackageStateKind::Success) | Some(PackageStateKind::UpToDate)
)
};
sorted.sort_by(|a, b| {
was_previously_ok(b)
.cmp(&was_previously_ok(a))
.then_with(|| get_breaks(b).cmp(&get_breaks(a)))
});
writeln!(file)?;
writeln!(
file,
"{:<44} {:>6} Previously",
format!("New Failures Since {}", d.build1_id),
"Breaks"
)?;
writeln!(file, "{}", "-".repeat(76))?;
for e in &sorted {
let pkgname = e.build2_pkgname.as_deref().unwrap_or("-");
let breaks = breaks_counts.get(pkgname).copied().unwrap_or(0);
let previously: &str = e.build1_outcome.map_or("absent", |o| o.into());
let breaks_str = if breaks > 0 {
breaks.to_string()
} else {
String::new()
};
writeln!(file, "{:<44} {:>6} {}", pkgname, breaks_str, previously)?;
}
}
}
if c.scanfail > 0 {
let max_path = summary
.scanfail
.iter()
.map(|(p, _)| p.as_path().display().to_string().len())
.max()
.unwrap_or(0);
writeln!(file)?;
writeln!(file, "Scan Failures")?;
writeln!(file, "{}", "-".repeat(76))?;
for (pkgpath, error_msg) in &summary.scanfail {
writeln!(
file,
"{:<width$} {}",
pkgpath.as_path().display(),
error_msg,
width = max_path
)?;
}
}
let mut failed: Vec<_> = summary
.failed()
.into_iter()
.filter(|r| matches!(r.state, PackageState::Failed(_)))
.map(|r| {
let breaks = breaks_counts.get(r.pkgname.pkgname()).copied().unwrap_or(0);
(r, breaks)
})
.collect();
failed.sort_by(|a, b| {
b.1.cmp(&a.1)
.then_with(|| a.0.pkgname.pkgname().cmp(b.0.pkgname.pkgname()))
});
if !failed.is_empty() {
writeln!(file)?;
writeln!(file, "{:<44} {:>6} Maintainer", "Build Failures", "Breaks")?;
writeln!(file, "{}", "-".repeat(76))?;
for (result, breaks) in &failed {
let maintainer = maintainers
.get(result.pkgname.pkgname())
.map(|s| s.as_str())
.unwrap_or_default();
let breaks_str = if *breaks > 0 {
breaks.to_string()
} else {
String::new()
};
writeln!(
file,
"{:<44} {:>6} {}",
result.pkgname.pkgname(),
breaks_str,
maintainer
)?;
}
}
debug!(path = %path.display(), "Wrote text report");
Ok(())
}
fn write_machine_report(db: &Database, logdir: &Path) -> Result<()> {
let path = logdir.join("report.zst");
let file = std::fs::File::create(&path)
.with_context(|| format!("Failed to create {}", path.display()))?;
let mut encoder = zstd::Encoder::new(file, 19)?;
let sched = bob::Scheduler::new(db)?;
let dep_counts: HashMap<String, usize> = sched
.iter()
.map(|sp| (sp.pkg.to_string(), sp.dep_count))
.collect();
for (pkgname, idx, outcome_id, detail) in db.get_report_data()? {
write!(encoder, "{}", idx.report())?;
// TODO: packages not in the scheduler (e.g. skipped at scan time)
// won't have a dep_count entry. These default to 1 (just themselves).
let pkg_depth = dep_counts.get(&pkgname).copied().unwrap_or(0) + 1;
writeln!(encoder, "PKG_DEPTH={}", pkg_depth)?;
let status = match outcome_id {
Some(id) => match PackageState::from_db(id, detail) {
Some(state) => state.pbulk_status(),
None => "open",
},
None => "open",
};
writeln!(encoder, "BUILD_STATUS={}", status)?;
}
encoder.finish()?;
debug!(path = %path.display(), "Wrote machine-readable report");
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn write_variables_json(
pkgsrc_env: &bob::config::PkgsrcEnv,
vcs_info: &bob::vcs::VcsInfo,
states: &PackageCounts,
build_id: &str,
report_url: Option<&str>,
duration: Duration,
logdir: &Path,
diff: Option<&bob::db::BuildDiff>,
) -> Result<()> {
let mut pkgsrc = serde_json::Map::new();
pkgsrc.insert(
"PREFIX".to_string(),
pkgsrc_env.prefix.display().to_string().into(),
);
let mut sorted: Vec<_> = pkgsrc_env.metadata.iter().collect();
sorted.sort_by_key(|(k, _)| k.as_str());
for (key, value) in sorted {
pkgsrc.insert(key.clone(), value.clone().into());
}
let mut counts: serde_json::Map<_, _> = PackageStateKind::iter()
.map(|kind| (kind.as_ref().to_string(), states[kind].into()))
.collect();
counts.insert("total".to_string(), states.total().into());
let mut report = serde_json::Map::new();
report.insert("date".to_string(), build_id.into());
report.insert("duration".to_string(), duration.as_secs().into());
if let Some(base) = report_url {
report.insert("url".to_string(), format!("{}/report.html", base).into());
report.insert("raw".to_string(), format!("{}/report.zst", base).into());
}
let mut root = serde_json::Map::new();
root.insert("pkgsrc".to_string(), pkgsrc.into());
root.insert("counts".to_string(), counts.into());
root.insert("report".to_string(), report.into());
if vcs_info.is_detected() {
let vcs = serde_json::to_value(vcs_info)?;
root.insert("vcs".to_string(), vcs);
}
if let Some(d) = diff {
root.insert(
"diff".to_string(),
serde_json::json!({
"compared_build_id": d.build1_id,
"new_failures": d.new_failures.len(),
"fixes": d.fixes.len(),
"version_changes": d.version_changes.len(),
"other_changes": d.other_changes.len(),
}),
);
}
let path = logdir.join("variables.json");
let file = std::fs::File::create(&path)
.with_context(|| format!("Failed to create {}", path.display()))?;
serde_json::to_writer_pretty(file, &root)?;
debug!(path = %path.display(), "Wrote variables.json");
Ok(())
}
const GITHUB_SVG: &str = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='20' height='20'%3E%3Cpath fill='%23caa080' d='M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z'/%3E%3C/svg%3E";
const REPORT_SVG: &str = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='20' height='20'%3E%3Cpath fill='%23caa080' d='M2 14V2h12v12H2zm2-1h2V7H4v6zm3 0h2V4H7v9zm3 0h2V9h-2v4z'/%3E%3C/svg%3E";
const VARS_SVG: &str = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='20' height='20'%3E%3Cpath fill='%23caa080' d='M4 1h8a1 1 0 011 1v12a1 1 0 01-1 1H4a1 1 0 01-1-1V2a1 1 0 011-1zm1 3v1h6V4H5zm0 2.5v1h6v-1H5zm0 2.5v1h4V9H5z'/%3E%3C/svg%3E";
const REPORT_CSS: &str = "\
body { font-family: \"Trebuchet MS\", Verdana, sans-serif; font-size: 0.875rem; color: #444; margin: 1em 4%; background: #fefefe; line-height: 1.45; }\n\
a { color: #8a4500; text-decoration: none; }\n\
a:hover { color: #f37021; text-decoration: underline; }\n\
a:visited { color: #8a4500; }\n\
table { border-collapse: collapse; margin-bottom: 1em; }\n\
th, td { padding: 0.25em 0.625em; text-align: left; }\n\
th { white-space: nowrap; color: #d35400; background: none; border-bottom: 1px solid #f37021; cursor: pointer; user-select: none; }\n\
th:focus-visible { outline: 2px solid #f37021; outline-offset: -2px; }\n\
th[aria-sort=\"ascending\"]::after { content: \" \\25B2\"; font-size: 0.75em; }\n\
th[aria-sort=\"descending\"]::after { content: \" \\25BC\"; font-size: 0.75em; }\n\
.data { font-size: 0.8125rem; white-space: nowrap; margin: 0 auto; }\n\
.data tbody tr:nth-child(even) td { background: #fdfaf7; }\n\
.r { text-align: right; }\n\
.header { margin-bottom: 1em; padding-bottom: 0.625em; border-bottom: 1px solid #e8ddd4; }\n\
.header-table { width: 100%; white-space: nowrap; border: none; margin: 0; }\n\
.header-table td { border: none; padding: 0 0.5em; vertical-align: middle; }\n\
.header-icons { width: 1%; }\n\
.header-icons a { color: #caa080; margin-left: 0.375em; }\n\
.header-icons a:hover { color: #f37021; }\n\
.hdr-label { font-size: 1.5rem; color: #333; }\n\
.hdr-platform { font-size: 1.5rem; color: #333; text-align: center; }\n\
.hdr-date { font-size: 1.5rem; color: #333; text-align: right; }\n\
.var-columns { margin-bottom: 1em; text-align: center; }\n\
.var-columns table { display: inline-table; vertical-align: top; margin-right: 1.5em; text-align: left; }\n\
.vars th, .vars td, .vars .vk { font-family: Consolas, Monaco, \"Courier New\", monospace; }\n\
.vars th { text-align: left; color: #d35400; font-size: 0.75rem; background: none; border-bottom: 1px solid #f37021; padding: 0.1875em 0.625em 0.3125em; font-family: \"Trebuchet MS\", Verdana, sans-serif; cursor: default; }\n\
.vars .vk { color: #8a4500; font-weight: bold; font-size: 0.75rem; }\n\
.vars td { font-size: 0.75rem; }\n\
.stats td:not(.vk) { text-align: right; }\n\
.phase { margin-right: 0.375em; }\n\
.indirect { color: #aaa; }\n\
.reason { color: #888; }\n\
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }\n\
.col-err { width: 75%; }\n\
.col-pkg span, .col-path span { display: inline-block; max-width: 18em; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }\n\
.col-breaks, .col-dur { text-align: right; }\n\
@media (max-width: 100em) { .col-status { display: none; } }\n\
@media (max-width: 93em) { .col-maint { display: none; } }\n\
@media (max-width: 80em) { .col-dur { display: none; } }\n\
@media (max-width: 75em) { .col-path { display: none; } }\n\
@media (max-width: 58em) { .col-breaks { display: none; } }\n";
fn write_html_report(
db: &Database,
logdir: &Path,
path: &Path,
meta: &ReportMeta,
diff: Option<&bob::db::BuildDiff>,
commits: Option<&HashMap<String, Vec<bob::vcs::CommitInfo>>>,
) -> Result<()> {
let mut results = db.get_all_build_results()?;
let duration = db.get_build_duration()?;
let sched = bob::Scheduler::new(db)?;
let breaks_counts: HashMap<String, usize> = sched
.iter()
.map(|sp| (sp.pkg.to_string(), sp.dep_count))
.collect();
for (pkgname, pkgpath, state) in db.get_prefailskip_packages()? {
results.push(BuildResult {
pkgname: pkgsrc::PkgName::new(&pkgname),
pkgpath: pkgpath.and_then(|p| pkgsrc::PkgPath::new(&p).ok()),
state,
log_dir: None,
build_stats: PkgBuildStats::default(),
});
}
for (pkgname, pkgpath, failed_dep) in db.get_indirect_failures()? {
results.push(BuildResult {
pkgname: pkgsrc::PkgName::new(&pkgname),
pkgpath: pkgpath.and_then(|p| pkgsrc::PkgPath::new(&p).ok()),
state: PackageState::IndirectFailed(failed_dep),
log_dir: None,
build_stats: PkgBuildStats::default(),
});
}
let mut scanfail: Vec<(pkgsrc::PkgPath, String)> = db
.get_scan_failures()?
.into_iter()
.filter_map(|(p, e)| pkgsrc::PkgPath::new(&p).ok().map(|pp| (pp, e)))
.collect();
for r in &results {
if let PackageState::Unresolved(reason) = &r.state {
if let Some(pp) = &r.pkgpath {
scanfail.push((pp.clone(), reason.clone()));
}
}
}
let summary = BuildSummary {
duration,
results,
scanfail,
};
let mut file = std::fs::File::create(path)?;
let m = &meta.pkgsrc_env.metadata;
let mut failed_info: Vec<FailedPackageInfo> = summary
.results
.iter()
.filter(|r| {
matches!(
r.state,
PackageState::Failed(_) | PackageState::IndirectFailed(_)
)
})
.map(|result| {
let breaks_count = breaks_counts
.get(result.pkgname.pkgname())
.copied()
.unwrap_or(0);
let failed_log = result
.build_stats
.stage
.and_then(|stage| {
BUILD_PHASES
.iter()
.find(|(name, _)| *name == stage.into_str())
.map(|(_, log)| (*log).to_string())
})
.or_else(|| {
/*
* No build phase reached -- e.g. sandbox or bootstrap
* setup failed before bmake. Fall back to setup.log
* if the per-package layer wrote one.
*/
let setup_log = logdir.join(result.pkgname.pkgname()).join("setup.log");
setup_log.exists().then(|| "setup.log".to_string())
});
FailedPackageInfo {
result,
breaks_count,
failed_log,
}
})
.collect();
failed_info.sort_by(|a, b| {
let a_indirect = matches!(a.result.state, PackageState::IndirectFailed(_));
let b_indirect = matches!(b.result.state, PackageState::IndirectFailed(_));
a_indirect
.cmp(&b_indirect)
.then_with(|| b.breaks_count.cmp(&a.breaks_count))
.then_with(|| a.result.pkgname.pkgname().cmp(b.result.pkgname.pkgname()))
});
let display_date = match chrono::NaiveDateTime::parse_from_str(meta.build_id, "%Y%m%dT%H%M%SZ")
{
Ok(dt) => dt.format("%Y-%m-%d %H:%M").to_string(),
Err(_) => meta.build_id.to_string(),
};
let os = m
.get("OS_VARIANT")
.or_else(|| m.get("OPSYS"))
.context("neither OS_VARIANT nor OPSYS found in metadata")?;
let ver = m
.get("LOWER_VARIANT_VERSION")
.or_else(|| m.get("OS_VERSION"))
.context("neither LOWER_VARIANT_VERSION nor OS_VERSION found in metadata")?;
let arch = m
.get("MACHINE_ARCH")
.context("MACHINE_ARCH not found in metadata")?;
let platform = format!("{} {}/{}", os, ver, arch);
let title = format!("Build Report - {} - {}", platform, display_date);
writeln!(file, "<!DOCTYPE html>")?;
writeln!(file, "<html lang=\"en\">")?;
writeln!(file, "<head>")?;
writeln!(file, "<meta charset=\"UTF-8\">")?;
writeln!(
file,
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">"
)?;
writeln!(file, "<title>{}</title>", escape_html(&title))?;
writeln!(file, "<style>")?;
write!(file, "{}", REPORT_CSS)?;
writeln!(file, "</style>")?;
write_sort_script(&mut file)?;
writeln!(file, "</head>")?;
writeln!(file, "<body>")?;
write_header(&mut file, &platform, &display_date)?;
writeln!(file, "<div class=\"var-columns\">")?;
write_statistics_table(&mut file, &summary)?;
write_platform_table(&mut file, m)?;
write_paths_table(&mut file, m, &meta.pkgsrc_env.prefix)?;
write_misc_table(&mut file, meta.vcs_info, summary.duration, db, diff)?;
writeln!(file, "</div>")?;
let pkgpath_base = meta.vcs_info.web_url().and_then(|base| {
meta.vcs_info
.revision_full
.as_ref()
.map(|rev| format!("{}/tree/{}", base, rev))
});
if let Some(d) = diff {
write_diff_section(
&mut file,
d,
&failed_info,
pkgpath_base.as_deref(),
commits,
meta.vcs_info,
)?;
}
if !summary.scanfail.is_empty() {
write_scanfail_table(&mut file, &summary.scanfail, pkgpath_base.as_deref())?;
}
let mut maintainers: HashMap<String, String> = HashMap::new();
for (pkgname, idx, _, _) in db.get_report_data()? {
if let Some(m) = idx.maintainer {
maintainers.insert(pkgname, m);
}
}
write_failed_table(
&mut file,
"All Failures",
&failed_info,
&maintainers,
logdir,
pkgpath_base.as_deref(),
)?;
writeln!(file, "</body>")?;
writeln!(file, "</html>")?;
Ok(())
}
fn write_sort_script(file: &mut std::fs::File) -> Result<()> {
writeln!(file, "<script>")?;
writeln!(file, "function cmpVal(cells, col, type) {{")?;
writeln!(
file,
" var v = cells[col].getAttribute('data-sort') || cells[col].textContent;"
)?;
writeln!(file, " return type === 'num' ? (parseFloat(v) || 0) : v;")?;
writeln!(file, "}}")?;
writeln!(file, "function sortTable(id, col, type, col2, type2) {{")?;
writeln!(file, " var t = document.getElementById(id);")?;
writeln!(file, " var b = t.tBodies[0];")?;
writeln!(file, " var rows = Array.prototype.slice.call(b.rows);")?;
writeln!(file, " var same = t.getAttribute('data-sort-col') == col;")?;
writeln!(
file,
" var desc = same ? t.getAttribute('data-sort-desc') != '1' : type === 'num';"
)?;
writeln!(file, " rows.sort(function(a, b) {{")?;
writeln!(file, " var av = cmpVal(a.cells, col, type);")?;
writeln!(file, " var bv = cmpVal(b.cells, col, type);")?;
writeln!(
file,
" var r = type === 'num' ? av - bv : av.localeCompare(bv);"
)?;
writeln!(file, " if (r === 0 && col2 !== undefined) {{")?;
writeln!(file, " var av2 = cmpVal(a.cells, col2, type2);")?;
writeln!(file, " var bv2 = cmpVal(b.cells, col2, type2);")?;
writeln!(
file,
" r = type2 === 'num' ? bv2 - av2 : av2.localeCompare(bv2);"
)?;
writeln!(file, " }}")?;
writeln!(file, " return desc ? -r : r;")?;
writeln!(file, " }});")?;
writeln!(
file,
" for (var i = 0; i < rows.length; i++) b.appendChild(rows[i]);"
)?;
writeln!(file, " t.setAttribute('data-sort-col', col);")?;
writeln!(
file,
" t.setAttribute('data-sort-desc', desc ? '1' : '0');"
)?;
writeln!(file, " var h = t.tHead.rows[0].cells;")?;
writeln!(
file,
" for (var i = 0; i < h.length; i++) h[i].setAttribute('aria-sort', i == col ? (desc ? 'descending' : 'ascending') : 'none');"
)?;
writeln!(file, "}}")?;
writeln!(file, "</script>")?;
Ok(())
}
fn write_header(file: &mut std::fs::File, platform: &str, date: &str) -> Result<()> {
writeln!(file, "<div class=\"header\">")?;
writeln!(file, "<table class=\"header-table\"><tr>")?;
writeln!(
file,
"<td class=\"header-icons\"><a href=\"https://pkgsrc.org/\"><img src=\"https://www.pkgsrc.org/img/pkgsrc-square.png\" alt=\"pkgsrc\" height=\"36\"></a></td>"
)?;
writeln!(file, "<td class=\"hdr-label\">Build Report</td>")?;
writeln!(
file,
"<td class=\"hdr-platform\">{}</td>",
escape_html(platform)
)?;
writeln!(file, "<td class=\"hdr-date\">{}</td>", escape_html(date))?;
write!(
file,
"<td class=\"header-icons\" style=\"text-align:right\">"
)?;
write!(
file,
"<a href=\"report.zst\" title=\"Machine-readable report\"><img src=\"{}\" alt=\"Report\"></a>",
REPORT_SVG
)?;
write!(
file,
"<a href=\"variables.json\" title=\"Build variables\"><img src=\"{}\" alt=\"Variables\"></a>",
VARS_SVG
)?;
write!(
file,
"<a href=\"https://github.com/jperkin/bob\" title=\"bob on GitHub\"><img src=\"{}\" alt=\"GitHub\"></a>",
GITHUB_SVG
)?;
writeln!(file, "</td>")?;
writeln!(file, "</tr></table>")?;
writeln!(file, "</div>")?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn write_sortable_th(
file: &mut std::fs::File,
table_id: &str,
col: usize,
sort_type: &str,
label: &str,
class: &str,
extra_sort: &str,
aria_sort: &str,
) -> Result<()> {
let cls = if class.is_empty() {
String::new()
} else {
format!(" class=\"{}\"", class)
};
writeln!(
file,
"<th{cls} scope=\"col\" role=\"columnheader\" tabindex=\"0\" aria-sort=\"{aria_sort}\" \
onclick=\"sortTable('{table_id}', {col}, '{sort_type}'{extra_sort})\" \
onkeydown=\"if(event.key==='Enter')this.click()\">{label}</th>",
)?;
Ok(())
}
fn write_var_row(file: &mut std::fs::File, key: &str, value: &str) -> Result<()> {
writeln!(
file,
"<tr><td class=\"vk\">{}</td><td>{}</td></tr>",
escape_html(key),
escape_html(value)
)?;
Ok(())
}
fn write_statistics_table(file: &mut std::fs::File, summary: &BuildSummary) -> Result<()> {
let c = summary.counts();
writeln!(file, "<table class=\"vars stats\">")?;
writeln!(file, "<tr><th colspan=\"2\">Statistics</th></tr>")?;
write_var_row(file, "Total", &c.states.total().to_string())?;
write_var_row(file, "Successful", &c.states.successful().to_string())?;
write_var_row(file, "Failed", &c.states.failed().to_string())?;
write_var_row(file, "UpToDate", &c.states.up_to_date().to_string())?;
write_var_row(file, "Masked", &c.states.masked().to_string())?;
writeln!(file, "</table>")?;
Ok(())
}
fn write_platform_table(file: &mut std::fs::File, m: &HashMap<String, String>) -> Result<()> {
writeln!(file, "<table class=\"vars\">")?;
writeln!(file, "<tr><th colspan=\"2\">Platform</th></tr>")?;
let has_variant = m.contains_key("OS_VARIANT");
let has_variant_version = m.contains_key("LOWER_VARIANT_VERSION");
if has_variant {
write_var_row(file, "OS_VARIANT", &m["OS_VARIANT"])?;
} else if let Some(val) = m.get("OPSYS") {
write_var_row(file, "OPSYS", val)?;
}
if has_variant_version {
write_var_row(file, "LOWER_VARIANT_VERSION", &m["LOWER_VARIANT_VERSION"])?;
} else if let Some(val) = m.get("OS_VERSION") {
write_var_row(file, "OS_VERSION", val)?;
}
for key in &["MACHINE_ARCH", "CC_VERSION"] {
if let Some(val) = m.get(*key) {
write_var_row(file, key, val)?;
}
}
writeln!(file, "</table>")?;
Ok(())
}
fn write_paths_table(
file: &mut std::fs::File,
m: &HashMap<String, String>,
prefix: &Path,
) -> Result<()> {
writeln!(file, "<table class=\"vars\">")?;
writeln!(file, "<tr><th colspan=\"2\">Paths</th></tr>")?;
write_var_row(file, "PREFIX", &prefix.display().to_string())?;
for key in &["SYSCONFBASE", "VARBASE", "PKGMANDIR", "PKGINFODIR"] {
if let Some(val) = m.get(*key) {
write_var_row(file, key, val)?;
}
}
writeln!(file, "</table>")?;
Ok(())
}
fn write_misc_table(
file: &mut std::fs::File,
vcs: &bob::vcs::VcsInfo,
duration: Duration,
db: &Database,
diff: Option<&bob::db::BuildDiff>,
) -> Result<()> {
writeln!(file, "<table class=\"vars\">")?;
writeln!(file, "<tr><th colspan=\"2\">Miscellaneous</th></tr>")?;
let web_url = if vcs.is_detected() {
vcs.web_url()
} else {
None
};
let web_base = web_url.as_deref();
if let Some(base) = web_base {
writeln!(
file,
"<tr><td class=\"vk\">Repository</td><td><a href=\"{}\">{}</a></td></tr>",
escape_html(base),
escape_html(base)
)?;
} else if let Some(url) = &vcs.remote_url {
write_var_row(file, "Repository", url)?;
}
if let Some(branch) = &vcs.remote_branch {
if let Some(base) = web_base {
writeln!(
file,
"<tr><td class=\"vk\">Branch</td><td><a href=\"{}/tree/{}\">{}</a></td></tr>",
escape_html(base),
escape_html(branch),
escape_html(branch)
)?;
} else {
write_var_row(file, "Branch", branch)?;
}
}
if let Some(rev) = &vcs.revision {
if let (Some(base), Some(full)) = (web_base, &vcs.revision_full) {
writeln!(
file,
"<tr><td class=\"vk\">Revision</td><td><a href=\"{}/tree/{}\">{}</a></td></tr>",
escape_html(base),
escape_html(full),
escape_html(rev)
)?;
} else {
write_var_row(file, "Revision", rev)?;
}
}
if let Some(d) = diff {
if let Some((url, old_rev, new_rev)) = build_compare_url(db, vcs, &d.build1_id) {
writeln!(
file,
"<tr><td class=\"vk\">Compare</td>\
<td><a href=\"{}\">{}..{}</a></td></tr>",
escape_html(&url),
escape_html(&old_rev),
escape_html(&new_rev),
)?;
}
}
let dur_secs = duration.as_secs();
let duration_str = if dur_secs >= 3600 {
format!(
"{}h {}m {}s",
dur_secs / 3600,
(dur_secs % 3600) / 60,
dur_secs % 60
)
} else if dur_secs >= 60 {
format!("{}m {}s", dur_secs / 60, dur_secs % 60)
} else {
format!("{}s", dur_secs)
};
write_var_row(file, "Duration", &duration_str)?;
writeln!(file, "</table>")?;
Ok(())
}
fn generate_phase_links(pkg_name: &str, log_dir: &Path) -> String {
if !log_dir.exists() {
return String::new();
}
let mut links = Vec::new();
if log_dir.join("setup.log").exists() {
links.push(format!(
"<a href=\"{pkg_name}/setup.log\" class=\"phase\">setup</a>"
));
}
for (phase_name, log_file) in BUILD_PHASES {
let log_path = log_dir.join(log_file);
if log_path.exists() {
links.push(format!(
"<a href=\"{pkg_name}/{log_file}\" class=\"phase\">{phase_name}</a>"
));
}
}
links.join(" ")
}
fn write_failed_table(
file: &mut std::fs::File,
title: &str,
failed_info: &[FailedPackageInfo],
maintainers: &HashMap<String, String>,
logdir: &Path,
pkgpath_base: Option<&str>,
) -> Result<()> {
let t = "failed-table";
writeln!(
file,
"<table id=\"{t}\" class=\"data\" data-sort-col=\"6\" data-sort-desc=\"0\">"
)?;
writeln!(file, "<caption class=\"sr-only\">Failed packages</caption>")?;
writeln!(file, "<thead><tr>")?;
write_sortable_th(file, t, 0, "str", title, "col-pkg", "", "none")?;
write_sortable_th(file, t, 1, "str", "PkgPath", "col-path", "", "none")?;
write_sortable_th(file, t, 2, "num", "Breaks", "col-breaks", "", "none")?;
write_sortable_th(file, t, 3, "num", "Duration", "col-dur", "", "none")?;
write_sortable_th(file, t, 4, "str", "Maintainer", "col-maint", "", "none")?;
write_sortable_th(file, t, 5, "str", "Status", "col-status", "", "none")?;
write_sortable_th(
file,
t,
6,
"num",
"Build Logs",
"col-logs",
", 2, 'num'",
"ascending",
)?;
writeln!(file, "</tr></thead>")?;
writeln!(file, "<tbody>")?;
for info in failed_info {
let pkg_name = info.result.pkgname.pkgname();
let pkgpath_str = info
.result
.pkgpath
.as_ref()
.map(|p| p.as_path().display().to_string())
.unwrap_or_default();
let escaped_path = escape_html(&pkgpath_str);
let pkgpath = match pkgpath_base {
Some(base) if !pkgpath_str.is_empty() => format!(
"<span title=\"{0}\"><a href=\"{1}/{0}\">{0}</a></span>",
escaped_path,
escape_html(base),
),
_ => format!("<span title=\"{0}\">{0}</span>", escaped_path),
};
let maintainer = maintainers
.get(pkg_name)
.map(|s| s.as_str())
.unwrap_or_default();
let breaks_display = if info.breaks_count > 0 {
info.breaks_count.to_string()
} else {
String::new()
};
if info.result.state.is_skip() {
let reason = info.result.state.to_string();
writeln!(
file,
"<tr><td class=\"col-pkg indirect\"><span title=\"{0}\">{0}</span></td><td class=\"col-path indirect\">{1}</td><td class=\"col-breaks r indirect\" data-sort=\"{2}\">{3}</td><td class=\"col-dur r indirect\" data-sort=\"0\"></td><td class=\"col-maint indirect\">{4}</td><td class=\"col-status indirect\">{5}</td><td class=\"col-logs reason\" data-sort=\"1\">{6}</td></tr>",
escape_html(pkg_name),
pkgpath,
info.breaks_count,
breaks_display,
escape_html(maintainer),
info.result.state.status(),
escape_html(&reason)
)?;
continue;
}
let dur_secs = info.result.build_stats.duration.as_secs();
let duration = if dur_secs >= 60 {
format!("{}m {}s", dur_secs / 60, dur_secs % 60)
} else {
format!("{}s", dur_secs)
};
let escaped = escape_html(pkg_name);
let pkg_link = match &info.failed_log {
Some(log) => format!(
"<span title=\"{0}\"><a href=\"{0}/{1}\">{0}</a></span>",
escaped, log
),
None => format!("<span title=\"{0}\">{0}</span>", escaped),
};
let log_dir = logdir.join(pkg_name);
let phase_links = generate_phase_links(pkg_name, &log_dir);
writeln!(
file,
"<tr><td class=\"col-pkg\">{}</td><td class=\"col-path\">{}</td><td class=\"col-breaks r\" data-sort=\"{}\">{}</td><td class=\"col-dur r\" data-sort=\"{}\">{}</td><td class=\"col-maint\">{}</td><td class=\"col-status\">{}</td><td class=\"col-logs\" data-sort=\"0\">{}</td></tr>",
pkg_link,
pkgpath,
info.breaks_count,
breaks_display,
dur_secs,
duration,
escape_html(maintainer),
info.result.state.status(),
phase_links
)?;
}
writeln!(file, "</tbody>")?;
writeln!(file, "</table>")?;
Ok(())
}
fn write_diff_section(
file: &mut std::fs::File,
diff: &bob::db::BuildDiff,
failed_info: &[FailedPackageInfo],
pkgpath_base: Option<&str>,
commits: Option<&HashMap<String, Vec<bob::vcs::CommitInfo>>>,
vcs_info: &bob::vcs::VcsInfo,
) -> Result<()> {
if diff.new_failures.is_empty() {
return Ok(());
}
let show_commits = commits.is_some_and(|c| !c.is_empty());
let info_by_name: HashMap<&str, &FailedPackageInfo> = failed_info
.iter()
.map(|i| (i.result.pkgname.pkgname(), i))
.collect();
let was_ok = |e: &bob::db::DiffEntry| -> bool {
matches!(
e.build1_outcome,
None | Some(PackageStateKind::Success) | Some(PackageStateKind::UpToDate)
)
};
let mut sorted: Vec<_> = diff.new_failures.iter().collect();
sorted.sort_by(|a, b| {
let ai = a
.build2_pkgname
.as_deref()
.and_then(|n| info_by_name.get(n));
let bi = b
.build2_pkgname
.as_deref()
.and_then(|n| info_by_name.get(n));
let ab = ai.map(|i| i.breaks_count).unwrap_or(0);
let bb = bi.map(|i| i.breaks_count).unwrap_or(0);
was_ok(b).cmp(&was_ok(a)).then_with(|| bb.cmp(&ab))
});
let t = "diff-new-failures";
let title = format!("New Failures Since {}", escape_html(&diff.build1_id));
writeln!(
file,
"<table id=\"{t}\" class=\"data\" data-sort-col=\"2\" data-sort-desc=\"1\">"
)?;
writeln!(
file,
"<caption class=\"sr-only\">New failures since previous build</caption>"
)?;
writeln!(file, "<thead><tr>")?;
write_sortable_th(file, t, 0, "str", &title, "col-pkg", "", "none")?;
write_sortable_th(file, t, 1, "str", "PkgPath", "col-path", "", "none")?;
write_sortable_th(file, t, 2, "num", "Breaks", "col-breaks", "", "none")?;
write_sortable_th(file, t, 3, "num", "Duration", "col-dur", "", "none")?;
write_sortable_th(file, t, 4, "str", "Previously", "col-prev", "", "none")?;
if show_commits {
write_sortable_th(file, t, 5, "str", "Commits", "col-commits", "", "none")?;
}
writeln!(file, "</tr></thead>")?;
writeln!(file, "<tbody>")?;
for e in &sorted {
let pkgname = e
.build2_pkgname
.as_deref()
.or(e.build1_pkgname.as_deref())
.unwrap_or("-");
let info = info_by_name.get(pkgname);
let escaped_path = escape_html(&e.pkgpath);
let pkgpath_cell = match pkgpath_base {
Some(base) => format!(
"<span title=\"{0}\"><a href=\"{1}/{0}\">{0}</a></span>",
escaped_path,
escape_html(base),
),
None => format!("<span title=\"{0}\">{0}</span>", escaped_path),
};
let breaks = info.map(|i| i.breaks_count).unwrap_or(0);
let breaks_display = if breaks > 0 {
breaks.to_string()
} else {
String::new()
};
let (dur_secs, duration) = match info {
Some(i) => {
let s = i.result.build_stats.duration.as_secs();
let d = if s >= 60 {
format!("{}m {}s", s / 60, s % 60)
} else {
format!("{}s", s)
};
(s, d)
}
None => (0, String::new()),
};
let previously: &str = e.build1_outcome.map_or("absent", |o| o.into());
let escaped = escape_html(pkgname);
let pkg_link = match info.and_then(|i| i.failed_log.as_deref()) {
Some(log) => format!(
"<span title=\"{0}\"><a href=\"{0}/{1}\">{0}</a></span>",
escaped, log
),
None => format!("<span title=\"{0}\">{0}</span>", escaped),
};
let commits_cell = if show_commits {
let entries = commits.and_then(|m| m.get(&e.pkgpath));
let mut parts: Vec<String> = Vec::new();
if let Some(entries) = entries {
for c in entries {
let label = format!("{}@{}", escape_html(&c.author), escape_html(&c.sha_short));
let part = match vcs_info.commit_url(&c.sha_full) {
Some(url) => format!("<a href=\"{}\">{}</a>", escape_html(&url), label),
None => label,
};
parts.push(part);
}
}
format!("<td class=\"col-commits\">{}</td>", parts.join(" "))
} else {
String::new()
};
writeln!(
file,
"<tr><td class=\"col-pkg\">{}</td>\
<td class=\"col-path\">{}</td>\
<td class=\"col-breaks r\" data-sort=\"{}\">{}</td>\
<td class=\"col-dur r\" data-sort=\"{}\">{}</td>\
<td class=\"col-prev\">{}</td>{}</tr>",
pkg_link,
pkgpath_cell,
breaks,
breaks_display,
dur_secs,
duration,
previously,
commits_cell,
)?;
}
writeln!(file, "</tbody>")?;
writeln!(file, "</table>")?;
Ok(())
}
/**
* Build a compare URL and short revision pair for two builds.
*
* Returns the GitHub-style compare URL (constructed from full SHAs)
* plus the two short revision strings used for display. Returns None
* if any prerequisite is missing.
*/
fn build_compare_url(
db: &Database,
vcs_info: &bob::vcs::VcsInfo,
build1_id: &str,
) -> Option<(String, String, String)> {
let web_url = vcs_info.web_url()?;
let old_rev_full = db.get_build_revision(build1_id).ok()??;
let new_rev_full = vcs_info.revision_full.as_deref()?;
let new_rev_short = vcs_info.revision.as_deref()?;
let old_rev_short = &old_rev_full[..12.min(old_rev_full.len())];
let url = format!("{}/compare/{}..{}", web_url, old_rev_full, new_rev_full);
Some((url, old_rev_short.to_string(), new_rev_short.to_string()))
}
/**
* For each pkgpath in `diff.new_failures`, compute the list of commits
* in the pkgsrc tree that touched that pkgpath since the previous build.
* Returns None if any prerequisite is missing (no git, missing previous
* revision, walk failure). Each early-return path logs at debug level
* so the reason is visible with `RUST_LOG=bob=debug`.
*/
fn compute_diff_commits(
db: &Database,
config: &Config,
vcs_info: &bob::vcs::VcsInfo,
diff: &bob::db::BuildDiff,
) -> Option<HashMap<String, Vec<bob::vcs::CommitInfo>>> {
let old_rev = match db.get_build_revision(&diff.build1_id) {
Ok(Some(r)) => r,
Ok(None) => {
debug!(
build1_id = %diff.build1_id,
"Skipping per-pkgpath commit lookup: no revision stored \
for previous build"
);
return None;
}
Err(e) => {
debug!(
build1_id = %diff.build1_id,
error = format!("{e:#}"),
"Skipping per-pkgpath commit lookup: failed to query \
previous build revision"
);
return None;
}
};
let Some(new_rev) = vcs_info.revision_full.as_deref() else {
debug!(
"Skipping per-pkgpath commit lookup: no current revision \
from VcsInfo (not a git tree?)"
);
return None;
};
let pkgpaths: HashSet<String> = diff
.new_failures
.iter()
.map(|e| e.pkgpath.clone())
.collect();
if pkgpaths.is_empty() {
debug!("Skipping per-pkgpath commit lookup: no new failures in diff");
return None;
}
debug!(
old_rev = %old_rev,
new_rev = %new_rev,
pkgpath_count = pkgpaths.len(),
repo = %config.pkgsrc().display(),
"Computing per-pkgpath commit list"
);
match bob::vcs::commits_for_pkgpaths(config.pkgsrc(), &old_rev, new_rev, &pkgpaths) {
Ok(map) if !map.is_empty() => {
let total: usize = map.values().map(|v| v.len()).sum();
debug!(
pkgpaths_with_commits = map.len(),
total_commits = total,
"Per-pkgpath commit list ready"
);
Some(map)
}
Ok(_) => {
debug!(
"Per-pkgpath commit list is empty: walk succeeded but no \
commits touched any of the failing pkgpaths"
);
None
}
Err(e) => {
debug!(
error = format!("{e:#}"),
"Failed to compute per-pkgpath commit list"
);
None
}
}
}
fn write_scanfail_table(
file: &mut std::fs::File,
scanfail: &[(pkgsrc::PkgPath, String)],
pkgpath_base: Option<&str>,
) -> Result<()> {
let t = "scanfail-table";
writeln!(
file,
"<table id=\"{t}\" class=\"data\" style=\"white-space:normal\">"
)?;
writeln!(file, "<caption class=\"sr-only\">Scan failures</caption>")?;
writeln!(file, "<thead><tr>")?;
write_sortable_th(file, t, 0, "str", "PKGPATH", "", "", "none")?;
write_sortable_th(file, t, 1, "str", "Scan Error", "col-err", "", "none")?;
writeln!(file, "</tr></thead>")?;
writeln!(file, "<tbody>")?;
for (pkgpath, error_msg) in scanfail {
let path_str = pkgpath.as_path().display().to_string();
let path_html = match pkgpath_base {
Some(base) => format!(
"<a href=\"{}/{}\">{}</a>",
escape_html(base),
escape_html(&path_str),
escape_html(&path_str)
),
None => escape_html(&path_str),
};
writeln!(
file,
"<tr><td>{}</td><td>{}</td></tr>",
path_html,
escape_html(error_msg)
)?;
}
writeln!(file, "</tbody>")?;
writeln!(file, "</table>")?;
Ok(())
}