use std::collections::HashSet;
use crate::config::{Config, EpicId, InitiativeId, ProjectId};
use crate::epics::{EpicIndex, EpicRollup, ReferenceTargets};
use crate::error::Result;
use crate::health::{
HealthIndex, HealthPolicy, HealthRollup, HealthScope, HealthState, rollup as health_rollup,
};
use crate::model::Issue;
use crate::projects::{ProjectHealthReport, ProjectIndex};
use crate::relationships::{IssueView, RelationshipGraph};
use crate::roadmap::diagnostics::{self, RoadmapDiagnostic, RoadmapDiagnosticKind};
use crate::roadmap::filter::RoadmapFilter;
use crate::roadmap::order::{RoadmapEntry, filter_and_order};
use crate::roadmap::registry::{self, AssociationKind};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoadmapConsumed {
pub targets: ReferenceTargets,
pub done_column: String,
pub assigned: HashSet<u32>,
pub overdue: HashSet<u32>,
pub blocked: HashSet<u32>,
}
impl Default for RoadmapConsumed {
fn default() -> Self {
Self {
targets: ReferenceTargets::default(),
done_column: "done".to_string(),
assigned: HashSet::new(),
overdue: HashSet::new(),
blocked: HashSet::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct RoadmapSources<'a> {
pub issues: &'a [Issue],
pub projects: ProjectIndex,
pub epics: EpicIndex,
pub health: HealthIndex,
pub policy: HealthPolicy,
pub consumed: RoadmapConsumed,
}
impl<'a> RoadmapSources<'a> {
#[must_use]
pub fn new(
config: &Config,
issues: &'a [Issue],
policy: &HealthPolicy,
consumed: RoadmapConsumed,
) -> Self {
let projects = ProjectIndex::build(config, issues);
let epics = EpicIndex::build(config, issues);
let cycles: Vec<String> = config.cycles.keys().cloned().collect();
let mut milestones: Vec<String> = consumed.targets.milestones.iter().cloned().collect();
milestones.sort();
let health = HealthIndex::build(issues, &cycles, &milestones, policy);
Self {
issues,
projects,
epics,
health,
policy: policy.clone(),
consumed,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ReferenceResolution {
Resolved,
Missing,
Ambiguous,
}
impl ReferenceResolution {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Resolved => "resolved",
Self::Missing => "missing",
Self::Ambiguous => "ambiguous",
}
}
#[must_use]
pub const fn is_resolved(self) -> bool {
matches!(self, Self::Resolved)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProgressValue {
Project(Box<ProjectHealthReport>),
Epic(Box<EpicRollup>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SummaryContribution {
pub kind: AssociationKind,
pub target: String,
pub label: String,
pub resolution: ReferenceResolution,
pub progress: Option<ProgressValue>,
pub risk: Option<HealthRollup>,
pub policy_version: Option<String>,
}
impl SummaryContribution {
#[must_use]
pub fn source_entity(&self) -> String {
scope_of(self.kind, &self.target).as_str()
}
#[must_use]
pub fn is_knowable(&self) -> bool {
self.progress.is_some()
|| self
.risk
.as_ref()
.is_some_and(|risk| risk.state != HealthState::Unknown)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum SummaryState {
Complete,
Partial,
Empty,
MissingReference,
Unknown,
}
impl SummaryState {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Complete => "complete",
Self::Partial => "partial",
Self::Empty => "empty",
Self::MissingReference => "missing_reference",
Self::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InitiativeSummary {
pub initiative_id: String,
pub state: SummaryState,
pub contributions: Vec<SummaryContribution>,
pub source_entities: Vec<String>,
pub policy_versions: Vec<String>,
pub diagnostics: Vec<RoadmapDiagnostic>,
}
impl InitiativeSummary {
#[must_use]
pub fn knowable_contributions(&self) -> usize {
self.contributions
.iter()
.filter(|contribution| contribution.is_knowable())
.count()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RowReference {
pub kind: AssociationKind,
pub target: String,
pub label: String,
pub resolution: ReferenceResolution,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoadmapRow {
pub id: String,
pub name: String,
pub outcome: Option<String>,
pub owner: Option<String>,
pub status: Option<String>,
pub priority: Option<crate::model::Priority>,
pub labels: Vec<String>,
pub target_date: Option<String>,
pub summary: InitiativeSummary,
pub references: Vec<RowReference>,
pub diagnostics: Vec<RoadmapDiagnostic>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RoadmapProjection {
pub initiatives: Vec<RoadmapRow>,
pub filters: RoadmapFilter,
pub diagnostics: Vec<RoadmapDiagnostic>,
pub total: usize,
}
pub fn project(
config: &Config,
sources: &RoadmapSources<'_>,
filter: &RoadmapFilter,
) -> Result<RoadmapProjection> {
let entries = filter_and_order(config, filter)?;
let views: Vec<IssueView<'_>> = sources.issues.iter().map(Issue::issue_view).collect();
let graph = RelationshipGraph::new(&views, &sources.consumed.done_column);
let mut rows: Vec<RoadmapRow> = Vec::with_capacity(entries.len());
for entry in &entries {
rows.push(join_row(config, sources, &graph, entry));
}
let mut diagnostics: Vec<RoadmapDiagnostic> = rows
.iter()
.flat_map(|row| row.diagnostics.iter().cloned())
.collect();
diagnostics::order(&mut diagnostics);
let total = rows.len();
Ok(RoadmapProjection {
initiatives: rows,
filters: filter.clone(),
diagnostics,
total,
})
}
fn join_row(
config: &Config,
sources: &RoadmapSources<'_>,
graph: &RelationshipGraph<'_>,
entry: &RoadmapEntry<'_>,
) -> RoadmapRow {
let record = entry.initiative;
let mut contributions: Vec<SummaryContribution> = Vec::new();
let mut references: Vec<RowReference> = Vec::new();
let mut findings: Vec<RoadmapDiagnostic> = Vec::new();
let associations = InitiativeId::new(entry.id)
.map(|id| registry::associations(config, &id))
.unwrap_or_default();
for association in &associations {
let (contribution, mut diagnostics) = match association.kind {
AssociationKind::Project => {
join_project(config, sources, entry.id, &association.target)
}
AssociationKind::Epic => {
join_epic(config, sources, graph, entry.id, &association.target)
}
AssociationKind::Milestone => join_milestone(sources, entry.id, &association.target),
};
references.push(RowReference {
kind: contribution.kind,
target: contribution.target.clone(),
label: contribution.label.clone(),
resolution: contribution.resolution,
});
contributions.push(contribution);
findings.append(&mut diagnostics);
}
let state = summary_state(&contributions);
match state {
SummaryState::Empty => findings.push(
RoadmapDiagnostic::new(
RoadmapDiagnosticKind::EmptyScope,
format!(
"initiative `{}` records no project, epic or milestone reference",
entry.id
),
)
.for_initiative(entry.id),
),
SummaryState::Unknown => findings.push(
RoadmapDiagnostic::new(
RoadmapDiagnosticKind::UnknownValue,
format!(
"initiative `{}` resolves every reference, but no source reports a value",
entry.id
),
)
.for_initiative(entry.id),
),
_ => {}
}
if entry.target_date.is_undated() {
findings.push(
RoadmapDiagnostic::new(
RoadmapDiagnosticKind::Undated,
format!("initiative `{}` has no target date", entry.id),
)
.for_initiative(entry.id)
.at_field("target_date"),
);
}
diagnostics::order(&mut findings);
let source_entities: Vec<String> = contributions
.iter()
.filter(|contribution| contribution.resolution.is_resolved())
.map(SummaryContribution::source_entity)
.collect();
let policy_versions = ordered_policy_versions(&contributions);
let summary = InitiativeSummary {
initiative_id: entry.id.to_string(),
state,
contributions,
source_entities,
policy_versions,
diagnostics: findings.clone(),
};
RoadmapRow {
id: entry.id.to_string(),
name: record.name.clone(),
outcome: record.outcome.clone(),
owner: record.owner.clone(),
status: record.status.clone(),
priority: record.priority.clone(),
labels: record.labels.clone(),
target_date: record.target_date.clone(),
summary,
references,
diagnostics: findings,
}
}
fn ordered_policy_versions(contributions: &[SummaryContribution]) -> Vec<String> {
let mut versions: Vec<String> = Vec::new();
for contribution in contributions {
if let Some(version) = &contribution.policy_version {
if !versions.iter().any(|seen| seen == version) {
versions.push(version.clone());
}
}
}
versions
}
fn summary_state(contributions: &[SummaryContribution]) -> SummaryState {
if contributions.is_empty() {
return SummaryState::Empty;
}
let resolved = contributions
.iter()
.filter(|contribution| contribution.resolution.is_resolved())
.count();
if resolved == 0 {
return SummaryState::MissingReference;
}
if resolved < contributions.len() {
return SummaryState::Partial;
}
if contributions.iter().any(SummaryContribution::is_knowable) {
SummaryState::Complete
} else {
SummaryState::Unknown
}
}
fn join_project(
config: &Config,
sources: &RoadmapSources<'_>,
initiative: &str,
target: &str,
) -> (SummaryContribution, Vec<RoadmapDiagnostic>) {
let resolved = ProjectId::new(target)
.ok()
.and_then(|id| sources.projects.project(&id));
let Some(project) = resolved else {
return missing_contribution(AssociationKind::Project, target, initiative);
};
let scope = HealthScope::Project(project.id.to_string());
match sources.projects.health(
config,
project.id.as_str(),
&sources.consumed.assigned,
&sources.consumed.overdue,
&sources.consumed.blocked,
) {
Ok(report) => {
let risk = health_rollup(&sources.health, &scope, &sources.policy);
let mut diagnostics = source_diagnostics(&scope, &risk);
for missing in &report.health.missing_references {
diagnostics.push(
RoadmapDiagnostic::new(
RoadmapDiagnosticKind::SourceDiagnostic,
&missing.reason,
)
.about_source(scope.as_str())
.at_field(AssociationKind::Project.field())
.referencing(&missing.project_id),
);
}
(
resolved_contribution(
AssociationKind::Project,
target,
project.name.clone(),
Some(ProgressValue::Project(Box::new(report))),
Some(&risk),
),
diagnostics,
)
}
Err(err) => unresolvable_contribution(
AssociationKind::Project,
target,
project.name.clone(),
&err.to_string(),
initiative,
),
}
}
fn join_epic(
config: &Config,
sources: &RoadmapSources<'_>,
graph: &RelationshipGraph<'_>,
initiative: &str,
target: &str,
) -> (SummaryContribution, Vec<RoadmapDiagnostic>) {
let resolved = EpicId::new(target)
.ok()
.and_then(|id| sources.epics.epic(&id));
let Some(epic) = resolved else {
return missing_contribution(AssociationKind::Epic, target, initiative);
};
let scope = HealthScope::Epic(epic.id.to_string());
match sources
.epics
.rollup(config, target, &sources.consumed.targets, graph)
{
Ok(rollup) => {
let risk = health_rollup(&sources.health, &scope, &sources.policy);
let mut diagnostics = source_diagnostics(&scope, &risk);
for missing in &rollup.missing_references {
diagnostics.push(
RoadmapDiagnostic::new(
RoadmapDiagnosticKind::SourceDiagnostic,
&missing.reason,
)
.about_source(scope.as_str())
.at_field(AssociationKind::Epic.field())
.referencing(&missing.target),
);
}
(
resolved_contribution(
AssociationKind::Epic,
target,
epic.name.clone(),
Some(ProgressValue::Epic(Box::new(rollup))),
Some(&risk),
),
diagnostics,
)
}
Err(err) => unresolvable_contribution(
AssociationKind::Epic,
target,
epic.name.clone(),
&err.to_string(),
initiative,
),
}
}
fn join_milestone(
sources: &RoadmapSources<'_>,
initiative: &str,
target: &str,
) -> (SummaryContribution, Vec<RoadmapDiagnostic>) {
if !sources.consumed.targets.milestone_known(target) {
return missing_contribution(AssociationKind::Milestone, target, initiative);
}
let scope = HealthScope::Milestone(target.to_string());
let risk = health_rollup(&sources.health, &scope, &sources.policy);
let diagnostics = source_diagnostics(&scope, &risk);
(
resolved_contribution(
AssociationKind::Milestone,
target,
target.to_string(),
None,
Some(&risk),
),
diagnostics,
)
}
fn resolved_contribution(
kind: AssociationKind,
target: &str,
label: String,
progress: Option<ProgressValue>,
risk: Option<&HealthRollup>,
) -> SummaryContribution {
SummaryContribution {
kind,
target: target.to_string(),
label,
resolution: ReferenceResolution::Resolved,
progress,
risk: risk.cloned(),
policy_version: risk.map(|risk| risk.policy_version.clone()),
}
}
fn missing_contribution(
kind: AssociationKind,
target: &str,
initiative: &str,
) -> (SummaryContribution, Vec<RoadmapDiagnostic>) {
let contribution = SummaryContribution {
kind,
target: target.to_string(),
label: target.to_string(),
resolution: ReferenceResolution::Missing,
progress: None,
risk: None,
policy_version: None,
};
let diagnostic = RoadmapDiagnostic::new(
RoadmapDiagnosticKind::MissingReference,
format!(
"`{}`: reference `{target}` does not resolve in the registry",
kind.field()
),
)
.at_field(kind.field())
.referencing(target);
let diagnostic = if initiative.is_empty() {
diagnostic
} else {
diagnostic.for_initiative(initiative)
};
(contribution, vec![diagnostic])
}
fn unresolvable_contribution(
kind: AssociationKind,
target: &str,
label: String,
reason: &str,
initiative: &str,
) -> (SummaryContribution, Vec<RoadmapDiagnostic>) {
let contribution = SummaryContribution {
kind,
target: target.to_string(),
label,
resolution: ReferenceResolution::Resolved,
progress: None,
risk: None,
policy_version: None,
};
let diagnostic = RoadmapDiagnostic::new(
RoadmapDiagnosticKind::UnresolvableRollup,
format!(
"`{}`: `{target}` resolved but its value could not be derived: {reason}",
kind.field()
),
)
.at_field(kind.field())
.referencing(target)
.about_source(scope_of(kind, target).as_str());
let diagnostic = if initiative.is_empty() {
diagnostic
} else {
diagnostic.for_initiative(initiative)
};
(contribution, vec![diagnostic])
}
fn source_diagnostics(scope: &HealthScope, risk: &HealthRollup) -> Vec<RoadmapDiagnostic> {
risk.diagnostics
.iter()
.map(|finding| {
let mut diagnostic =
RoadmapDiagnostic::new(RoadmapDiagnosticKind::SourceDiagnostic, &finding.message)
.about_source(scope.as_str());
if let Some(reference) = &finding.reference {
diagnostic = diagnostic.referencing(reference);
}
match &finding.field {
Some(field) => diagnostic.at_field(field),
None => diagnostic,
}
})
.collect()
}
fn scope_of(kind: AssociationKind, target: &str) -> HealthScope {
match kind {
AssociationKind::Project => HealthScope::Project(target.to_string()),
AssociationKind::Epic => HealthScope::Epic(target.to_string()),
AssociationKind::Milestone => HealthScope::Milestone(target.to_string()),
}
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::planning::Progress;
use crate::projects::{ProjectHealth, ProjectProgress};
fn contribution(kind: AssociationKind, resolution: ReferenceResolution) -> SummaryContribution {
SummaryContribution {
kind,
target: "x".to_string(),
label: "X".to_string(),
resolution,
progress: None,
risk: None,
policy_version: None,
}
}
#[test]
fn summary_state_is_exhaustive_and_never_conflates_zero_with_absence() {
assert_eq!(summary_state(&[]), SummaryState::Empty);
let missing = vec![contribution(
AssociationKind::Project,
ReferenceResolution::Missing,
)];
assert_eq!(summary_state(&missing), SummaryState::MissingReference);
let partial = vec![
contribution(AssociationKind::Project, ReferenceResolution::Resolved),
contribution(AssociationKind::Epic, ReferenceResolution::Missing),
];
assert_eq!(summary_state(&partial), SummaryState::Partial);
let unknown = vec![contribution(
AssociationKind::Milestone,
ReferenceResolution::Resolved,
)];
assert_eq!(summary_state(&unknown), SummaryState::Unknown);
let zero = SummaryContribution {
kind: AssociationKind::Project,
target: "p-zero".to_string(),
label: "Zero".to_string(),
resolution: ReferenceResolution::Resolved,
progress: Some(ProgressValue::Project(Box::new(ProjectHealthReport {
project_id: ProjectId::new("p-zero").expect("a valid id"),
name: "Zero".to_string(),
progress: ProjectProgress {
done: 0,
total: 0,
progress: Progress { done: 0, total: 0 },
},
health: ProjectHealth {
overdue: 0,
blocked: 0,
unassigned: 0,
missing_references: Vec::new(),
},
}))),
risk: None,
policy_version: None,
};
assert!(zero.is_knowable());
assert_eq!(summary_state(&[zero]), SummaryState::Complete);
}
#[test]
fn ordered_policy_versions_dedups_preserving_first_seen_order() {
let mut a = contribution(AssociationKind::Milestone, ReferenceResolution::Resolved);
a.policy_version = Some("health-v1".to_string());
let mut b = contribution(AssociationKind::Milestone, ReferenceResolution::Resolved);
b.policy_version = Some("health-v1".to_string());
let versions = ordered_policy_versions(&[a, b]);
assert_eq!(versions, vec!["health-v1".to_string()]);
}
#[test]
fn scope_spelling_is_stable_per_kind() {
assert_eq!(
scope_of(AssociationKind::Project, "p-1").as_str(),
"project:p-1"
);
assert_eq!(scope_of(AssociationKind::Epic, "e-1").as_str(), "epic:e-1");
assert_eq!(
scope_of(AssociationKind::Milestone, "m1").as_str(),
"milestone:m1"
);
}
}