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::{ChangeId, CheckSource, Hosting};
use crate::landed::{self, Landed};
use crate::preserve::{ALREADY_ON_ORIGIN, PUSHED};
use crate::publish::{self, DraftReason};
use crate::registry::Registry;
use crate::releases::TargetName;
use crate::rules::{Approvals, MergePolicy};
use crate::session::{Lifecycle, Liveness, OnOrigin, 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 = 9;
#[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,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired: Option<RetiredReport>,
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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_origin: Option<OnOrigin>,
}
#[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)]
#[serde(try_from = "AnyRetired")]
pub struct RetiredReport {
class: crate::retire::RetirementClass,
#[serde(skip_serializing_if = "Option::is_none")]
proof: Option<crate::retire::RetirementProof>,
trigger: crate::retire::Trigger,
mode: crate::retire::Acting,
at: Stamp,
tip: crate::host::Sha,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AnyRetired {
class: crate::retire::RetirementClass,
#[serde(default)]
proof: Option<crate::retire::RetirementProof>,
trigger: crate::retire::Trigger,
mode: crate::retire::Acting,
at: Stamp,
tip: crate::host::Sha,
}
impl TryFrom<AnyRetired> for RetiredReport {
type Error = String;
fn try_from(any: AnyRetired) -> std::result::Result<Self, Self::Error> {
let retirable = any.class == crate::retire::RetirementClass::Retirable;
if !any.mode.permits(any.class) || retirable != any.proof.is_some() {
return Err(format!(
"a retirement as {} under {} {} a proof, which no retirement is",
any.class.as_str(),
any.mode.as_str(),
if any.proof.is_some() {
"with"
} else {
"without"
},
));
}
Ok(RetiredReport {
class: any.class,
proof: any.proof,
trigger: any.trigger,
mode: any.mode,
at: any.at,
tip: any.tip,
})
}
}
impl RetiredReport {
fn of(record: &crate::retire::RetiredRecord) -> Option<Self> {
Some(RetiredReport {
class: record.class(),
proof: record.proof().cloned(),
trigger: record.trigger(),
mode: record.mode(),
at: Stamp::try_from(record.at().to_owned()).ok()?,
tip: record.tip().clone(),
})
}
fn landed(&self, on_base: Option<String>) -> Option<Landed> {
let proof = self.proof.clone()?;
(self.class == crate::retire::RetirementClass::Retirable).then(|| Landed::Yes {
evidence: landed::LandingEvidence::Retired {
commit: crate::host::Sha(on_base.unwrap_or_else(|| proof.commit().to_owned())),
proof,
},
})
}
}
fn landed_on_base(
resolution: &Resolution,
base: &Ref,
streams: &[Recorded],
work: &Work,
session: Option<&str>,
proof: Option<&crate::retire::RetirementProof>,
) -> Option<String> {
let publication = resolution.publication.as_path();
let tip = vcs::base_ref(publication, base);
let recorded = recorded_for(streams, &work.identity, &work.branch, session);
if let Some(landing) = recorded.landing {
if git::known_to_reach(publication, landing.as_str(), &tip).unwrap_or(false) {
return Some(landing.as_str().to_owned());
}
}
let mentioning = |needle: &str| git::first_commit_mentioning(publication, &tip, needle);
match proof? {
crate::retire::RetirementProof::RecordedLanding { commit } => mentioning(&commit.0),
crate::retire::RetirementProof::MergedChangeRequest { change_url, .. } => {
let number = change_url
.as_str()
.trim_end_matches('/')
.rsplit('/')
.next()?;
mentioning(&format!("(#{number})")).or_else(|| mentioning(change_url.as_str()))
}
crate::retire::RetirementProof::ContentIdentical { base_commit } => {
Some(base_commit.0.clone())
}
}
}
fn retired_now(streams: &[Recorded], work: &Work, holders: &[Holder]) -> Option<RetiredReport> {
if !holders.is_empty() {
return None;
}
retirement_of(streams, &work.identity, &work.branch)
.as_ref()
.and_then(RetiredReport::of)
}
#[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(Debug, Clone, PartialEq, Eq)]
struct PreservedRecord {
branch: Ref,
on_origin: OnOrigin,
}
#[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>,
on_origin: Option<OnOrigin>,
asked_the_host_to_land: bool,
merge_path: Option<MergePathReport>,
recorded: landed::Recorded,
opened: Option<Opened>,
change_stream: Option<String>,
}
#[derive(Debug, Clone)]
struct Opened {
url: String,
id: Option<String>,
base: Option<String>,
}
fn from_streams(streams: &[Recorded], work: &Work, session: Option<&str>) -> Told {
let relevant = relevant_streams(streams, &work.identity, &work.branch, session);
let opened = latest(relevant.iter().filter_map(|record| record.opened.clone()));
let change_url = opened.as_ref().map(|opened| opened.url.clone());
Told {
draft: standing_draft(&relevant),
described: latest(
relevant
.iter()
.filter_map(|record| record.described.clone()),
),
on_origin: preserved_in(&relevant, &work.branch),
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,
opened,
change_stream: newest(relevant.iter().filter_map(|record| {
record.opened.as_ref().map(|opened| Stamped {
at: opened.at.clone(),
value: record.token.clone(),
})
}))
.map(|stamped| stamped.value),
}
}
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()
}
struct Judging<'a> {
resolution: &'a Resolution,
lent: Option<&'a Path>,
base: &'a Ref,
work: &'a Work,
recorded: &'a landed::Recorded,
trailers: &'a provenance::Trailers,
sessions: &'a [workspace::Record],
}
fn judge(
holders: &[&Holder],
under: &Judging<'_>,
) -> (Vec<(PathBuf, String, Landed)>, Vec<String>) {
let current = vcs::base_commit(&under.resolution.publication, under.base);
let mut judged = Vec::new();
let mut unread = Vec::new();
for holder in holders {
let asked = git::Asked::borrowing(&holder.path, under.lent);
let compared = vcs::judged_against(asked, under.base, current.as_ref());
match landed::decide(
asked,
&compared,
current.as_ref(),
&under.work.branch,
under.recorded,
under.trailers,
) {
Ok(verdict) => judged.push((holder.path.clone(), compared, verdict)),
Err(failure) => unread.push(unreadable(
&holder.path,
&failure.to_string(),
under.sessions,
)),
}
}
(judged, unread)
}
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,
})
}
fn reconcile_late_merge(
registry: &Registry,
work: &Work,
told: &mut Told,
carrying: &[&Holder],
hosting: &dyn Hosting,
) {
if told.recorded.landing.is_some() || !told.asked_the_host_to_land {
return;
}
let (Some(opened), Some(url)) = (told.opened.as_ref(), told.recorded.change.as_ref()) else {
return;
};
let (Some(id), Some(base)) = (opened.id.as_deref(), opened.base.as_deref()) else {
return;
};
let Some(head) = carrying
.iter()
.find_map(|holder| git::tip(&holder.path, &work.branch))
else {
return;
};
let merged = publish::reconcile_late_merge(
registry,
&publish::Watched {
identity: &work.identity,
branch: &work.branch,
url,
id,
base,
head: &head,
stream: told.change_stream.as_deref(),
},
hosting,
);
told.recorded.landing = merged.as_deref().and_then(ObjectId::parse);
}
pub(crate) struct LandingOf {
pub reference: String,
pub identity: String,
pub branch: Ref,
pub landed: Landed,
pub carrier: Option<PathBuf>,
pub lent: Option<PathBuf>,
pub change_stream: Option<String>,
pub unreadable: Vec<String>,
}
pub(crate) fn landing_of(
registry: &Registry,
reference: &str,
hosting: Option<&dyn Hosting>,
) -> Result<LandingOf> {
landing_of_within(registry, reference, None, hosting)
}
pub(crate) fn landing_of_within(
registry: &Registry,
reference: &str,
repo: Option<&str>,
hosting: Option<&dyn Hosting>,
) -> 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 mut unreadable = Vec::new();
let (work, _) = resolve(registry, reference, &streams, within, &mut unreadable)?;
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 sessions = workspace::all()?;
let holders = holders_of(registry, &resolution, &work.branch, &sessions)?;
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 mut told = from_streams(&streams, &work, held_by.as_deref());
let change_stream = told.change_stream.clone();
let carrying = carrying(&holders, &answering.superseded);
if let Some(hosting) = hosting {
reconcile_late_merge(registry, &work, &mut told, &carrying, hosting);
}
let (judged, unread) = judge(
&carrying,
&Judging {
resolution: &resolution,
lent: lent.as_deref(),
base: &base,
work: &work,
recorded: &told.recorded,
trailers: &trailers,
sessions: &sessions,
},
);
unreadable.extend(unread);
let carrier = carrier_of(&judged);
let retired = retired_now(&streams, &work, &holders).and_then(|report| {
let session = answering
.session
.as_ref()
.map(|record| record.token.to_string());
let on_base = landed_on_base(
&resolution,
&base,
&streams,
&work,
session.as_deref(),
report.proof.as_ref(),
);
report.landed(on_base)
});
let decided = carrier.map(|(_, _, verdict)| verdict.clone());
let carrier = match (carrier, &retired) {
(Some((repo, _, _)), _) => Some(repo.clone()),
(None, Some(_)) => Some(resolution.publication.clone()),
(None, None) => None,
};
Ok(LandingOf {
reference: reference.to_owned(),
identity: work.identity.clone(),
branch: work.branch.clone(),
landed: match answering.broken.is_some() {
true => Landed::Unknown,
false => decided.or(retired).unwrap_or(Landed::Unknown),
},
carrier,
lent,
change_stream,
unreadable,
})
}
pub fn run(registry: &Registry, reference: &str, hosting: &dyn Hosting) -> Result<Report> {
git::memoized(|| reported(registry, reference, hosting))
}
fn reported(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, &mut notes)?;
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 sessions = workspace::all()?;
let holders = holders_of(registry, &resolution, &work.branch, &sessions)?;
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 mut told = from_streams(&streams, &work, held_by.as_deref());
let carrying = carrying(&holders, &answering.superseded);
reconcile_late_merge(registry, &work, &mut told, &carrying, hosting);
let change_url = told.change_url.clone();
let draft = told.draft.clone();
let described = told.described.clone();
let on_origin = told.on_origin.clone();
let asked_the_host_to_land = told.asked_the_host_to_land;
let merge_path = told.merge_path.clone();
let (judged, unread) = judge(
&carrying,
&Judging {
resolution: &resolution,
lent: lent.as_deref(),
base: &base,
work: &work,
recorded: &told.recorded,
trailers: &trailers,
sessions: &sessions,
},
);
notes.extend(unread);
let carrier = carrier_of(&judged);
let retired = retired_now(&streams, &work, &holders);
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 verdict.is_none() {
verdict = retired.as_ref().and_then(|report| {
let session = answering
.session
.as_ref()
.map(|record| record.token.to_string());
report.landed(landed_on_base(
&resolution,
&base,
&streams,
&work,
session.as_deref(),
report.proof.as_ref(),
))
});
}
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,
on_origin,
},
publication: PublicationReport {
state,
landed,
change_url,
draft,
held_as_draft,
described,
merge_policy: resolved.policy.publication,
},
retired,
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,
sessions: &[workspace::Record],
) -> Result<Vec<Holder>> {
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>,
opened: Option<Stamped<Opened>>,
landing: Option<Stamped<ObjectId>>,
draft: Option<Stamped<DraftReason>>,
lifted: Option<Stamp>,
described: Option<Stamped<DescribedReport>>,
on_origin: Option<Stamped<PreservedRecord>>,
asked_the_host_to_land: bool,
merge_path: Option<Stamped<MergePathReport>>,
pushed_heads: Vec<(Ref, ObjectId)>,
superseded: Vec<Stamped<crate::retire::SupersessionRecord>>,
retired: Option<Stamped<crate::retire::RetiredRecord>>,
gaps: bool,
}
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>> {
recorded_streams_whole(notes).map(|(streams, _)| streams)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Listing {
Whole,
Partial,
}
pub(crate) fn recorded_streams_whole(notes: &mut Vec<String>) -> Result<(Vec<Recorded>, Listing)> {
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(), Listing::Whole))
}
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(), Listing::Partial));
}
};
let listed = notes.len();
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();
let listing = match notes.len() == listed {
true => Listing::Whole,
false => Listing::Partial,
};
let streams = tokens
.into_iter()
.map(|token| {
let before = notes.len();
let mut record = read_stream(&directory, &token, notes);
record.gaps = notes.len() > before;
record
})
.collect();
Ok((streams, listing))
}
fn read_stream(directory: &Path, token: &str, notes: &mut Vec<String>) -> Recorded {
let mut record = Recorded {
token: token.to_owned(),
identity: None,
branch: None,
opened: None,
landing: None,
draft: None,
lifted: None,
described: None,
on_origin: None,
asked_the_host_to_land: false,
merge_path: None,
pushed_heads: Vec::new(),
superseded: Vec::new(),
retired: None,
gaps: false,
};
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(known) => (known.envelope.v, known.envelope.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(known) = read else {
continue;
};
let (kind, event) = (known.kind, known.envelope);
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 kind {
EventKind::ChangeOpened => {
if let Some(url) = field("url") {
record.opened = Some(Stamped {
at,
value: Opened {
url,
id: field("id"),
base: field("base"),
},
});
}
}
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::BranchPreserved => {
let reached_the_origin = matches!(
field("outcome").as_deref(),
Some(PUSHED | ALREADY_ON_ORIGIN)
);
if reached_the_origin {
if let (Some(branch), Some(remote), Some(commit)) = (
field("branch").and_then(|name| Ref::try_from(name).ok()),
field("remote").filter(|remote| git::is_usable_remote(remote)),
field("commit").as_deref().and_then(ObjectId::parse),
) {
record.on_origin = Some(Stamped {
at,
value: PreservedRecord {
branch,
on_origin: OnOrigin {
remote,
commit: commit.as_str().to_owned(),
},
},
});
}
}
}
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 => {
if let (Some(branch), Some(head)) = (
field("branch").and_then(|name| Ref::try_from(name).ok()),
field("head").as_deref().and_then(ObjectId::parse),
) {
if event.payload.get("accepted").and_then(Value::as_bool) == Some(true) {
record.pushed_heads.push((branch, head));
}
}
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(),
},
});
}
EventKind::BranchSuperseded => {
if let Some(read) = crate::retire::SupersessionRecord::read(&event.payload) {
record.superseded.push(Stamped { at, value: read });
}
}
EventKind::BranchRetired => {
if let Some(read) = crate::retire::RetiredRecord::read(&event.payload, &at) {
record.retired = Some(Stamped { at, value: read });
}
}
_ => {}
}
}
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}")),
Some(crate::preserve::preserve_token(branch)),
]
.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.opened.clone()))
.and_then(|opened| Url::parse(&opened.url).ok()),
}
}
fn preserved_in(relevant: &[&Recorded], branch: &str) -> Option<OnOrigin> {
latest(
relevant
.iter()
.filter_map(|record| record.on_origin.clone())
.filter(|stamped| *stamped.value.branch == *branch),
)
.map(|record| record.on_origin)
}
pub(crate) fn preserved_for(
streams: &[Recorded],
identity: &str,
branch: &str,
session: Option<&str>,
) -> Option<OnOrigin> {
preserved_in(
&relevant_streams(streams, identity, branch, session),
branch,
)
}
pub(crate) fn preserved_branches(streams: &[Recorded], identity: &str) -> BTreeSet<String> {
streams
.iter()
.filter(|record| record.identity.as_deref() == Some(identity))
.filter_map(|record| record.on_origin.as_ref())
.map(|stamped| stamped.value.branch.to_string())
.collect()
}
pub(crate) fn pushed_heads(
streams: &[Recorded],
identity: &str,
branch: &str,
session: Option<&str>,
) -> Vec<ObjectId> {
let mut heads: Vec<ObjectId> = Vec::new();
for record in relevant_streams(streams, identity, branch, session) {
for (pushed, head) in &record.pushed_heads {
if **pushed == *branch && !heads.contains(head) {
heads.push(head.clone());
}
}
}
heads
}
pub(crate) fn supersessions(
streams: &[Recorded],
identity: &str,
branch: &str,
) -> Vec<crate::retire::SupersessionRecord> {
let mut found: Vec<Stamped<crate::retire::SupersessionRecord>> =
relevant_streams(streams, identity, branch, None)
.into_iter()
.flat_map(|record| record.superseded.iter().cloned())
.filter(|stamped| stamped.value.names(identity, branch))
.collect();
found.sort_by(|left, right| left.at.cmp(&right.at));
found.into_iter().map(|stamped| stamped.value).collect()
}
pub(crate) fn retirement_of(
streams: &[Recorded],
identity: &str,
branch: &str,
) -> Option<crate::retire::RetiredRecord> {
latest(
relevant_streams(streams, identity, branch, None)
.into_iter()
.filter_map(|record| record.retired.clone())
.filter(|stamped| stamped.value.names(identity, branch)),
)
}
fn retired_under(streams: &[Recorded], branch: &Ref, within: Option<&str>) -> Vec<Work> {
let mut found: Vec<Work> = Vec::new();
for record in streams {
let Some(retired) = &record.retired else {
continue;
};
let identity = retired.value.identity();
if retired.value.branch() != &**branch || within.is_some_and(|key| key != identity) {
continue;
}
if !found.iter().any(|work| work.identity == identity) {
found.push(Work {
identity: identity.to_owned(),
branch: branch.clone(),
});
}
}
found
}
pub(crate) struct OpenedChange {
pub url: Url,
pub id: Option<ChangeId>,
pub base: Option<Ref>,
pub stream: Option<Token>,
}
pub(crate) fn opened_change(
streams: &[Recorded],
identity: &str,
branch: &str,
session: Option<&str>,
) -> Option<OpenedChange> {
let told = from_streams(
streams,
&Work {
identity: identity.to_owned(),
branch: Ref::from_git(branch),
},
session,
);
let opened = told.opened?;
Some(OpenedChange {
url: Url::parse(&opened.url).ok()?,
id: opened.id.filter(|id| !id.trim().is_empty()).map(ChangeId),
base: opened.base.and_then(|base| Ref::try_from(base).ok()),
stream: told
.change_stream
.and_then(|stream| Token::try_from(stream).ok()),
})
}
pub(crate) fn recorded_digest(
streams: &[Recorded],
identity: &str,
branch: &str,
session: Option<&str>,
) -> Option<String> {
let relevant = relevant_streams(streams, identity, branch, session);
let unattributed = streams
.iter()
.any(|record| record.gaps && (record.identity.is_none() || record.branch.is_none()));
if unattributed || relevant.iter().any(|record| record.gaps) {
return None;
}
Some(crate::ids::digest(&format!("{relevant:?}")))
}
pub(crate) fn recorded_branches(streams: &[Recorded], identity: &str) -> BTreeSet<String> {
let mut named = BTreeSet::new();
for record in streams {
if record.identity.as_deref() != Some(identity) {
continue;
}
named.extend(record.branch.clone());
named.extend(
record
.superseded
.iter()
.map(|stamped| stamped.value.branch().to_owned()),
);
}
named
}
fn resolve(
registry: &Registry,
reference: &str,
streams: &[Recorded],
within: Option<&str>,
notes: &mut Vec<String>,
) -> 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));
}
let retired = retired_under(streams, &named, within);
if !retired.is_empty() {
return one(retired, 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, notes)?;
if !found.is_empty() {
return one(found, reference, "commit").map(|work| (work, RefKind::Commit));
}
}
Err(Error::UnresolvableReference {
reference: reference.to_owned(),
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
.opened
.as_ref()
.is_some_and(|recorded| recorded.value.url == url)
})
.ok_or_else(|| Error::UnresolvableReference {
reference: url.to_owned(),
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::UnresolvableReference {
reference: url.to_owned(),
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>,
notes: &mut Vec<String>,
) -> Result<Vec<Work>> {
let mut found: Vec<Work> = Vec::new();
let sessions = workspace::all()?;
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;
}
let branches = match git::branches(&path) {
Ok(branches) => branches,
Err(failure) => {
notes.push(unreadable(&path, &failure.to_string(), &sessions));
continue;
}
};
for branch in branches {
if root.as_deref() == Some(branch.as_str()) {
continue;
}
match git::is_ancestor(&path, commit, &branch) {
Ok(true) => {}
Ok(false) => continue,
Err(failure) => {
notes.push(unreadable(&path, &failure.to_string(), &sessions));
break;
}
}
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 unreadable(path: &Path, said: &str, sessions: &[workspace::Record]) -> String {
let session = sessions
.iter()
.find(|record| record.clone == path)
.map(|record| record.token.to_string());
let whose = match session {
Some(token) => format!("the run clone of session {token} at {}", path.display()),
None => format!("the copy at {}", path.display()),
};
format!(
"{whose} could not be read by git ({said}), so nothing it holds is in this answer; \
every other copy of this identity's work was still read"
)
}
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",
}
));
match &self.branch.on_origin {
Some(on_origin) => out.push_str(&format!(
" on origin: {} at {} — preserved, not published\n",
on_origin.remote, on_origin.commit,
)),
None => out.push_str(" on origin: not preserved\n"),
}
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()));
}
}
if let Some(retired) = &self.retired {
out.push_str(&format!(
"retired: {class} — {proof}; by {trigger} ({mode}) at {at}, from {tip}\n",
class = retired.class.as_str(),
proof = retired
.proof
.as_ref()
.map(crate::retire::RetirementProof::describe)
.unwrap_or_else(|| "reclaimed, its differences discarded".to_owned()),
trigger = retired.trigger.as_str(),
mode = retired.mode.as_str(),
at = String::from(retired.at.clone()),
tip = retired.tip.0,
));
}
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-v9.json");
const MINIMAL: &str = include_str!("../tests/golden/status-report-v9-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"
);
let on_origin = full
.branch
.on_origin
.expect("the full golden names a preserved branch");
assert!(on_origin.remote.ends_with("hosted.git"));
assert_eq!(on_origin.commit.len(), 40);
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.branch.on_origin.is_none(),
"a report about a branch nothing preserved holds no origin, and the golden must \
not name the key even as null"
);
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());
}
#[test]
fn a_retirement_reads_back_only_where_its_class_its_proof_and_its_mode_agree() {
let sha = "0123456789abcdef0123456789abcdef01234567";
let proof = json!({"kind": "recorded-landing", "commit": sha});
let retired = |class: &str, proof: Option<&Value>, mode: &str| {
let mut document = parsed(FULL);
document["retired"] = json!({
"class": class,
"trigger": "verb",
"mode": mode,
"at": "2026-01-01T00:00:00.000Z",
"tip": sha,
});
if let Some(proof) = proof {
document["retired"]["proof"] = proof.clone();
}
document
};
for document in [
retired("retirable", Some(&proof), "retire"),
retired("superseded-with-changes", None, "reclaim"),
] {
let report: Report =
serde_json::from_value(document.clone()).expect("an agreeing retirement reads");
assert_eq!(
serde_json::to_value(&report).expect("a report serializes"),
document
);
}
for document in [
retired("retirable", None, "retire"),
retired("superseded-with-changes", Some(&proof), "reclaim"),
retired("superseded-with-changes", None, "retire"),
retired("keep", None, "reclaim"),
] {
let refusal = serde_json::from_value::<Report>(document)
.expect_err("a retirement whose fields disagree is refused")
.to_string();
assert!(refusal.contains("which no retirement is"), "{refusal}");
}
}
}