#[allow(clippy::wildcard_imports)]
use super::*;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicMembership {
pub epic_id: EpicId,
pub members: Vec<MemberRef>,
pub member_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicIndex {
epics: IndexMap<EpicId, Epic>,
buckets: HashMap<EpicId, Vec<IssueKey>>,
parents: HashMap<EpicId, EpicId>,
children: HashMap<EpicId, Vec<EpicId>>,
missing: Vec<EpicReferenceResolution>,
labels: HashMap<IssueKey, String>,
statuses: HashMap<IssueKey, String>,
}
impl EpicIndex {
pub fn build<N: EpicNode>(config: &Config, issues: &[N]) -> Self {
Self::build_from_registry(&config.epics, issues)
}
pub fn build_from_registry<N: EpicNode>(
registry: &IndexMap<String, EpicConfiguration>,
issues: &[N],
) -> Self {
let known: HashSet<String> = registry.keys().cloned().collect();
let mut labels: HashMap<IssueKey, String> = HashMap::new();
let mut statuses: HashMap<IssueKey, String> = HashMap::new();
let mut buckets: HashMap<EpicId, Vec<IssueKey>> = HashMap::new();
let mut missing: Vec<EpicReferenceResolution> = Vec::new();
for issue in issues {
let key = IssueKey::new(issue.number());
labels.insert(
key,
crate::hierarchy::display_label(issue.number(), issue.title()),
);
statuses.insert(key, issue.status().to_string());
let mut seen: HashSet<&str> = HashSet::new();
for raw in issue.epic_ids() {
if !seen.insert(raw.as_str()) {
continue;
}
let holder = ReferenceHolder::Issue(key);
match EpicId::new(raw) {
Ok(id) if known.contains(id.as_str()) => {
buckets.entry(id).or_default().push(key);
}
Ok(_) => missing.push(EpicReferenceResolution {
holder: holder.clone(),
target_kind: EpicReferenceKind::Epic,
target: raw.clone(),
reason: missing_epic_reason(&holder, raw),
}),
Err(_) => missing.push(EpicReferenceResolution {
holder,
target_kind: EpicReferenceKind::Issue,
target: raw.clone(),
reason: malformed_epic_reason(issue.number(), raw),
}),
}
}
}
for members in buckets.values_mut() {
members.sort_unstable();
}
let mut children: HashMap<EpicId, Vec<EpicId>> = HashMap::new();
let mut ordered: Vec<(EpicId, EpicConfiguration)> = Vec::new();
let parents: HashMap<EpicId, EpicId> = resolved_parents(registry);
for (raw, record) in registry {
let Ok(id) = EpicId::new(raw) else {
continue;
};
if let Some(parent) = parents.get(&id) {
children.entry(parent.clone()).or_default().push(id.clone());
}
ordered.push((id, record.clone()));
}
let mut epics: IndexMap<EpicId, Epic> = IndexMap::new();
for (id, record) in ordered {
let members: Vec<MemberRef> = buckets
.get(&id)
.map(|keys| {
keys.iter()
.map(|key| MemberRef {
issue: *key,
display: labels.get(key).cloned().unwrap_or_else(|| key.to_string()),
})
.collect()
})
.unwrap_or_default();
let kid_refs: Vec<EpicRef> = children
.get(&id)
.map(|ids| {
ids.iter()
.filter_map(|child| {
registry
.get(child.as_str())
.map(|record| EpicRef::new(child, &record.name))
})
.collect()
})
.unwrap_or_default();
epics.insert(
id.clone(),
Epic::from_configuration(id, &record, members, kid_refs),
);
}
missing.sort_by(reference_order);
Self {
epics,
buckets,
parents,
children,
missing,
labels,
statuses,
}
}
#[must_use]
pub fn buckets(&self) -> &HashMap<EpicId, Vec<IssueKey>> {
&self.buckets
}
#[must_use]
pub fn memberships_by_issue(&self) -> HashMap<u32, Vec<String>> {
let order: HashMap<&str, usize> = self
.epics
.keys()
.enumerate()
.map(|(position, id)| (id.as_str(), position))
.collect();
let mut memberships: HashMap<u32, Vec<String>> = HashMap::new();
for (id, keys) in &self.buckets {
for key in keys {
memberships
.entry(key.number())
.or_default()
.push(id.to_string());
}
}
for ids in memberships.values_mut() {
ids.sort_by_key(|id| order.get(id.as_str()).copied().unwrap_or(usize::MAX));
}
memberships
}
#[must_use]
pub fn bucket(&self, id: &EpicId) -> Option<&[IssueKey]> {
if !self.epics.contains_key(id) {
return None;
}
Some(self.buckets.get(id).map_or(&[][..], Vec::as_slice))
}
#[must_use]
pub fn epics(&self) -> Vec<&Epic> {
self.epics.values().collect()
}
#[must_use]
pub fn epic(&self, id: &EpicId) -> Option<&Epic> {
self.epics.get(id)
}
#[must_use]
pub fn label(&self, key: IssueKey) -> Option<&str> {
self.labels.get(&key).map(String::as_str)
}
#[must_use]
pub fn status_of(&self, key: IssueKey) -> Option<&str> {
self.statuses.get(&key).map(String::as_str)
}
#[must_use]
pub fn missing(&self) -> &[EpicReferenceResolution] {
&self.missing
}
pub fn resolve(&self, reference: &str) -> Result<&Epic, EpicError> {
if let Ok(id) = EpicId::new(reference) {
if let Some(epic) = self.epics.get(&id) {
return Ok(epic);
}
}
let normalized = reference.trim().to_lowercase();
self.epics
.values()
.find(|epic| epic.name.to_lowercase() == normalized)
.ok_or_else(|| EpicError::UnknownEpic {
reference: reference.to_string(),
})
}
pub fn membership(&self, reference: &str) -> Result<EpicMembership, EpicError> {
let epic = self.resolve(reference)?;
Ok(EpicMembership {
epic_id: epic.id.clone(),
members: epic.members.clone(),
member_count: epic.member_count,
})
}
#[must_use]
pub fn into_buckets(self) -> HashMap<EpicId, Vec<IssueKey>> {
self.buckets
}
#[must_use]
pub fn into_missing(self) -> Vec<EpicReferenceResolution> {
self.missing
}
#[must_use]
pub fn into_epics(self) -> Vec<Epic> {
self.epics.into_values().collect()
}
}
#[must_use]
pub fn membership_index<N: EpicNode>(
config: &Config,
issues: &[N],
) -> HashMap<EpicId, Vec<IssueKey>> {
EpicIndex::build(config, issues).into_buckets()
}
#[must_use]
pub fn memberships_by_issue<N: EpicNode>(
config: &Config,
issues: &[N],
) -> HashMap<u32, Vec<String>> {
EpicIndex::build(config, issues).memberships_by_issue()
}
pub fn membership<N: EpicNode>(
config: &Config,
issues: &[N],
reference: &str,
) -> Result<EpicMembership, EpicError> {
EpicIndex::build(config, issues).membership(reference)
}
#[must_use]
pub fn list_epics<N: EpicNode>(config: &Config, issues: &[N]) -> Vec<Epic> {
EpicIndex::build(config, issues).into_epics()
}
pub fn resolve<N: EpicNode>(
config: &Config,
issues: &[N],
reference: &str,
) -> Result<Epic, EpicError> {
EpicIndex::build(config, issues).resolve(reference).cloned()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnresolvedEpicParent {
pub epic: EpicId,
pub recorded_parent: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicHierarchy {
pub epic: EpicId,
pub recorded_parent: Option<String>,
pub parent: Option<EpicRef>,
pub children: Vec<EpicRef>,
pub child_count: usize,
pub ancestors: Vec<EpicRef>,
pub depth: usize,
pub unresolved: Option<UnresolvedEpicParent>,
}
pub(super) fn epic_cycle_path(
parents: &HashMap<EpicId, EpicId>,
epic: &EpicId,
parent: &EpicId,
) -> Option<Vec<EpicId>> {
let mut path = vec![epic.clone()];
let mut visited: HashSet<EpicId> = HashSet::new();
visited.insert(epic.clone());
let mut current = parent.clone();
loop {
if current == *epic {
return Some(path);
}
if !visited.insert(current.clone()) {
return None;
}
path.push(current.clone());
{
let next = parents.get(¤t)?;
current = next.clone()
}
}
}
pub(super) fn resolved_parents(
registry: &IndexMap<String, EpicConfiguration>,
) -> HashMap<EpicId, EpicId> {
let known: HashSet<String> = registry.keys().cloned().collect();
let mut parents: HashMap<EpicId, EpicId> = HashMap::new();
for (raw, record) in registry {
let Ok(id) = EpicId::new(raw) else {
continue;
};
if let Some(parent) = record
.parent
.as_deref()
.and_then(|p| EpicId::new(p).ok())
.filter(|p| known.contains(p.as_str()))
{
parents.insert(id, parent);
}
}
parents
}
pub fn validate_epic_parent(
registry: &IndexMap<String, EpicConfiguration>,
epic: &EpicId,
parent_reference: &str,
) -> Result<EpicId, EpicError> {
let parent = resolve_epic_id(registry, parent_reference)?;
if &parent == epic {
return Err(EpicError::SelfParent { epic: epic.clone() });
}
if let Some(path) = epic_cycle_path(&resolved_parents(registry), epic, &parent) {
return Err(EpicError::Cycle {
epic: epic.clone(),
path,
});
}
Ok(parent)
}
#[must_use]
pub fn validate_epic_chains(registry: &IndexMap<String, EpicConfiguration>) -> Vec<EpicError> {
let parents = resolved_parents(registry);
let mut out: Vec<EpicError> = Vec::new();
let mut ids: Vec<EpicId> = registry
.keys()
.filter_map(|raw| EpicId::new(raw).ok())
.collect();
ids.sort();
let mut seen: HashSet<EpicId> = HashSet::new();
for id in &ids {
if parents.get(id) == Some(id) {
seen.insert(id.clone());
out.push(EpicError::SelfParent { epic: id.clone() });
}
}
for id in &ids {
if seen.contains(id) {
continue;
}
let Some(parent) = parents.get(id) else {
continue;
};
if let Some(path) = epic_cycle_path(&parents, id, parent) {
for node in &path {
seen.insert(node.clone());
}
out.push(EpicError::Cycle {
epic: id.clone(),
path,
});
}
}
out.sort_by(|a, b| {
a.kind()
.cmp(&b.kind())
.then_with(|| chain_epic(a).cmp(&chain_epic(b)))
});
out
}
pub(super) fn chain_epic(err: &EpicError) -> Option<&EpicId> {
match err {
EpicError::SelfParent { epic } | EpicError::Cycle { epic, .. } => Some(epic),
_ => None,
}
}
impl EpicIndex {
pub fn hierarchy(&self, reference: &str) -> Result<EpicHierarchy, EpicError> {
let epic = self.resolve(reference)?;
let id = epic.id.clone();
let parent = self.parents.get(&id).and_then(|parent| self.ref_of(parent));
let mut ancestors: Vec<EpicRef> = Vec::new();
let mut visited: HashSet<EpicId> = HashSet::new();
visited.insert(id.clone());
let mut current = self.parents.get(&id).cloned();
while let Some(node) = current {
if !visited.insert(node.clone()) {
break;
}
if let Some(reference) = self.ref_of(&node) {
ancestors.push(reference);
}
current = self.parents.get(&node).cloned();
}
ancestors.reverse();
let children: Vec<EpicRef> = self
.children
.get(&id)
.map(|ids| ids.iter().filter_map(|child| self.ref_of(child)).collect())
.unwrap_or_default();
let unresolved = epic.parent.as_deref().and_then(|recorded| {
let resolves = self
.parents
.get(&id)
.is_some_and(|p| p.as_str() == recorded);
if resolves {
return None;
}
Some(UnresolvedEpicParent {
epic: id.clone(),
recorded_parent: recorded.to_string(),
reason: unresolved_parent_reason(&id, recorded),
})
});
Ok(EpicHierarchy {
epic: id,
recorded_parent: epic.parent.clone(),
parent,
child_count: children.len(),
children,
depth: ancestors.len(),
ancestors,
unresolved,
})
}
pub(super) fn ref_of(&self, id: &EpicId) -> Option<EpicRef> {
self.epics.get(id).map(|epic| EpicRef::new(id, &epic.name))
}
}
pub fn hierarchy<N: EpicNode>(
config: &Config,
issues: &[N],
reference: &str,
) -> Result<EpicHierarchy, EpicError> {
EpicIndex::build(config, issues).hierarchy(reference)
}
impl EpicIndex {
#[must_use]
pub fn missing_for(
&self,
id: &EpicId,
targets: &ReferenceTargets,
) -> Vec<EpicReferenceResolution> {
let Some(epic) = self.epics.get(id) else {
return Vec::new();
};
let holder = ReferenceHolder::Epic(id.clone());
let mut out: Vec<EpicReferenceResolution> = Vec::new();
if let Some(recorded) = epic.parent.as_deref() {
let resolves = self.parents.get(id).is_some_and(|p| p.as_str() == recorded);
if !resolves {
out.push(EpicReferenceResolution {
holder: holder.clone(),
target_kind: EpicReferenceKind::Epic,
target: recorded.to_string(),
reason: missing_epic_reason(&holder, recorded),
});
}
}
for project in &epic.projects {
if !targets.project_known(project) {
out.push(EpicReferenceResolution {
holder: holder.clone(),
target_kind: EpicReferenceKind::Project,
target: project.clone(),
reason: missing_project_reason(id, project),
});
}
}
if let Some(milestone) = epic.milestone.as_deref() {
if !targets.milestone_known(milestone) {
out.push(EpicReferenceResolution {
holder: holder.clone(),
target_kind: EpicReferenceKind::Milestone,
target: milestone.to_string(),
reason: missing_milestone_reason(id, milestone),
});
}
}
if let Some(cycle) = epic.cycle.as_deref() {
if !targets.cycle_known(cycle) {
out.push(EpicReferenceResolution {
holder: holder.clone(),
target_kind: EpicReferenceKind::Cycle,
target: cycle.to_string(),
reason: missing_cycle_reason(id, cycle),
});
}
}
out.sort_by(reference_order);
out
}
}
impl EpicIndex {
pub fn rollup(
&self,
config: &Config,
reference: &str,
targets: &ReferenceTargets,
graph: &RelationshipGraph<'_>,
) -> Result<EpicRollup, EpicError> {
let epic = self.resolve(reference)?;
let id = epic.id.clone();
let (_, resolution) = crate::workflow::active_resolution(config);
let members = self.bucket(&id).unwrap_or(&[]);
let done_board: HashSet<IssueKey> = members
.iter()
.filter(|key| {
self.status_of(**key)
.and_then(|status| resolution.resolve(status))
.is_some_and(crate::workflow::WorkflowCategory::is_complete)
})
.copied()
.collect();
let done = done_board.len();
let total = members.len();
let blocked_members: Vec<BlockedMember> = self
.blocked_members(&id, graph)
.into_iter()
.filter(|member| !done_board.contains(&member.issue))
.collect();
Ok(EpicRollup {
name: epic.name.clone(),
progress: EpicProgress {
done,
total,
progress: Progress { done, total },
},
child_count: epic.child_count,
weighted_progress: self.weighted_progress(config, &id, &resolution),
blocked: blocked_members.len(),
blocked_members,
missing_references: self.missing_for(&id, targets),
epic_id: id,
})
}
#[must_use]
pub fn blocked_members(
&self,
id: &EpicId,
graph: &RelationshipGraph<'_>,
) -> Vec<BlockedMember> {
let Some(members) = self.buckets.get(id) else {
return Vec::new();
};
let mut out: Vec<BlockedMember> = members
.iter()
.filter_map(|key| {
let state = graph.blocked_state(key.number());
if !state.is_blocked {
return None;
}
Some(BlockedMember {
issue: *key,
display: self
.label(*key)
.map(str::to_string)
.unwrap_or_else(|| key.to_string()),
reason: state.reason,
})
})
.collect();
out.sort_by_key(|member| member.issue);
out
}
pub(super) fn weighted_progress(
&self,
config: &Config,
id: &EpicId,
resolution: &crate::workflow::CategoryResolution,
) -> WeightedProgress {
let own_weight = config.epics.get(id.as_str()).and_then(|r| r.weight);
let Some(children) = self.children.get(id) else {
return WeightedProgress {
own_weight,
weighted_children: 0,
unweighted_children: 0,
done_weight: 0,
total_weight: 0,
percent: None,
};
};
let mut weighted = 0usize;
let mut unweighted = 0usize;
let mut done_weight = 0i64;
let mut total_weight = 0i64;
for child in children {
let Some(weight) = config.epics.get(child.as_str()).and_then(|r| r.weight) else {
unweighted += 1;
continue;
};
weighted += 1;
total_weight += weight;
if self.child_is_complete(child, resolution) {
done_weight += weight;
}
}
let percent = whole_percent(done_weight, total_weight);
WeightedProgress {
own_weight,
weighted_children: weighted,
unweighted_children: unweighted,
done_weight,
total_weight,
percent,
}
}
pub(super) fn child_is_complete(
&self,
child: &EpicId,
resolution: &crate::workflow::CategoryResolution,
) -> bool {
let Some(members) = self.buckets.get(child) else {
return false;
};
if members.is_empty() {
return false;
}
members.iter().all(|key| {
self.status_of(*key)
.and_then(|status| resolution.resolve(status))
.is_some_and(crate::workflow::WorkflowCategory::is_complete)
})
}
}
fn whole_percent(done: i64, total: i64) -> Option<u32> {
(total > 0).then(|| {
let numerator = i128::from(done.max(0)) * 100;
let percent = numerator / i128::from(total);
u32::try_from(percent).unwrap_or(u32::MAX)
})
}