use std::collections::btree_map::Entry;
use std::collections::{BTreeMap, BTreeSet};
use crate::assignee::parse_assignee;
use crate::config::Config;
use crate::cycle_schedule::identity::{CycleReference, ScheduleIndex};
use crate::error::render_diagnostics;
use crate::model::Issue;
use crate::wip::WipConfig;
use crate::workflow;
use super::config::{CapacityScopeKind, resolve_cycle};
use super::estimate::EstimateValue;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ScopeDiagnosticKind {
UnknownScope,
AmbiguousScope,
}
impl ScopeDiagnosticKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::UnknownScope => "unknown_scope",
Self::AmbiguousScope => "ambiguous_scope",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScopeDiagnostic {
pub kind: ScopeDiagnosticKind,
pub scope_kind: CapacityScopeKind,
pub reference: String,
pub issue: Option<u32>,
pub message: String,
pub because: Option<String>,
}
impl ScopeDiagnostic {
#[must_use]
pub fn new(
kind: ScopeDiagnosticKind,
scope_kind: CapacityScopeKind,
reference: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
kind,
scope_kind,
reference: reference.into(),
issue: None,
message: message.into(),
because: None,
}
}
#[must_use]
pub fn for_issue(mut self, number: u32) -> Self {
self.issue = Some(number);
self
}
#[must_use]
pub fn because(mut self, because: impl Into<String>) -> Self {
self.because = Some(because.into());
self
}
#[must_use]
pub fn render(&self) -> String {
let mut pairs = vec![(self.kind.as_str(), self.message.as_str())];
if let Some(because) = self.because.as_deref() {
pairs.push((self.kind.as_str(), because));
}
render_diagnostics("E-tix-capacity", pairs)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct IssueSnapshot {
numbers: Vec<u32>,
by_cycle: BTreeMap<String, Vec<u32>>,
cycle_names: BTreeMap<String, String>,
by_team: BTreeMap<String, Vec<u32>>,
by_assignee: BTreeMap<String, Vec<u32>>,
stated: BTreeMap<u32, EstimateValue>,
converted: BTreeMap<u32, BTreeMap<String, EstimateValue>>,
targets: Vec<String>,
completed: BTreeSet<u32>,
unscoped: Vec<u32>,
diagnostics: Vec<ScopeDiagnostic>,
visits: usize,
parses: usize,
conversions: usize,
}
impl IssueSnapshot {
#[must_use]
#[allow(clippy::too_many_lines)]
pub fn build(config: &Config, index: &ScheduleIndex, issues: &[Issue]) -> Self {
let board = config.default_config();
let resolution = workflow::resolution(config, board, "default");
let mut targets: BTreeSet<String> = board.wip.units.keys().cloned().collect();
for (_, entry) in config.capacity.iter() {
if let Some(unit) = entry
.unit
.as_deref()
.map(str::trim)
.filter(|unit| !unit.is_empty())
{
targets.insert(unit.to_string());
}
}
let targets: Vec<String> = targets.into_iter().collect();
let mut teams_of: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (label, team) in &config.teams {
for member in &team.members {
let Some(handle) = parse_assignee(member) else {
continue;
};
let teams = teams_of.entry(handle).or_default();
if !teams.contains(label) {
teams.push(label.clone());
}
}
}
let mut snapshot = Self {
targets: targets.clone(),
..Self::default()
};
snapshot.numbers.reserve(issues.len());
for issue in issues {
snapshot.numbers.push(issue.number);
let (stated, per_unit) = snapshot.read_issue(issue, &board.wip, &targets);
snapshot.converted.insert(issue.number, per_unit);
snapshot.stated.insert(issue.number, stated);
if workflow::is_completed(issue, &resolution) {
snapshot.completed.insert(issue.number);
}
let mut spellings: BTreeMap<String, String> = BTreeMap::new();
let mut scoped = false;
for reference in issue.cycles.iter().flatten() {
let cycle = resolve_cycle(index, reference);
let member = match &cycle {
CycleReference::Generated { name, id, .. } => {
Some((format!("cycle-id:{id}"), name.clone()))
}
CycleReference::Legacy { name } => {
Some((format!("cycle-name:{name}"), name.clone()))
}
CycleReference::Unknown { name } => {
snapshot.diagnostics.push(
ScopeDiagnostic::new(
ScopeDiagnosticKind::UnknownScope,
CapacityScopeKind::Cycle,
name.clone(),
format!(
"issue #{} `cycles` names `{name}`, which does not resolve \
to a cycle record",
issue.number
),
)
.for_issue(issue.number)
.because(
"the issue counts in no cycle until that cycle exists; an \
unresolvable name is reported, never guessed (C-003)",
),
);
None
}
};
let Some((key, name)) = member else {
continue;
};
snapshot.cycle_names.entry(key.clone()).or_insert(name);
scoped = true;
match spellings.entry(key.clone()) {
Entry::Vacant(slot) => {
slot.insert(reference.clone());
snapshot.by_cycle.entry(key).or_default().push(issue.number);
}
Entry::Occupied(slot) => {
if slot.get() != reference {
snapshot.diagnostics.push(
ScopeDiagnostic::new(
ScopeDiagnosticKind::AmbiguousScope,
CapacityScopeKind::Cycle,
reference.clone(),
format!(
"issue #{} `cycles` names `{reference}` and `{}`, \
which resolve to one cycle",
issue.number,
slot.get()
),
)
.for_issue(issue.number)
.because("the cycle counts once, not twice"),
);
}
}
}
}
if let Some(handle) = issue.assignee.as_deref().and_then(parse_assignee) {
scoped = true;
snapshot
.by_assignee
.entry(handle.clone())
.or_default()
.push(issue.number);
for label in teams_of.get(&handle).into_iter().flatten() {
snapshot
.by_team
.entry(label.clone())
.or_default()
.push(issue.number);
}
}
if !scoped {
snapshot.unscoped.push(issue.number);
}
}
for members in snapshot
.by_cycle
.values_mut()
.chain(snapshot.by_team.values_mut())
.chain(snapshot.by_assignee.values_mut())
{
members.sort_unstable();
members.dedup();
}
snapshot.unscoped.sort_unstable();
snapshot.unscoped.dedup();
snapshot
}
fn read_issue(
&mut self,
issue: &Issue,
registry: &WipConfig,
targets: &[String],
) -> (EstimateValue, BTreeMap<String, EstimateValue>) {
self.visits += 1;
let stated = match issue.estimate.as_deref() {
Some(raw) if !raw.trim().is_empty() => {
self.parses += 1;
EstimateValue::parse(issue.number, raw)
}
_ => EstimateValue::unestimated(),
};
let mut per_unit: BTreeMap<String, EstimateValue> = BTreeMap::new();
for unit in targets {
per_unit.insert(
unit.clone(),
stated.converted_to(issue.number, registry, unit),
);
}
if stated.is_valid() {
self.conversions += targets.len();
}
(stated, per_unit)
}
#[must_use]
pub fn issues(&self) -> &[u32] {
&self.numbers
}
#[must_use]
pub fn len(&self) -> usize {
self.numbers.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.numbers.is_empty()
}
#[must_use]
pub fn targets(&self) -> &[String] {
&self.targets
}
#[must_use]
pub fn estimate(&self, number: u32) -> Option<&EstimateValue> {
self.stated.get(&number)
}
#[must_use]
pub fn estimate_in(&self, number: u32, unit: &str) -> Option<&EstimateValue> {
self.converted.get(&number)?.get(unit)
}
#[must_use]
pub fn is_completed(&self, number: u32) -> bool {
self.completed.contains(&number)
}
#[must_use]
pub fn completed(&self) -> &BTreeSet<u32> {
&self.completed
}
#[must_use]
pub fn cycle_members(&self, index: &ScheduleIndex, reference: &str) -> &[u32] {
let key = match resolve_cycle(index, reference) {
CycleReference::Generated { id, .. } => format!("cycle-id:{id}"),
CycleReference::Legacy { name } => format!("cycle-name:{name}"),
CycleReference::Unknown { .. } => return &[],
};
self.by_cycle.get(&key).map_or(&[], Vec::as_slice)
}
#[must_use]
pub fn team_members(&self, label: &str) -> &[u32] {
self.by_team.get(label).map_or(&[], Vec::as_slice)
}
#[must_use]
pub fn assignee_members(&self, reference: &str) -> &[u32] {
match parse_assignee(reference) {
Some(handle) => self.by_assignee.get(&handle).map_or(&[], Vec::as_slice),
None => &[],
}
}
#[must_use]
pub fn members(
&self,
kind: CapacityScopeKind,
reference: &str,
index: &ScheduleIndex,
) -> &[u32] {
match kind {
CapacityScopeKind::Cycle => self.cycle_members(index, reference),
CapacityScopeKind::Team => self.team_members(reference),
CapacityScopeKind::Assignee => self.assignee_members(reference),
}
}
#[must_use]
pub fn cycle_scopes(&self) -> Vec<&str> {
self.by_cycle
.keys()
.map(|key| {
self.cycle_names
.get(key)
.map_or(key.as_str(), String::as_str)
})
.collect()
}
#[must_use]
pub fn team_scopes(&self) -> Vec<&str> {
self.by_team.keys().map(String::as_str).collect()
}
#[must_use]
pub fn assignee_scopes(&self) -> Vec<&str> {
self.by_assignee.keys().map(String::as_str).collect()
}
#[must_use]
pub fn unscoped(&self) -> &[u32] {
&self.unscoped
}
#[must_use]
pub fn diagnostics(&self) -> &[ScopeDiagnostic] {
&self.diagnostics
}
#[must_use]
pub fn issue_visits(&self) -> usize {
self.visits
}
#[must_use]
pub fn estimates_parsed(&self) -> usize {
self.parses
}
#[must_use]
pub fn conversions_resolved(&self) -> usize {
self.conversions
}
}