use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use onevcs::MergePolicy;
use crate::plan::{Node, Plan};
use crate::refusal::Refusal;
pub const BINARY_ENV: &str = "ONEPIPELINE_ONEVCS_BIN";
pub const DEFAULT_BINARY: &str = "onevcs";
const COMMIT_MSG_HOOK: &str = "commit-msg";
const PUBLICATION_LINE: &str = "publication:";
pub(crate) fn check(plan: &Plan) -> std::result::Result<(), Refusal> {
let mut resolved: BTreeMap<String, Result<Destination, String>> = BTreeMap::new();
for node in &plan.tasks {
let Some(repo) = node.repo.as_deref() else {
continue;
};
if node.title.is_none() && node.consumes.is_empty() && !node.draft {
continue;
}
let destination = resolved
.entry(repo.to_owned())
.or_insert_with(|| resolve(repo));
let destination = match destination {
Ok(destination) => destination,
Err(why) => {
report_unchecked(&node.id, repo, why);
continue;
}
};
type Rule = fn(&Node, &Destination) -> std::result::Result<Option<Refusal>, String>;
for ask in [
consumes_refusal as Rule,
draft_refusal as Rule,
title_refusal as Rule,
] {
match ask(node, destination) {
Ok(Some(refusal)) => return Err(refusal),
Ok(None) => {}
Err(why) => report_unchecked(&node.id, repo, &why),
}
}
}
Ok(())
}
fn report_unchecked(node: &str, repo: &str, why: &str) {
eprintln!(
"onepipeline: node '{node}': this build could not ask {repo} what it says about this \
node, so the plan loaded without that check having run — {why}"
);
}
#[derive(serde::Deserialize)]
struct Resolved {
identity: String, publication_checkout: PathBuf,
}
struct Destination {
resolved: Resolved,
publication: std::result::Result<MergePolicy, String>,
}
fn binary() -> std::ffi::OsString {
std::env::var_os(BINARY_ENV)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| DEFAULT_BINARY.into())
}
fn ask(verb: &[&str], repo: &str) -> Result<String, String> {
let binary = binary();
let named = || format!("`{} {} {repo}`", binary.to_string_lossy(), verb.join(" "));
let output = Command::new(&binary)
.args(verb)
.arg(repo)
.output()
.map_err(|error| {
format!(
"{} could not be run: {error} (set {BINARY_ENV} to an executable one)",
named()
)
})?;
if !output.status.success() {
return Err(format!(
"{} refused: {}",
named(),
one_line(&String::from_utf8_lossy(&output.stderr))
));
}
String::from_utf8(output.stdout)
.map_err(|error| format!("{} answered bytes that are not UTF-8: {error}", named()))
}
fn resolve(repo: &str) -> Result<Destination, String> {
let resolved: Resolved =
serde_json::from_str(ask(&["resolve"], repo)?.trim()).map_err(|error| {
format!("`onevcs resolve {repo}` did not answer the shape this build reads: {error}")
})?;
if resolved.identity.trim().is_empty() {
return Err(format!("`onevcs resolve {repo}` states a blank identity"));
}
if !resolved.publication_checkout.is_absolute() {
return Err(format!(
"`onevcs resolve {repo}` states a publication checkout that is not an absolute \
path, {}",
resolved.publication_checkout.display()
));
}
Ok(Destination {
resolved,
publication: ask(&["rules", "check"], repo)
.and_then(|reported| publication(repo, &reported)),
})
}
fn publication(repo: &str, reported: &str) -> Result<MergePolicy, String> {
let line = reported
.lines()
.map(str::trim)
.find_map(|line| line.strip_prefix(PUBLICATION_LINE))
.map(str::trim)
.ok_or_else(|| {
format!("`onevcs rules check {repo}` states no `{PUBLICATION_LINE}` line")
})?;
let (stated, whence) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
let whence = whence.trim();
if !(whence.is_empty() || (whence.starts_with('(') && whence.ends_with(')'))) {
return Err(format!(
"`onevcs rules check {repo}` states a `{PUBLICATION_LINE}` line this build does \
not read: {line:?}"
));
}
serde_json::from_value(serde_json::Value::String(stated.to_owned())).map_err(|_| {
format!("`onevcs rules check {repo}` states a publication policy this build does not know, '{stated}'")
})
}
fn consumes_refusal(
node: &Node,
destination: &Destination,
) -> std::result::Result<Option<Refusal>, String> {
if node.consumes.is_empty() {
return Ok(None);
}
let publication = match node.merge_policy {
Some(stated) => stated,
None => destination.publication.clone()?,
};
if opens_a_change_request(publication) {
return Ok(None);
}
let consumed = node
.consumes
.iter()
.map(|(dependency, target)| format!("{dependency}={target}"))
.collect::<Vec<_>>()
.join(", ");
Ok(Some(
Refusal::node(
&node.id,
format!(
"it consumes the release targets {consumed}, and its repository {identity} \
publishes with {publication}, which opens no change \
request at all — so there is nothing for the draft that holds this node to a \
release to be a state of, and `onevcs` refuses the publication outright at \
the last step of the node. Publish it under a change-* policy, on this node \
or in that repository's own rules, or drop `consumes`",
identity = destination.resolved.identity,
publication = spell(publication),
),
)
.field("consumes"),
))
}
fn draft_refusal(
node: &Node,
destination: &Destination,
) -> std::result::Result<Option<Refusal>, String> {
if !node.draft {
return Ok(None);
}
let publication = match node.merge_policy {
Some(stated) => stated,
None => destination.publication.clone()?,
};
if opens_a_change_request(publication) {
return Ok(None);
}
Ok(Some(
Refusal::node(
&node.id,
format!(
"it is declared `draft: true`, and its repository {identity} publishes with \
{publication}, which opens no change request at all — so there is nothing to \
leave as a draft, and `onevcs` refuses the publication outright at the last \
step of the node. Publish it under a change-* policy, on this node or in that \
repository's own rules, or drop `draft`",
identity = destination.resolved.identity,
publication = spell(publication),
),
)
.field("draft"),
))
}
fn opens_a_change_request(policy: MergePolicy) -> bool {
policy != MergePolicy::LocalDirect
}
fn spell<T: serde::Serialize + std::fmt::Debug>(value: T) -> String {
serde_json::to_value(&value)
.ok()
.and_then(|value| value.as_str().map(str::to_owned))
.unwrap_or_else(|| format!("{value:?}"))
}
fn title_refusal(node: &Node, destination: &Destination) -> Result<Option<Refusal>, String> {
let Some(title) = node
.title
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
else {
return Ok(None);
};
let Some(rejection) = ask_the_hook(&destination.resolved.publication_checkout, title)? else {
return Ok(None);
};
Ok(Some(
Refusal::node(
&node.id,
format!(
"the repository {identity} turns this node's title down at its own \
{COMMIT_MSG_HOOK} hook, so the publication this node ends with would be \
refused with the whole dispatch already paid for. The title is {title:?}, and \
the hook ({termination}) said:\n{said}",
identity = destination.resolved.identity,
termination = rejection.termination,
said = rejection.said,
),
)
.field("title"),
))
}
#[derive(Debug, PartialEq, Eq)]
enum Termination {
Exit(i32),
Signal,
}
impl std::fmt::Display for Termination {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Exit(status) => write!(f, "exit {status}"),
Self::Signal => f.write_str("killed by a signal"),
}
}
}
#[derive(Debug)]
struct Rejected {
termination: Termination,
said: String,
}
fn ask_the_hook(checkout: &Path, title: &str) -> Result<Option<Rejected>, String> {
let hooks = git_path(checkout, "hooks")?;
let hook = hooks.join(COMMIT_MSG_HOOK);
if !runnable(&hook)? {
return Ok(None);
}
let message = write_message_file(title)?;
let ran = Command::new(&hook)
.arg(&message)
.current_dir(checkout)
.output()
.map_err(|error| {
format!(
"the {COMMIT_MSG_HOOK} hook at {} could not be run: {error}",
hook.display()
)
});
let _ = std::fs::remove_file(&message);
let ran = ran?;
if ran.status.success() {
return Ok(None);
}
let said = format!(
"{}{}",
String::from_utf8_lossy(&ran.stdout),
String::from_utf8_lossy(&ran.stderr)
);
let said = said.trim();
Ok(Some(Rejected {
termination: ran
.status
.code()
.map_or(Termination::Signal, Termination::Exit),
said: if said.is_empty() {
"<no output>".to_owned()
} else {
said.to_owned()
},
}))
}
fn git_path(checkout: &Path, name: &str) -> Result<PathBuf, String> {
let output = Command::new("git")
.args(["rev-parse", "--git-path", name])
.current_dir(checkout)
.output()
.map_err(|error| {
format!(
"git could not be run in the publication checkout {}: {error}",
checkout.display()
)
})?;
if !output.status.success() {
return Err(format!(
"git does not answer for the publication checkout {}: {}",
checkout.display(),
one_line(&String::from_utf8_lossy(&output.stderr))
));
}
let path = os_path(&output.stdout)?;
Ok(if path.is_absolute() {
path
} else {
checkout.join(path)
})
}
#[cfg(unix)]
fn os_path(printed: &[u8]) -> Result<PathBuf, String> {
use std::os::unix::ffi::OsStrExt;
let named = printed
.strip_suffix(b"\n")
.unwrap_or(printed)
.strip_suffix(b"\r")
.unwrap_or_else(|| printed.strip_suffix(b"\n").unwrap_or(printed));
if named.is_empty() {
return Err("git named no path at all".to_owned());
}
Ok(PathBuf::from(std::ffi::OsStr::from_bytes(named)))
}
#[cfg(not(unix))]
fn os_path(printed: &[u8]) -> Result<PathBuf, String> {
std::str::from_utf8(printed)
.map(|path| PathBuf::from(path.trim()))
.map_err(|error| format!("git printed a path that is not UTF-8: {error}"))
}
#[cfg(unix)]
fn runnable(path: &Path) -> Result<bool, String> {
use std::os::unix::fs::PermissionsExt;
match std::fs::metadata(path) {
Ok(meta) => Ok(meta.is_file() && meta.permissions().mode() & 0o111 != 0),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(format!(
"the {COMMIT_MSG_HOOK} hook at {} cannot be read: {error}",
path.display()
)),
}
}
#[cfg(not(unix))]
fn runnable(path: &Path) -> Result<bool, String> {
match std::fs::metadata(path) {
Ok(meta) => Ok(meta.is_file()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(format!(
"the {COMMIT_MSG_HOOK} hook at {} cannot be read: {error}",
path.display()
)),
}
}
fn write_message_file(title: &str) -> Result<PathBuf, String> {
use std::sync::atomic::{AtomicU64, Ordering};
static ASKED: AtomicU64 = AtomicU64::new(0);
let path = std::env::temp_dir().join(format!(
"onepipeline-{COMMIT_MSG_HOOK}-{}-{}",
crate::sys::pid(),
ASKED.fetch_add(1, Ordering::Relaxed)
));
std::fs::write(&path, format!("{title}\n")).map_err(|error| {
format!(
"the message put to the hook could not be written to {}: {error}",
path.display()
)
})?;
Ok(path)
}
fn one_line(said: &str) -> String {
let said = said.trim();
match said.lines().next() {
Some(first) if !first.is_empty() => first.to_owned(),
_ => "it said nothing".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_publication_policy_is_read_off_the_line_and_nothing_else_is_taken_for_one() {
let reported = "repo: github.com/owner/service\n\
identity: github.com/owner/service\n\
checkout: /tmp/service\n\
rules: /tmp/home/rules.yml\n\
matched: rule 1 {host: github.com, owner: owner, name: service}\n\
publication: local-direct (from rule 1)\n\
approvals: none (from rule 1)\n";
assert_eq!(
publication("service", reported).expect("the policy is read"),
MergePolicy::LocalDirect
);
assert_eq!(
publication("service", "publication: change-auto (from the default)\n")
.expect("the policy is read"),
MergePolicy::ChangeAuto
);
let missing = publication("service", "approvals: none\n").expect_err("no policy is stated");
assert!(
missing.contains("states no `publication:` line"),
"{missing}"
);
let unknown = publication("service", "publication: none (from rule 1)\n")
.expect_err("an unknown policy is not a policy");
assert!(unknown.contains("does not know, 'none'"), "{unknown}");
let reworded = publication("service", "publication: local-direct, from rule 1\n")
.expect_err("a line this build does not read is not read anyway");
assert!(
reworded.contains("line this build does not read"),
"{reworded}"
);
assert_eq!(
publication("service", "publication: change-open\n").expect("a bare policy is read"),
MergePolicy::ChangeOpen,
"the bracketed provenance is optional, as a `--policy` override prints it"
);
}
#[test]
fn a_borrowed_sentence_is_one_line_and_never_an_empty_one() {
assert_eq!(
one_line(" onevcs: no such identity\nand more\n"),
"onevcs: no such identity"
);
assert_eq!(one_line(" \n \n"), "it said nothing");
assert_eq!(one_line(""), "it said nothing");
}
#[test]
fn a_policy_and_an_adoption_are_spelled_by_the_types_that_own_them() {
assert_eq!(spell(MergePolicy::LocalDirect), "local-direct");
assert_eq!(spell(MergePolicy::ChangeAuto), "change-auto");
assert_eq!(spell(onevcs::Adoption::Published), "published");
assert_eq!(spell(onevcs::Adoption::Fast), "fast");
}
fn destination(publication: MergePolicy) -> Destination {
Destination {
resolved: resolved(),
publication: Ok(publication),
}
}
fn resolved() -> Resolved {
Resolved {
identity: "github.com/owner/service".to_owned(),
publication_checkout: PathBuf::from("/tmp/service"),
}
}
fn consuming(adoption: Option<onevcs::Adoption>) -> Node {
let mut node = Node {
id: "consumer".to_owned(),
repo: Some("service".to_owned()),
deps: vec!["engine".to_owned()],
adoption,
..Node::default()
};
node.consumes
.insert("engine".to_owned(), "crate".parse().expect("a target name"));
node
}
#[test]
fn a_consumes_is_refused_wherever_its_publication_opens_no_change_request() {
let refusal = consumes_refusal(&consuming(None), &destination(MergePolicy::LocalDirect))
.expect("the policy is known, so the rule is answerable")
.expect("a fast node consuming on a local-direct repository is refused");
assert_eq!(refusal.node.as_deref(), Some("consumer"));
assert_eq!(refusal.field.as_deref(), Some("consumes"));
for named in [
"node 'consumer'",
"engine=crate",
"github.com/owner/service",
"local-direct",
] {
assert!(
refusal.message.contains(named),
"the refusal does not name {named}: {}",
refusal.message
);
}
let loads = |why: &str, node: &Node, destination: &Destination| {
assert!(
consumes_refusal(node, destination)
.expect("the rule is answerable")
.is_none(),
"{why}"
);
};
for adoption in [onevcs::Adoption::Fast, onevcs::Adoption::Published] {
assert!(
consumes_refusal(
&consuming(Some(adoption)),
&destination(MergePolicy::LocalDirect)
)
.expect("the rule is answerable")
.is_some(),
"a `{}` node consuming on a local-direct repository was not refused",
spell(adoption)
);
}
loads(
"a repository that opens a change request has something to draft",
&consuming(None),
&destination(MergePolicy::ChangeAuto),
);
let mut bare = consuming(None);
bare.consumes.clear();
loads(
"a node consuming nothing holds no pin",
&bare,
&destination(MergePolicy::LocalDirect),
);
let mut narrowed = consuming(None);
narrowed.merge_policy = Some(MergePolicy::ChangeOpen);
loads(
"a node that named a change-* policy opens a change request to draft",
&narrowed,
&destination(MergePolicy::LocalDirect),
);
let mut stated = consuming(None);
stated.merge_policy = Some(MergePolicy::LocalDirect);
assert!(
consumes_refusal(&stated, &destination(MergePolicy::ChangeAuto))
.expect("the rule is answerable")
.is_some(),
"a node that named `local-direct` itself was let through on its repository's answer"
);
}
#[test]
fn a_policy_this_build_could_not_read_is_reported_only_where_the_rule_needed_it() {
let unreadable = Destination {
resolved: resolved(),
publication: Err(
"`onevcs rules check service` states no `publication:` line".to_owned()
),
};
let why = consumes_refusal(&consuming(None), &unreadable)
.expect_err("a rule that needs the policy cannot be answered without it");
assert!(why.contains("states no `publication:` line"), "{why}");
let mut bare = consuming(None);
bare.consumes.clear();
assert!(consumes_refusal(&bare, &unreadable)
.expect("a node consuming nothing needs no policy")
.is_none());
let mut narrowed = consuming(None);
narrowed.merge_policy = Some(MergePolicy::ChangeAuto);
assert!(consumes_refusal(&narrowed, &unreadable)
.expect("a node that named its own change-* policy needs no resolved one")
.is_none());
}
#[test]
fn a_draft_is_refused_wherever_its_publication_opens_no_change_request() {
let drafting = |policy: Option<MergePolicy>| Node {
id: "held".to_owned(),
repo: Some("service".to_owned()),
draft: true,
merge_policy: policy,
..Node::default()
};
let refusal = draft_refusal(&drafting(None), &destination(MergePolicy::LocalDirect))
.expect("the policy is known, so the rule is answerable")
.expect("a draft on a local-direct repository is refused");
assert_eq!(refusal.node.as_deref(), Some("held"));
assert_eq!(refusal.field.as_deref(), Some("draft"));
for named in [
"node 'held'",
"`draft: true`",
"github.com/owner/service",
"local-direct",
] {
assert!(
refusal.message.contains(named),
"the refusal does not name {named}: {}",
refusal.message
);
}
let loads = |why: &str, node: &Node, destination: &Destination| {
assert!(
draft_refusal(node, destination)
.expect("the rule is answerable")
.is_none(),
"{why}"
);
};
loads(
"a repository that opens a change request has one to leave as a draft",
&drafting(None),
&destination(MergePolicy::ChangeAuto),
);
loads(
"a node that named a change-* policy opens a change request to draft",
&drafting(Some(MergePolicy::ChangeOpen)),
&destination(MergePolicy::LocalDirect),
);
let mut undrafted = drafting(None);
undrafted.draft = false;
loads(
"a node asking for no draft has nothing to refuse",
&undrafted,
&destination(MergePolicy::LocalDirect),
);
assert!(
draft_refusal(
&drafting(Some(MergePolicy::LocalDirect)),
&destination(MergePolicy::ChangeAuto)
)
.expect("the rule is answerable")
.is_some(),
"a node that named `local-direct` itself was let through on its repository's answer"
);
let unreadable = Destination {
resolved: resolved(),
publication: Err("`onevcs rules check service` states no `publication:` line".into()),
};
let why = draft_refusal(&drafting(None), &unreadable)
.expect_err("a rule that needs the policy cannot be answered without it");
assert!(why.contains("states no `publication:` line"), "{why}");
assert!(draft_refusal(&undrafted, &unreadable)
.expect("a node asking for no draft needs no policy")
.is_none());
}
#[test]
fn an_absent_hook_states_no_policy_and_a_directory_git_cannot_answer_for_is_not_a_verdict() {
let scratch = std::env::temp_dir().join(format!(
"onepipeline-destination-{}-{}",
crate::sys::pid(),
"nohook"
));
let _ = std::fs::remove_dir_all(&scratch);
std::fs::create_dir_all(&scratch).expect("a scratch directory");
assert!(
!runnable(&scratch.join(COMMIT_MSG_HOOK))
.expect("an absent hook is readable as absent"),
"a hook that is not there was read as one git would run"
);
let why = ask_the_hook(&scratch, "feat: x")
.expect_err("a directory that is no repository answers no verdict");
assert!(
why.contains(&scratch.display().to_string()),
"the refusal does not name the checkout it could not read: {why}"
);
let _ = std::fs::remove_dir_all(&scratch);
}
#[cfg(unix)]
#[test]
fn a_repositorys_own_hook_answers_for_each_title_it_is_put() {
use std::os::unix::fs::PermissionsExt;
let checkout = std::env::temp_dir().join(format!(
"onepipeline-destination-hook-{}",
crate::sys::pid()
));
let _ = std::fs::remove_dir_all(&checkout);
std::fs::create_dir_all(&checkout).expect("a checkout to ask");
let git = |args: &[&str]| {
let ran = Command::new("git")
.args(args)
.current_dir(&checkout)
.output()
.expect("git runs");
assert!(ran.status.success(), "git {args:?}: {ran:?}");
};
git(&["init", "--initial-branch=main"]);
let hooks = checkout.join("policy-hooks");
std::fs::create_dir_all(&hooks).expect("a hooks directory");
git(&["config", "core.hooksPath", "policy-hooks"]);
assert!(
ask_the_hook(&checkout, "refactor(x): y")
.expect("a repository with no hook answers")
.is_none(),
"a repository with no commit-msg hook acquired one by being asked"
);
let install = |body: &str| {
let path = hooks.join(COMMIT_MSG_HOOK);
std::fs::write(&path, body).expect("the hook is written");
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755))
.expect("the hook is executable");
};
install(
"#!/bin/sh\ngrep -q '^feat' \"$1\" && exit 0\necho 'only feat: here' >&2\nexit 3\n",
);
assert!(
ask_the_hook(&checkout, "feat: ship it")
.expect("the hook runs")
.is_none(),
"a title this repository releases from was turned down"
);
let rejected = ask_the_hook(&checkout, "refactor(x): y")
.expect("the hook runs")
.expect("a title this repository does not release from is turned down");
assert_eq!(rejected.termination, Termination::Exit(3));
assert_eq!(rejected.said, "only feat: here");
install("#!/bin/sh\nexit 1\n");
let silent = ask_the_hook(&checkout, "feat: ship it")
.expect("the hook runs")
.expect("a silent refusal is still a refusal");
assert_eq!(silent.termination, Termination::Exit(1));
assert_eq!(
silent.said, "<no output>",
"a refusal nobody can read must say that it said nothing"
);
let path = hooks.join(COMMIT_MSG_HOOK);
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
.expect("the hook is made unrunnable");
assert!(
ask_the_hook(&checkout, "refactor(x): y")
.expect("a hook git would skip answers")
.is_none(),
"a hook git would skip was run anyway"
);
let _ = std::fs::remove_dir_all(&checkout);
}
#[test]
fn every_message_put_to_a_hook_is_its_own_file() {
let first = write_message_file("feat: one").expect("a message file");
let second = write_message_file("feat: two").expect("a second message file");
assert_ne!(first, second, "two calls shared one message file");
assert_eq!(
std::fs::read_to_string(&first).expect("the first is written"),
"feat: one\n"
);
assert_eq!(
std::fs::read_to_string(&second).expect("the second is written"),
"feat: two\n"
);
for path in [first, second] {
let _ = std::fs::remove_file(path);
}
}
}