use std::collections::BTreeMap;
use crate::config::Config;
use crate::model::Issue;
use super::r#override::OverrideRequest;
use super::policy::{MoveContext, MoveRequest, WipDecision, evaluate};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ItemWip {
pub number: u32,
pub to: String,
pub decision: WipDecision,
}
pub fn annotate_plan(plan: &mut crate::bulk::plan::BatchPlan, config: &Config, issues: &[Issue]) {
let moves: Vec<(u32, String)> = plan
.items
.iter()
.filter_map(|item| match &plan.patch.status {
crate::bulk::patch::PatchValue::Set(target) => Some((item.number, target.clone())),
_ => None,
})
.collect();
let mut decisions: BTreeMap<u32, ItemWip> =
evaluate_batch(config, issues, &moves, &BTreeMap::new())
.into_iter()
.map(|item| (item.number, item))
.collect();
for item in &mut plan.items {
item.wip = decisions.remove(&item.number);
}
}
impl ItemWip {
pub fn is_committable(&self) -> bool {
self.decision.is_committable()
}
}
pub fn evaluate_batch(
config: &Config,
issues: &[Issue],
moves: &[(u32, String)],
overrides: &BTreeMap<u32, OverrideRequest>,
) -> Vec<ItemWip> {
if !config.default_config().wip.has_enabled_limits() {
return Vec::new();
}
let mut projected: Vec<Issue> = issues.to_vec();
let mut out = Vec::with_capacity(moves.len());
for (number, target) in moves {
let Some(index) = projected.iter().position(|issue| issue.number == *number) else {
continue;
};
let context = MoveContext::new(config, &projected);
let request = MoveRequest::new(&projected[index], target);
let decision = match overrides.get(number) {
Some(over) => evaluate(&context, &request.overriding(over)),
None => evaluate(&context, &request),
};
out.push(ItemWip {
number: *number,
to: target.clone(),
decision,
});
projected[index].status = crate::model::Status::new(target);
}
let _ = &mut out;
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::wip::{ColumnLimit, LimitPolicy, WipConfig};
fn config(limit: u32, policy: LimitPolicy) -> Config {
let mut config = Config::default();
let board = config
.kanban
.configurations
.get_mut("default")
.expect("the default board");
board.columns = ["todo", "in progress", "done"]
.into_iter()
.map(String::from)
.collect();
board.done_column = Some("done".to_string());
board.wip = WipConfig {
units: [("d".to_string(), 1u32)].into_iter().collect(),
limits: [("in progress".to_string(), ColumnLimit::items(limit, policy))]
.into_iter()
.collect(),
};
config
}
fn issue(number: u32, status: &str) -> Issue {
let contents = format!(
"---\nnumber: {number}\ntitle: \"Issue {number}\"\nstatus: \"{status}\"\n\
created: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
);
let mut issue = Issue::parse(&contents).expect("the fixture parses");
issue.filename = format!("issue-{number}.md");
issue
}
#[test]
fn wip_bulk_projects_each_item_in_order() {
let config = config(3, LimitPolicy::Block);
let issues = vec![
issue(1, "todo"),
issue(2, "todo"),
issue(3, "todo"),
issue(4, "todo"),
];
let moves: Vec<(u32, String)> = (1..=4)
.map(|number| (number, "in progress".to_string()))
.collect();
let decisions = evaluate_batch(&config, &issues, &moves, &BTreeMap::new());
assert_eq!(decisions.len(), 4);
let kinds: Vec<&str> = decisions
.iter()
.map(|item| item.decision.as_str())
.collect();
assert_eq!(kinds, ["allow", "allow", "allow", "block"]);
assert!(decisions[0].is_committable());
assert!(!decisions[3].is_committable());
assert_eq!(decisions[3].decision.state_after.as_str(), "over");
}
#[test]
fn wip_bulk_an_override_carries_only_the_item_that_has_one() {
let config = config(1, LimitPolicy::Block);
let issues = vec![issue(1, "in progress"), issue(2, "todo"), issue(3, "todo")];
let moves: Vec<(u32, String)> = (2..=3)
.map(|number| (number, "in progress".to_string()))
.collect();
let limit = config
.default_config()
.wip
.effective_limit("in progress")
.expect("the limit")
.clone();
let over = OverrideRequest::new(
"alice",
"2026-01-02T03:04:05Z",
"release week",
super::super::r#override::BoundLimit::of("in progress", &limit),
);
let overrides = BTreeMap::from([(2u32, over)]);
let decisions = evaluate_batch(&config, &issues, &moves, &overrides);
assert_eq!(decisions[0].decision.as_str(), "override");
assert!(decisions[0].decision.history.is_some(), "one event");
assert_eq!(decisions[1].decision.as_str(), "block");
assert!(decisions[1].decision.history.is_none());
}
#[test]
fn wip_bulk_an_unknown_issue_is_skipped_rather_than_invented() {
let config = config(3, LimitPolicy::Warn);
let issues = vec![issue(1, "todo")];
let moves = vec![
(1u32, "in progress".to_string()),
(99u32, "in progress".to_string()),
];
let decisions = evaluate_batch(&config, &issues, &moves, &BTreeMap::new());
assert_eq!(decisions.len(), 1);
assert_eq!(decisions[0].number, 1);
}
}