use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use time::{Date, Month, Time};
use url::Url;
use crate::error::{Error, Result};
use crate::event::{ArtifactId, EventKind, Line};
use crate::git::ObjectId;
use crate::host::{CheckSource, Hosting};
use crate::landed::{self, Landed};
use crate::publish::{self, DraftReason};
use crate::registry::Registry;
use crate::releases::TargetName;
use crate::rules::{Approvals, MergePolicy};
use crate::session::{Lifecycle, Liveness, Provenance, SessionHolder};
use crate::store::{self, Resolution};
use crate::workspace::{Ref, Token};
use crate::{gh, git, guidance, home, policy, provenance, stream, vcs, workspace};
pub const REPORT_VERSION: u32 = 7;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "u32", into = "u32")]
pub struct ReportVersion(u32);
impl ReportVersion {
pub fn current() -> Self {
ReportVersion(REPORT_VERSION)
}
}
impl TryFrom<u32> for ReportVersion {
type Error = String;
fn try_from(value: u32) -> std::result::Result<Self, Self::Error> {
if value == REPORT_VERSION {
Ok(ReportVersion(value))
} else {
Err(format!(
"a status report declares version {value}; this build reads version \
{REPORT_VERSION}"
))
}
}
}
impl From<ReportVersion> for u32 {
fn from(version: ReportVersion) -> Self {
version.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Report {
pub version: ReportVersion,
#[serde(rename = "ref")]
pub reference: Reference,
pub identity: IdentityReport,
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<SessionReport>,
pub branch: BranchReport,
pub publication: PublicationReport,
pub checks: ChecksReport,
#[serde(skip_serializing_if = "Option::is_none")]
pub merge_path: Option<MergePathReport>,
pub next: NextReport,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Reference {
pub given: String,
pub kind: RefKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum RefKind {
ChangeUrl,
SessionToken,
Branch,
Commit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdentityReport {
pub key: String,
pub publication_checkout: PathBuf,
pub approvals: Approvals,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionReport {
pub token: Token,
pub state: Lifecycle,
pub liveness: Liveness,
pub base: Ref,
pub clone: PathBuf,
pub worktree: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BranchReport {
pub name: Ref,
pub base: Ref,
#[serde(skip_serializing_if = "Option::is_none")]
pub change_base: Option<Ref>,
pub holders: Vec<Holder>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ahead: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provenance: Option<BranchProvenance>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Holder {
pub path: PathBuf,
pub kind: HolderKind,
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<Token>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum HolderKind {
PublicationCheckout,
RegisteredCheckout,
RunClone,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum BranchProvenance {
Complete,
IncompleteUnattested,
IncompleteAttested,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(try_from = "AnyPublication")]
pub struct PublicationReport {
pub state: Landing,
pub landed: Landed,
#[serde(skip_serializing_if = "Option::is_none")]
pub change_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub draft: Option<DraftReason>,
#[serde(skip_serializing_if = "Option::is_none")]
pub held_as_draft: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub described: Option<DescribedReport>,
pub merge_policy: MergePolicy,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DescribedReport {
pub at: Stamp,
#[serde(skip_serializing_if = "Option::is_none")]
pub artifact: Option<ArtifactId>,
#[serde(skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
}
#[derive(Deserialize)]
struct AnyPublication {
state: Landing,
landed: Landed,
#[serde(default)]
change_url: Option<String>,
#[serde(default)]
draft: Option<DraftReason>,
#[serde(default)]
held_as_draft: Option<bool>,
#[serde(default)]
described: Option<DescribedReport>,
merge_policy: MergePolicy,
}
impl TryFrom<AnyPublication> for PublicationReport {
type Error = String;
fn try_from(value: AnyPublication) -> std::result::Result<Self, Self::Error> {
if let Some(reason) = &value.draft {
reason.checked().map_err(|refusal| refusal.to_string())?;
}
let fixed = fixed_word(&value.landed);
let claims_a_record = matches!(value.state, Landing::Landed | Landing::LandedInPart);
if (fixed.is_some() || claims_a_record) && fixed != Some(value.state) {
return Err(format!(
"a report says the work is {state:?} and that history decided {landed:?}; those \
are two answers to one question",
state = spell_landing(value.state),
landed = spell_landed(&value.landed),
));
}
Ok(PublicationReport {
state: value.state,
landed: value.landed,
change_url: value.change_url,
draft: value.draft,
held_as_draft: value.held_as_draft,
described: value.described,
merge_policy: value.merge_policy,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Landing {
Landed,
Queued,
Open,
#[serde(rename = "closed-without-landing")]
Closed,
Published,
LandedInPart,
MaybeLanded,
NothingToPublish,
Unpublished,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "kebab-case")]
pub enum ChecksReport {
Reported {
checks: Vec<CheckReport>,
sources: Vec<CheckSource>,
},
Unavailable {
because: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CheckReport {
pub name: String,
pub status: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub conclusion: Option<String>,
pub required: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MergePathReport {
pub verdict: Verdict,
#[serde(skip_serializing_if = "Option::is_none")]
pub log: Option<PathBuf>,
pub recorded_by: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Verdict {
Pass,
Fail,
Unrecorded,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NextReport {
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
pub because: String,
}
struct Work {
identity: String,
branch: Ref,
}
struct Told {
change_url: Option<String>,
draft: Option<DraftReason>,
described: Option<DescribedReport>,
asked_the_host_to_land: bool,
merge_path: Option<MergePathReport>,
recorded: landed::Recorded,
}
fn from_streams(streams: &[Recorded], work: &Work, session: Option<&str>) -> Told {
let relevant = relevant_streams(streams, &work.identity, &work.branch, session);
let change_url = latest(
relevant
.iter()
.filter_map(|record| record.change_url.clone()),
);
Told {
draft: standing_draft(&relevant),
described: latest(
relevant
.iter()
.filter_map(|record| record.described.clone()),
),
asked_the_host_to_land: relevant.iter().any(|record| record.asked_the_host_to_land),
merge_path: latest(
relevant
.iter()
.filter_map(|record| record.merge_path.clone()),
),
recorded: landed::Recorded {
landing: latest(relevant.iter().filter_map(|record| record.landing.clone())),
change: change_url.as_deref().and_then(|url| Url::parse(url).ok()),
},
change_url,
}
}
fn standing_draft(relevant: &[&Recorded]) -> Option<DraftReason> {
let drafted = newest(relevant.iter().filter_map(|record| record.draft.clone()))?;
let lifted = relevant
.iter()
.filter_map(|record| record.lifted.clone())
.max();
match lifted {
Some(lifted) if lifted >= drafted.at => None,
_ => Some(drafted.value),
}
}
struct Answering {
session: Option<workspace::Record>,
superseded: BTreeSet<String>,
broken: Option<String>,
}
fn answering(work: &Work) -> Result<Answering> {
let held: Vec<workspace::Record> = workspace::all()?
.into_iter()
.filter(|record| record.identity == work.identity && record.branch == work.branch)
.collect();
let mut broken = None;
let mut ends = Vec::new();
for record in &held {
match workspace::newest(record) {
Ok(end) => ends.push(end),
Err(why) => {
broken.get_or_insert(why);
}
}
}
Ok(Answering {
session: ends
.into_iter()
.max_by_key(|record| record.state == Lifecycle::Open),
superseded: held
.iter()
.filter(|record| record.retried_by.is_some())
.map(|record| record.token.to_string())
.collect(),
broken,
})
}
fn carrying<'a>(holders: &'a [Holder], superseded: &BTreeSet<String>) -> Vec<&'a Holder> {
holders
.iter()
.filter(|holder| {
holder
.session
.as_ref()
.is_none_or(|token| !superseded.contains(&token.to_string()))
})
.collect()
}
fn judge(
holders: &[&Holder],
resolution: &Resolution,
lent: Option<&Path>,
base: &Ref,
work: &Work,
recorded: &landed::Recorded,
trailers: &provenance::Trailers,
) -> Result<Vec<(PathBuf, String, Landed)>> {
let current = vcs::base_commit(&resolution.publication, base);
let mut judged = Vec::new();
for holder in holders {
let asked = git::Asked::borrowing(&holder.path, lent);
let compared = vcs::judged_against(asked, base, current.as_ref());
let verdict = landed::decide(
asked,
&compared,
current.as_ref(),
&work.branch,
recorded,
trailers,
)?;
judged.push((holder.path.clone(), compared, verdict));
}
Ok(judged)
}
fn carrier_of(judged: &[(PathBuf, String, Landed)]) -> Option<&(PathBuf, String, Landed)> {
judged.iter().min_by_key(|(_, _, verdict)| match verdict {
Landed::No => 0,
Landed::Unknown => 1,
Landed::InPart { .. } => 2,
Landed::Yes { .. } => 3,
})
}
pub(crate) struct LandingOf {
pub identity: String,
pub branch: Ref,
pub landed: Landed,
pub carrier: Option<PathBuf>,
pub lent: Option<PathBuf>,
}
pub(crate) fn landing_of(registry: &Registry, reference: &str) -> Result<LandingOf> {
landing_of_within(registry, reference, None)
}
pub(crate) fn landing_of_within(
registry: &Registry,
reference: &str,
repo: Option<&str>,
) -> Result<LandingOf> {
let scope = repo
.map(|repo| store::resolve(registry, repo).map(|resolution| resolution.key))
.transpose()?;
let within = scope.as_deref();
let mut notes = Vec::new();
let streams = recorded_streams(&mut notes)?;
let (work, _) = resolve(registry, reference, &streams, within)?;
let resolution = store::resolve(registry, &work.identity)?;
let (file, _) = policy::load(registry)?;
let trailers = provenance::from_rules(&file);
let base = Ref::from_git(git::default_branch(&resolution.publication, "origin")?);
let holders = holders_of(registry, &resolution, &work.branch)?;
let lent = git::objects_dir(&resolution.publication).ok();
let answering = answering(&work)?;
let held_by = answering
.session
.as_ref()
.map(|record| record.token.to_string());
let told = from_streams(&streams, &work, held_by.as_deref());
let judged = judge(
&carrying(&holders, &answering.superseded),
&resolution,
lent.as_deref(),
&base,
&work,
&told.recorded,
&trailers,
)?;
let carrier = carrier_of(&judged);
Ok(LandingOf {
identity: work.identity.clone(),
branch: work.branch.clone(),
landed: match answering.broken.is_some() {
true => Landed::Unknown,
false => carrier
.map(|(_, _, verdict)| verdict.clone())
.unwrap_or(Landed::Unknown),
},
carrier: carrier.map(|(repo, _, _)| repo.clone()),
lent,
})
}
pub fn run(registry: &Registry, reference: &str, hosting: &dyn Hosting) -> Result<Report> {
let mut notes = Vec::new();
let streams = recorded_streams(&mut notes)?;
let (work, kind) = resolve(registry, reference, &streams, None)?;
let resolution = store::resolve(registry, &work.identity)?;
let (file, source) = policy::load(registry)?;
let trailers = provenance::from_rules(&file);
let normalized = store::normalize(&resolution.identity.origin);
let resolved = policy::resolve(&file, &source, &normalized, &resolution.publication);
let base = Ref::from_git(git::default_branch(&resolution.publication, "origin")?);
let holders = holders_of(registry, &resolution, &work.branch)?;
let lent = git::objects_dir(&resolution.publication).ok();
let answering = answering(&work)?;
let session = answering.session.clone().map(|record| SessionReport {
token: record.token.clone(),
state: record.state,
liveness: SessionHolder::from(record.clone()).liveness,
base: record.base.clone(),
clone: record.clone.clone(),
worktree: record.worktree.clone(),
});
let held_by = session.as_ref().map(|session| session.token.to_string());
let told = from_streams(&streams, &work, held_by.as_deref());
let change_url = told.change_url.clone();
let draft = told.draft.clone();
let described = told.described.clone();
let asked_the_host_to_land = told.asked_the_host_to_land;
let merge_path = told.merge_path.clone();
let judged = judge(
&carrying(&holders, &answering.superseded),
&resolution,
lent.as_deref(),
&base,
&work,
&told.recorded,
&trailers,
)?;
let carrier = carrier_of(&judged);
let mut ahead = None;
let mut branch_provenance = None;
let mut change_base = None;
let mut verdict = None;
if let Some((repo, compared, decided)) = carrier {
let repo = git::Asked::borrowing(repo, lent.as_deref());
ahead = Some(git::log_messages(repo, compared, &work.branch)?.len());
branch_provenance = Some(judge_provenance(repo, compared, &work.branch, &trailers)?);
change_base = provenance::recorded_change_base(repo, compared, &work.branch, &trailers)?
.and_then(|recorded| Ref::try_from(recorded).ok());
verdict = Some(decided.clone());
}
if let Some(why) = &answering.broken {
notes.push(format!(
"{why}. What became of {branch:?} is therefore not decided from any of them",
branch = work.branch,
));
verdict = Some(Landed::Unknown);
}
let host = match &answering.session {
Some(record) => match publish::standing_target(record) {
Ok(target) => ask_the_host(&resolution.key, &work.branch, target.base(), hosting),
Err(error) => HostAnswer::Unasked {
because: format!(
"the base session {token} publishes into could not be resolved, so the \
host was not asked about its change request: {error}",
token = record.token,
),
},
},
None => {
let target = change_base.clone().unwrap_or_else(|| base.clone());
ask_the_host(&resolution.key, &work.branch, &target, hosting)
}
};
let state = landing(
verdict.as_ref(),
ahead,
host.said(),
match (change_url.is_some(), asked_the_host_to_land) {
(false, _) => Proposed::Never,
(true, false) => Proposed::Opened,
(true, true) => Proposed::OpenedAndAskedToLand,
},
);
let (open, held_as_draft, checks) = host.into_parts();
let change_url = open.or(change_url);
let landed = verdict.unwrap_or(Landed::Unknown);
let next = next_step(&Advance {
resolution: &resolution,
work: &work,
base: &base,
state,
landed: &landed,
change_url: change_url.as_deref(),
session: session.as_ref(),
provenance: branch_provenance,
nobody_has_it: holders.is_empty(),
});
Ok(Report {
version: ReportVersion::current(),
reference: Reference {
given: reference.to_owned(),
kind,
},
identity: IdentityReport {
key: resolution.key.clone(),
publication_checkout: resolution.publication.clone(),
approvals: resolved.policy.approvals,
},
session,
branch: BranchReport {
name: work.branch.clone(),
base,
change_base,
holders,
ahead,
provenance: branch_provenance,
},
publication: PublicationReport {
state,
landed,
change_url,
draft,
held_as_draft,
described,
merge_policy: resolved.policy.publication,
},
checks,
merge_path,
next,
notes,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OnTheHost {
Unasked,
NothingOpen,
Open,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Proposed {
Never,
Opened,
OpenedAndAskedToLand,
}
fn fixed_word(landed: &Landed) -> Option<Landing> {
match landed {
Landed::Yes { .. } => Some(Landing::Landed),
Landed::InPart { .. } => Some(Landing::LandedInPart),
Landed::No | Landed::Unknown => None,
}
}
fn spell_landed(landed: &Landed) -> &'static str {
match landed {
Landed::Yes { .. } => "yes",
Landed::InPart { .. } => "in part",
Landed::No => "no",
Landed::Unknown => "unknown",
}
}
fn landing(
verdict: Option<&Landed>,
ahead: Option<usize>,
host: OnTheHost,
proposed: Proposed,
) -> Landing {
if let Some(word) = verdict.and_then(fixed_word) {
return word;
}
match verdict {
Some(Landed::Unknown) if ahead == Some(0) => return Landing::NothingToPublish,
Some(Landed::Unknown) => return Landing::MaybeLanded,
Some(Landed::Yes { .. } | Landed::InPart { .. } | Landed::No) | None => {}
}
match (host, proposed) {
(OnTheHost::Open, Proposed::OpenedAndAskedToLand) => Landing::Queued,
(OnTheHost::Open, _) => Landing::Open,
(_, Proposed::Never) => Landing::Unpublished,
(OnTheHost::NothingOpen, _) => Landing::Closed,
(OnTheHost::Unasked, _) => Landing::Published,
}
}
enum HostAnswer {
Unasked {
because: String,
},
NothingOpen,
Open {
url: String,
draft: Option<bool>,
checks: ChecksReport,
},
}
impl HostAnswer {
fn said(&self) -> OnTheHost {
match self {
HostAnswer::Unasked { .. } => OnTheHost::Unasked,
HostAnswer::NothingOpen => OnTheHost::NothingOpen,
HostAnswer::Open { .. } => OnTheHost::Open,
}
}
fn into_parts(self) -> (Option<String>, Option<bool>, ChecksReport) {
let reported = || ChecksReport::Reported {
checks: Vec::new(),
sources: Vec::new(),
};
match self {
HostAnswer::Unasked { because } => (None, None, ChecksReport::Unavailable { because }),
HostAnswer::NothingOpen => (None, None, reported()),
HostAnswer::Open { url, draft, checks } => (Some(url), draft, checks),
}
}
}
fn ask_the_host(identity: &str, branch: &str, base: &str, hosting: &dyn Hosting) -> HostAnswer {
let unasked = |because: String| HostAnswer::Unasked { because };
let Some(slug) = gh::slug(identity) else {
return unasked(format!(
"identity {identity:?} is not a {} repository, so no host answers for it",
gh::HOST
));
};
let host = match hosting.for_repo(&slug) {
Ok(host) => host,
Err(error) => return unasked(error.to_string()),
};
let open = match host.find_changes(branch, base) {
Ok(changes) => changes.into_iter().next(),
Err(error) => return unasked(error.to_string()),
};
let Some(change) = open else {
return HostAnswer::NothingOpen;
};
let url = change.url.to_string();
let draft = host.is_draft(&change).ok();
let checks = match host.change_checks(&change) {
Ok(answer) => ChecksReport::Reported {
checks: answer
.checks
.into_iter()
.map(|check| CheckReport {
name: check.name,
status: check.status,
conclusion: check.conclusion,
required: check.required,
})
.collect(),
sources: answer.sources.into_iter().collect(),
},
Err(error) => ChecksReport::Unavailable {
because: error.to_string(),
},
};
HostAnswer::Open { url, draft, checks }
}
#[derive(Clone, Copy)]
struct Advance<'a> {
resolution: &'a Resolution,
work: &'a Work,
base: &'a str,
state: Landing,
landed: &'a Landed,
change_url: Option<&'a str>,
session: Option<&'a SessionReport>,
provenance: Option<BranchProvenance>,
nobody_has_it: bool,
}
fn next_step(seen: &Advance<'_>) -> NextReport {
let Advance {
resolution,
work,
base,
state,
landed,
change_url,
session,
provenance,
nobody_has_it,
} = *seen;
let says = |because: String| NextReport {
command: None,
because,
};
let change = change_url.unwrap_or("the change request");
match state {
Landing::Landed => says(format!(
"the work landed: {evidence} says branch {branch:?} reached {base}. Nothing advances \
it",
branch = work.branch,
evidence = landed.tier(),
)),
Landing::MaybeLanded => says(format!(
"nothing records that branch {branch:?} reached {base}: no landing, no change \
request's number in the base's history, and no landing trailer. {base} does already \
carry everything it changed, which is what a landing nobody recorded looks like — \
and also what somebody else making the same change looks like. Read it with `{diff}` \
before publishing it",
branch = work.branch,
diff = guidance::command([
"git",
"-C",
&resolution.publication.to_string_lossy(),
"diff",
"--stat",
&format!("{base}...{branch}", branch = work.branch),
]),
)),
Landing::NothingToPublish => says(format!(
"branch {branch:?} has nothing {base} does not already carry, so there is nothing to \
publish",
branch = work.branch,
)),
Landing::Queued => says(format!(
"the host has queued the merge of {change} and lands it once its required checks pass"
)),
Landing::Open => says(format!(
"{change} is open on the host and lands when what its policy waits for — review, or \
the host's required checks — is done"
)),
Landing::Published => says(format!(
"{change} was opened for this branch, and the host could not be asked what became of \
it; the checks section says why"
)),
Landing::LandedInPart | Landing::Closed | Landing::Unpublished => {
if nobody_has_it {
return says(format!(
"no checkout or run clone of identity {key:?} holds branch {branch:?}, so there \
is nothing here to publish. `onevcs import` makes a branch reachable again",
key = resolution.key,
branch = work.branch,
));
}
if let Some(session) = session.filter(|session| session.state == Lifecycle::Open) {
return NextReport {
command: Some(guidance::command(["onevcs", "publish", &session.token])),
because: format!(
"session {token} is open and still holds branch {branch:?} (its owner is \
{liveness}), so this is not preserved work: publishing the session is \
what lands it",
token = session.token,
branch = work.branch,
liveness = session.liveness.as_str(),
),
};
}
let repo = resolution.publication.to_string_lossy();
let interrupted = provenance == Some(BranchProvenance::IncompleteUnattested);
let verb = if interrupted {
"recover"
} else {
"publish-branch"
};
NextReport {
command: Some(guidance::command([
"onevcs",
verb,
&work.branch,
"--repo",
&repo,
])),
because: if interrupted {
format!(
"branch {branch:?} is preserved and unpublished, and carries an unattested \
incomplete marker: only `recover` may publish it, because publishing it \
means attesting that verification cleared the step that stopped",
branch = work.branch,
)
} else if let Landed::InPart { evidence, unlanded } = landed {
format!(
"branch {branch:?} landed in part: {tier} ({commit}) says work of it \
reached {base}, and it has {unlanded} commit(s) since that the landing \
does not carry",
branch = work.branch,
tier = landed.tier(),
commit = evidence.commit(),
)
} else {
format!(
"branch {branch:?} is preserved and unpublished: it has commits {base} does \
not carry, and no session holds it",
branch = work.branch,
)
},
}
}
}
}
fn judge_provenance<'a>(
repo: impl Into<git::Asked<'a>>,
compared: &str,
branch: &str,
trailers: &provenance::Trailers,
) -> Result<BranchProvenance> {
let repo = repo.into();
if !provenance::unrecognized(repo, compared, branch, trailers)?.is_empty()
|| !provenance::unattested(repo, compared, branch, trailers)?.is_empty()
{
return Ok(BranchProvenance::IncompleteUnattested);
}
Ok(
match provenance::provenance_of(repo, compared, branch, trailers)? {
Provenance::IncompleteStep => BranchProvenance::IncompleteAttested,
Provenance::Complete => BranchProvenance::Complete,
},
)
}
fn holders_of(registry: &Registry, resolution: &Resolution, branch: &str) -> Result<Vec<Holder>> {
let sessions = workspace::all()?;
let mut holders = Vec::new();
for path in workspace::checkouts_of(registry, resolution)? {
if !git::is_repo(&path) || !git::branch_exists(&path, branch) {
continue;
}
let kind = if path == resolution.publication {
HolderKind::PublicationCheckout
} else if registry
.checkouts
.values()
.any(|checkout| checkout.path == path)
{
HolderKind::RegisteredCheckout
} else {
HolderKind::RunClone
};
let session = sessions
.iter()
.find(|record| record.clone == path)
.map(|record| record.token.clone());
holders.push(Holder {
path,
kind,
session,
});
}
Ok(holders)
}
#[derive(Debug, Clone)]
pub(crate) struct Recorded {
token: String,
identity: Option<String>,
branch: Option<String>,
change_url: Option<Stamped<String>>,
landing: Option<Stamped<ObjectId>>,
draft: Option<Stamped<DraftReason>>,
lifted: Option<Stamp>,
described: Option<Stamped<DescribedReport>>,
asked_the_host_to_land: bool,
merge_path: Option<Stamped<MergePathReport>>,
}
fn read_draft_reason(field: &dyn Fn(&str) -> Option<String>) -> Option<DraftReason> {
let reason = match field("kind").as_deref() {
Some("held") => DraftReason::Held {
because: field("because")?,
},
Some("awaiting-release") | None => DraftReason::AwaitingRelease {
awaiting: field("awaiting")?,
target: field("target").and_then(|name| TargetName::try_from(name).ok())?,
reference: field("reference")?,
because: field("because")?,
},
Some(_) => return None,
};
reason.checked().ok().map(|()| reason)
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub struct Stamp(String);
impl TryFrom<String> for Stamp {
type Error = String;
fn try_from(value: String) -> std::result::Result<Self, String> {
Stamp::parse(&value).ok_or_else(|| {
format!("{value:?} is not a moment spelled the way the event envelope spells one")
})
}
}
impl From<Stamp> for String {
fn from(stamp: Stamp) -> Self {
stamp.0
}
}
impl Stamp {
fn parse(value: &str) -> Option<Self> {
const SHAPE: &str = "0000-00-00T00:00:00.000Z";
if value.len() != SHAPE.len()
|| !value.chars().zip(SHAPE.chars()).all(|(had, want)| {
if want == '0' {
had.is_ascii_digit()
} else {
had == want
}
})
{
return None;
}
let field = |from: usize, to: usize| value[from..to].parse::<u32>().ok();
Date::from_calendar_date(
i32::try_from(field(0, 4)?).ok()?,
Month::try_from(u8::try_from(field(5, 7)?).ok()?).ok()?,
u8::try_from(field(8, 10)?).ok()?,
)
.ok()?;
Time::from_hms_milli(
u8::try_from(field(11, 13)?).ok()?,
u8::try_from(field(14, 16)?).ok()?,
u8::try_from(field(17, 19)?).ok()?,
u16::try_from(field(20, 23)?).ok()?,
)
.ok()?;
Some(Stamp(value.to_owned()))
}
}
#[derive(Debug, Clone)]
struct Stamped<T> {
at: Stamp,
value: T,
}
fn latest<T>(recorded: impl Iterator<Item = Stamped<T>>) -> Option<T> {
newest(recorded).map(|stamped| stamped.value)
}
fn newest<T>(recorded: impl Iterator<Item = Stamped<T>>) -> Option<Stamped<T>> {
recorded.max_by(|left, right| left.at.cmp(&right.at))
}
pub(crate) fn recorded_streams(notes: &mut Vec<String>) -> Result<Vec<Recorded>> {
let directory = home::streams_dir()?;
let entries = match std::fs::read_dir(&directory) {
Ok(entries) => entries,
Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(failure) => {
notes.push(format!(
"the event streams at {} could not be listed ({failure}), so nothing any session \
recorded is in this report",
directory.display()
));
return Ok(Vec::new());
}
};
let mut tokens: Vec<String> = Vec::new();
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(failure) => {
notes.push(format!(
"an entry of the event streams at {} could not be read ({failure}), so \
whatever session it belongs to is not in this report",
directory.display()
));
continue;
}
};
if let Some(token) = entry
.file_name()
.to_string_lossy()
.strip_suffix(".ndjson")
.map(str::to_owned)
{
tokens.push(token);
}
}
tokens.sort();
Ok(tokens
.into_iter()
.map(|token| read_stream(&directory, &token, notes))
.collect())
}
fn read_stream(directory: &Path, token: &str, notes: &mut Vec<String>) -> Recorded {
let mut record = Recorded {
token: token.to_owned(),
identity: None,
branch: None,
change_url: None,
landing: None,
draft: None,
lifted: None,
described: None,
asked_the_host_to_land: false,
merge_path: None,
};
let path = directory.join(format!("{token}.ndjson"));
let raw = match std::fs::read_to_string(&path) {
Ok(raw) => raw,
Err(failure) => {
notes.push(format!(
"the event stream at {} could not be read ({failure}), so what it recorded is not \
in this report",
path.display()
));
return record;
}
};
for (index, line) in raw.lines().enumerate() {
let read = match stream::attributed(line, token, index + 1) {
Ok(read) => read,
Err(refusal) => {
notes.push(format!(
"{refusal}, so whatever it recorded is not in this report ({})",
path.display()
));
continue;
}
};
let (v, ts) = match &read {
Line::Known(event) => (event.v, event.ts.as_str()),
Line::Unknown(header) => (header.v, header.ts.as_str()),
};
if v != stream::ENVELOPE_VERSION {
notes.push(format!(
"line {} of the event stream at {} declares envelope version {}, and this build \
reads version {}, so what it recorded is not in this report",
index + 1,
path.display(),
v,
stream::ENVELOPE_VERSION,
));
continue;
}
let Some(at) = Stamp::parse(ts) else {
notes.push(format!(
"line {} of the event stream at {} is stamped {:?}, which is not the RFC3339 \
millisecond UTC the envelope declares, so what it recorded cannot be ordered \
against the rest and is not in this report",
index + 1,
path.display(),
ts,
));
continue;
};
let Line::Known(event) = read else {
continue;
};
if record.identity.is_none() {
record.identity = event.labels.extra.get("identity").and_then(text);
}
if record.branch.is_none() {
record.branch = event.payload.get("branch").and_then(text);
}
let field = |name: &str| event.payload.get(name).and_then(text);
match event.kind {
EventKind::ChangeOpened => {
if let Some(url) = field("url") {
record.change_url = Some(Stamped { at, value: url });
}
}
EventKind::ChangeDrafted => {
let read = read_draft_reason(&field);
match read {
Some(reason) => record.draft = Some(Stamped { at, value: reason }),
None => notes.push(format!(
"line {} of the event stream at {} records a draft whose reason cannot \
be read, so why {} is held back is not in this report",
index + 1,
path.display(),
record.branch.as_deref().unwrap_or("this change"),
)),
}
}
EventKind::DraftLifted => record.lifted = Some(at),
EventKind::ChangeDescribed => {
record.described = Some(Stamped {
at: at.clone(),
value: DescribedReport {
at,
artifact: field("artifact")
.filter(|id| crate::ids::is_safe_name(id))
.map(ArtifactId),
title: field("title"),
},
});
}
EventKind::MergeQueued => {
record.asked_the_host_to_land |= field("url").is_some();
}
EventKind::ChangeMerged | EventKind::MergeCompleted => {
if let Some(sha) = field("sha").as_deref().and_then(ObjectId::parse) {
record.landing = Some(Stamped { at, value: sha });
}
}
EventKind::Push => {
record.merge_path = Some(Stamped {
at,
value: MergePathReport {
verdict: match event.payload.get("accepted").and_then(Value::as_bool) {
Some(true) => Verdict::Pass,
Some(false) => Verdict::Fail,
None => Verdict::Unrecorded,
},
log: field("preserved_log").map(PathBuf::from),
recorded_by: token.to_owned(),
},
});
}
_ => {}
}
}
record
}
fn text(value: &Value) -> Option<String> {
value
.as_str()
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
fn relevant_streams<'a>(
streams: &'a [Recorded],
identity: &str,
branch: &str,
session: Option<&str>,
) -> Vec<&'a Recorded> {
let slug = policy::branch_slug(branch);
let named: BTreeSet<String> = [
session.map(str::to_owned),
Some(format!("publish-branch-{slug}")),
Some(format!("recover-{slug}")),
]
.into_iter()
.flatten()
.collect();
streams
.iter()
.filter(|record| {
named.contains(&record.token)
|| (record.branch.as_deref() == Some(branch)
&& record.identity.as_deref() == Some(identity))
})
.collect()
}
pub(crate) fn recorded_for(
streams: &[Recorded],
identity: &str,
branch: &str,
session: Option<&str>,
) -> landed::Recorded {
let relevant = relevant_streams(streams, identity, branch, session);
landed::Recorded {
landing: latest(relevant.iter().filter_map(|record| record.landing.clone())),
change: latest(
relevant
.iter()
.filter_map(|record| record.change_url.clone()),
)
.and_then(|url| Url::parse(&url).ok()),
}
}
fn resolve(
registry: &Registry,
reference: &str,
streams: &[Recorded],
within: Option<&str>,
) -> Result<(Work, RefKind)> {
if reference.starts_with("http://") || reference.starts_with("https://") {
return change_url(registry, reference, streams, within)
.and_then(|work| in_scope(work, reference, within))
.map(|work| (work, RefKind::ChangeUrl));
}
if let Ok(record) = workspace::load(reference) {
return in_scope(
Work {
identity: record.identity,
branch: record.branch.clone(),
},
reference,
within,
)
.map(|work| (work, RefKind::SessionToken));
}
if let Ok(named) = Ref::try_from(reference.to_owned()) {
let found = by_branch(registry, &named, within)?;
if !found.is_empty() {
return one(found, reference, "branch").map(|work| (work, RefKind::Branch));
}
}
if reference.len() >= 7 && reference.chars().all(|c| c.is_ascii_hexdigit()) {
let found = by_commit(registry, reference, within)?;
if !found.is_empty() {
return one(found, reference, "commit").map(|work| (work, RefKind::Commit));
}
}
Err(Error::Invalid {
reason: match within {
Some(key) => format!(
"{reference:?} names no work this host knows in repository {key:?}: it is not a \
change request `onevcs` opened there, a session token it printed for it, a \
branch any checkout or run clone of that identity holds, or a commit one of \
those branches carries. `onevcs recoverable` lists the preserved branches"
),
None => format!(
"{reference:?} names no work this host knows: it is not a change request \
`onevcs` opened, a session token it printed, a branch any checkout or run clone \
of a registered identity holds, or a commit one of those branches carries. \
`onevcs repos` lists the identities and `onevcs recoverable` lists the \
preserved branches"
),
},
})
}
fn in_scope(work: Work, reference: &str, within: Option<&str>) -> Result<Work> {
match within {
Some(key) if key != work.identity => Err(Error::Invalid {
reason: format!(
"{reference:?} names work in repository {found:?}, not in {key:?}; ask about it \
without naming a repository, or name the one it belongs to",
found = work.identity,
),
}),
_ => Ok(work),
}
}
fn one(found: Vec<Work>, reference: &str, spelling: &str) -> Result<Work> {
let named: Vec<String> = found
.iter()
.map(|work| format!("{} in identity {:?}", work.branch, work.identity))
.collect();
let mut found = found;
if found.len() == 1 {
return Ok(found.remove(0));
}
Err(Error::Invalid {
reason: format!(
"{reference:?} is an ambiguous {spelling}: {count} pieces of work answer to it — \
{candidates}. Ask about one of them by a reference only it has: a session token, or \
the URL of its change request",
count = named.len(),
candidates = named.join("; "),
),
})
}
fn change_url(
registry: &Registry,
url: &str,
streams: &[Recorded],
within: Option<&str>,
) -> Result<Work> {
let recorded = streams
.iter()
.find(|record| {
record
.change_url
.as_ref()
.is_some_and(|recorded| recorded.value == url)
})
.ok_or_else(|| Error::Invalid {
reason: format!(
"no change request at {url} was opened through `onevcs` on this host, so nothing \
here records which branch it carries. Ask about the branch by name instead"
),
})?;
let branch = recorded.branch.clone().ok_or_else(|| Error::Invalid {
reason: format!(
"the event stream for {token:?} records the change request at {url} and names no \
branch, so nothing here can tell which work it carries. `onevcs events {token}` is \
what it does hold",
token = recorded.token,
),
})?;
let branch = Ref::try_from(branch).map_err(|reason| Error::Invalid {
reason: format!(
"the event stream for {token:?} records the change request at {url} as carrying \
{reason}, so nothing here can look for it. `onevcs events {token}` is what that \
stream holds",
token = recorded.token,
),
})?;
if let Some(identity) = &recorded.identity {
if registry.identities.contains_key(identity) {
return Ok(Work {
identity: identity.clone(),
branch,
});
}
}
let found = by_branch(registry, &branch, within)?;
if found.is_empty() {
return Err(Error::Invalid {
reason: match within {
Some(key) => format!(
"the change request at {url} carries branch {branch}, which no checkout or \
run clone of repository {key:?} holds; ask about it without naming a \
repository, or name the one it belongs to"
),
None => format!(
"the change request at {url} carries branch {branch}, which no checkout or \
run clone of any registered identity holds, so nothing here can say which \
work it was. `onevcs recoverable` lists the preserved branches"
),
},
});
}
one(found, url, "change request")
}
fn by_branch(registry: &Registry, branch: &Ref, within: Option<&str>) -> Result<Vec<Work>> {
let mut found = Vec::new();
for identity in identities(registry, within) {
let resolution = store::resolve(registry, &identity)?;
for path in workspace::checkouts_of(registry, &resolution)? {
if git::is_repo(&path) && git::branch_exists(&path, branch) {
found.push(Work {
identity: identity.clone(),
branch: branch.clone(),
});
break;
}
}
}
Ok(found)
}
fn by_commit(registry: &Registry, commit: &str, within: Option<&str>) -> Result<Vec<Work>> {
let mut found: Vec<Work> = Vec::new();
for identity in identities(registry, within) {
let resolution = store::resolve(registry, &identity)?;
let root = git::default_branch(&resolution.publication, "origin").ok();
for path in workspace::checkouts_of(registry, &resolution)? {
if !git::is_repo(&path) || !git::has_commit(&path, &crate::host::Sha(commit.to_owned()))
{
continue;
}
for branch in git::branches(&path)? {
if root.as_deref() == Some(branch.as_str()) {
continue;
}
if !git::is_ancestor(&path, commit, &branch)? {
continue;
}
let branch = Ref::from_git(branch);
if found
.iter()
.any(|work| work.identity == identity && work.branch == branch)
{
continue;
}
found.push(Work {
identity: identity.clone(),
branch,
});
}
}
}
Ok(found)
}
fn identities(registry: &Registry, within: Option<&str>) -> Vec<String> {
let mut keys: Vec<String> = registry
.checkouts
.values()
.map(|checkout| checkout.identity.clone())
.filter(|identity| within.is_none_or(|key| key == identity))
.collect();
keys.sort_unstable();
keys.dedup();
keys
}
impl Report {
pub fn render(&self) -> String {
let mut out = String::new();
out.push_str(&format!(
"work: {} [{}]\n",
self.branch.name, self.identity.key
));
out.push_str(&format!(
"identity:\n key: {}\n publication checkout: {}\n approvals: {}\n",
self.identity.key,
self.identity.publication_checkout.display(),
spell_approvals(self.identity.approvals),
));
match &self.session {
Some(session) => out.push_str(&format!(
"session:\n token: {}\n state: {} ({})\n base: {}\n clone: {}\n worktree: {}\n",
session.token,
match session.state {
Lifecycle::Open => "open",
Lifecycle::Closed => "closed",
},
session.liveness.as_str(),
session.base,
session.clone.display(),
session.worktree.display(),
)),
None => out.push_str("session: none recorded for this branch\n"),
}
out.push_str("branch:\n");
out.push_str(&format!(" name: {}\n", self.branch.name));
out.push_str(&format!(" base: {}\n", self.branch.base));
if let Some(change_base) = &self.branch.change_base {
out.push_str(&format!(" change base recorded: {change_base}\n"));
}
match self.branch.ahead {
Some(ahead) => out.push_str(&format!(
" ahead of {}: {ahead} commit(s)\n",
self.branch.base
)),
None => out.push_str(" ahead: unknown — nothing on this host holds the branch\n"),
}
out.push_str(&format!(
" provenance: {}\n",
match self.branch.provenance {
Some(BranchProvenance::Complete) => "complete",
Some(BranchProvenance::IncompleteUnattested) => "incomplete (unattested)",
Some(BranchProvenance::IncompleteAttested) => "incomplete (attested)",
None => "unknown — nothing on this host holds the branch",
}
));
if self.branch.holders.is_empty() {
out.push_str(" held by: nothing this identity keeps work in\n");
} else {
out.push_str(" held by:\n");
for holder in &self.branch.holders {
let what = match holder.kind {
HolderKind::PublicationCheckout => "publication checkout".to_owned(),
HolderKind::RegisteredCheckout => "registered checkout".to_owned(),
HolderKind::RunClone => match &holder.session {
Some(token) => format!("run clone of session {token}"),
None => "run clone".to_owned(),
},
};
out.push_str(&format!(" {} ({what})\n", holder.path.display()));
}
}
out.push_str("publication:\n");
out.push_str(&format!(
" state: {}\n",
spell_landing(self.publication.state)
));
out.push_str(&format!(
" landed: {}\n",
spell_landed(&self.publication.landed)
));
out.push_str(&format!(
" decided by: {}\n",
match &self.publication.landed {
Landed::Yes { evidence } => format!(
"{tier} ({commit})",
tier = self.publication.landed.tier(),
commit = evidence.commit(),
),
Landed::InPart { evidence, unlanded } => format!(
"{tier} ({commit}), and {unlanded} commit(s) of the branch are not in it",
tier = self.publication.landed.tier(),
commit = evidence.commit(),
),
other => other.tier().to_owned(),
},
));
out.push_str(&format!(
" change request: {}\n",
self.publication
.change_url
.as_deref()
.unwrap_or("none recorded")
));
if let Some(held) = self.publication.held_as_draft {
out.push_str(&format!(
" held as draft by the host: {}\n",
if held { "yes" } else { "no" }
));
}
if let Some(reason) = &self.publication.draft {
out.push_str(&format!(
" draft reason: {} — {}\n",
reason.kind(),
reason.because()
));
}
if let Some(described) = &self.publication.described {
out.push_str(&format!(
" described: {}{}{}\n",
described.at.0,
described
.artifact
.as_ref()
.map(|id| format!(", body as artifact {}", id.0))
.unwrap_or_default(),
described
.title
.as_ref()
.map(|title| format!(", titled {title:?}"))
.unwrap_or_default(),
));
}
out.push_str(&format!(
" merge policy: {}\n",
policy::spell(self.publication.merge_policy)
));
match &self.checks {
ChecksReport::Unavailable { because } => {
out.push_str(&format!("checks: unavailable — {because}\n"));
}
ChecksReport::Reported { checks, .. } if checks.is_empty() => {
out.push_str("checks: none reported on this work\n");
}
ChecksReport::Reported { checks, sources } => {
out.push_str("checks:\n");
for check in checks {
out.push_str(&format!(
" {}\t{}\t{}\t{}\n",
check.name,
check.status,
check.conclusion.as_deref().unwrap_or("-"),
if check.required {
"required"
} else {
"not required"
},
));
}
out.push_str(&format!(
" sources: {}\n",
sources
.iter()
.map(spell_source)
.collect::<Vec<_>>()
.join(", ")
));
}
}
match &self.merge_path {
Some(merge_path) => {
out.push_str(&format!(
"merge path:\n verdict: {}\n recorded by: {}\n",
spell_verdict(merge_path.verdict),
merge_path.recorded_by
));
if let Some(log) = &merge_path.log {
out.push_str(&format!(" log: {}\n", log.display()));
}
}
None => out.push_str("merge path: no verdict recorded for this work\n"),
}
out.push_str("next:\n");
match &self.next.command {
Some(command) => out.push_str(&format!(" {command}\n")),
None => out.push_str(" nothing advances this work\n"),
}
out.push_str(&format!(" because: {}\n", self.next.because));
for note in &self.notes {
out.push_str(&format!("note: {note}\n"));
}
out
}
}
fn spell_landing(state: Landing) -> &'static str {
match state {
Landing::Landed => "landed",
Landing::Queued => "queued",
Landing::Open => "open",
Landing::Closed => "closed without landing",
Landing::Published => "published (the host could not be asked what became of it)",
Landing::MaybeLanded => {
"maybe landed (the base carries it and nothing records that it did)"
}
Landing::LandedInPart => "landed in part (the branch has gone on since)",
Landing::NothingToPublish => "nothing to publish",
Landing::Unpublished => "unpublished",
}
}
fn spell_approvals(approvals: Approvals) -> &'static str {
match approvals {
Approvals::Required => "required",
Approvals::None => "none",
}
}
fn spell_verdict(verdict: Verdict) -> &'static str {
match verdict {
Verdict::Pass => "pass",
Verdict::Fail => "fail",
Verdict::Unrecorded => "a verdict this build does not read",
}
}
fn spell_source(source: &CheckSource) -> &'static str {
match source {
CheckSource::StatusChecks => "status-checks",
CheckSource::Actions => "actions",
CheckSource::BranchRules => "branch-rules",
}
}
#[cfg(test)]
mod round_trip {
use super::{DraftReason, Landing, Report, ReportVersion, REPORT_VERSION};
use serde_json::json;
use serde_json::Value;
const FULL: &str = include_str!("../tests/golden/status-report-v7.json");
const MINIMAL: &str = include_str!("../tests/golden/status-report-v7-minimal.json");
fn parsed(golden: &str) -> Value {
serde_json::from_str(golden).expect("a golden is JSON")
}
#[test]
fn both_checked_in_goldens_read_back_as_reports_and_write_themselves_again() {
for (name, golden) in [("full", FULL), ("minimal", MINIMAL)] {
let report: Report = serde_json::from_str(golden)
.unwrap_or_else(|e| panic!("the {name} golden reads back as a report: {e}"));
assert_eq!(
serde_json::to_value(&report).expect("a report serializes"),
parsed(golden),
"the {name} golden and the report it reads back as disagree"
);
}
let full: Report = serde_json::from_str(FULL).expect("the full golden");
assert_eq!(full.version, ReportVersion::current());
assert_eq!(&*full.branch.name, "feature/full");
assert_eq!(
full.branch.change_base.as_deref(),
Some("feature/below"),
"a recorded change base reads back as the branch name it is"
);
assert_eq!(
&*full.session.expect("the full golden names a session").token,
"s-000000000000"
);
for (name, golden) in [("full", FULL), ("minimal", MINIMAL)] {
let report: Report = serde_json::from_str(golden).expect("a golden reads back");
assert_eq!(
report.publication.state == Landing::Landed,
report.publication.landed.is_landed(),
"the {name} golden's publication state and its landing answer disagree"
);
}
let minimal: Report = serde_json::from_str(MINIMAL).expect("the minimal golden");
assert!(minimal.session.is_none());
assert!(minimal.merge_path.is_none());
assert!(minimal.branch.change_base.is_none());
assert!(minimal.publication.change_url.is_none());
assert!(minimal.notes.is_empty());
assert!(
minimal.publication.draft.is_none(),
"a report about a change nobody drafted holds no reason, and the golden must not \
name the key even as null"
);
}
#[test]
fn a_recorded_draft_reason_round_trips_and_an_absent_one_is_omitted() {
let full: Report = serde_json::from_str(FULL).expect("the full golden");
let held = full
.publication
.draft
.as_ref()
.expect("the full golden's publication is the drafted one");
let DraftReason::AwaitingRelease {
awaiting,
target,
reference,
because,
} = held
else {
panic!("the full golden's draft awaits a release: {held:?}");
};
assert_eq!(awaiting, "github.com/acme-corp/upstream");
assert_eq!(&**target, "crate");
assert_eq!(reference, "feature/the-pinned-branch");
assert_eq!(
because,
"the dependency is pinned to a branch until crate 2.0 is released"
);
held.checked().expect("the golden's reason is a usable one");
assert_eq!(
serde_json::to_value(&full).expect("a report serializes")["publication"]["draft"],
parsed(FULL)["publication"]["draft"],
);
let minimal: Report = serde_json::from_str(MINIMAL).expect("the minimal golden");
let written = serde_json::to_value(&minimal).expect("a report serializes");
assert!(
written["publication"].get("draft").is_none(),
"an absent reason is omitted rather than written: {written}"
);
for (field, unusable) in [
("target", "not a target name"),
("because", ""),
("reference", "feature/two\nlines"),
] {
let mut document = parsed(FULL);
document["publication"]["draft"][field] = Value::from(unusable);
assert!(
serde_json::from_value::<Report>(document).is_err(),
"a {field} no publication could have carried is refused where the document is \
read, rather than rendered as though one had"
);
}
}
#[test]
fn a_report_whose_two_landing_answers_disagree_is_refused_where_it_is_read() {
let in_part = json!({
"state": "in-part",
"evidence": {"tier": "trailer", "commit": "0f1e2d3"},
"unlanded": 2,
});
let landed = json!({"state": "yes", "evidence": {"tier": "trailer", "commit": "0f1e2d3"}});
for (state, landed) in [
("landed", json!({"state": "no"})),
("unpublished", landed.clone()),
("landed-in-part", json!({"state": "unknown"})),
("landed-in-part", landed),
("unpublished", in_part.clone()),
("landed", in_part),
] {
let mut document = parsed(FULL);
document["publication"]["state"] = Value::from(state);
document["publication"]["landed"] = landed;
let refusal = serde_json::from_value::<Report>(document)
.expect_err("two answers to one question are not a report this reads")
.to_string();
assert!(
refusal.contains("two answers to one question"),
"the refusal says what disagreed: {refusal}"
);
}
}
#[test]
fn a_report_declaring_a_version_this_build_does_not_read_is_refused_where_it_is_read() {
for declared in [0, REPORT_VERSION + 1, u32::MAX] {
let mut document = parsed(FULL);
document["version"] = Value::from(declared);
let refusal = serde_json::from_value::<Report>(document)
.expect_err("a version this build does not read is refused")
.to_string();
assert!(
refusal.contains(&format!("declares version {declared}"))
&& refusal.contains(&format!("this build reads version {REPORT_VERSION}")),
"the refusal names neither the version nor the one this build reads: {refusal}"
);
}
let mut document = parsed(FULL);
document["landed"] = Value::Bool(true);
assert!(serde_json::from_value::<Report>(document).is_err());
}
}