use super::config::StateModel;
use crate::commands::ticket::labels::AssigneeTarget;
pub(crate) struct StateMachine<'a> {
model: &'a StateModel,
}
impl<'a> StateMachine<'a> {
pub(crate) fn new(model: &'a StateModel) -> Self {
Self { model }
}
pub(crate) fn state_label(&self, state: &str) -> Option<&'a str> {
self.model
.states
.iter()
.find(|s| s.name == state)
.map(|s| s.label.name.as_str())
}
pub(crate) fn is_state(&self, state: &str) -> bool {
self.model.states.iter().any(|s| s.name == state)
}
pub(crate) fn state_names(&self) -> Vec<&'a str> {
self.model.states.iter().map(|s| s.name.as_str()).collect()
}
pub(crate) fn transition_allowed(&self, from: Option<&str>, to: &str) -> bool {
self.model
.transitions
.iter()
.any(|t| t.from.as_deref() == from && t.to == to)
}
pub(crate) fn allowed_targets_from(&self, from: Option<&str>) -> Vec<&'a str> {
self.model
.transitions
.iter()
.filter(|t| t.from.as_deref() == from)
.map(|t| t.to.as_str())
.collect()
}
pub(crate) fn resolve_current_state(&self, issue_labels: &[String]) -> CurrentState<'a> {
let matches: Vec<&'a str> = self
.model
.states
.iter()
.filter(|s| issue_labels.iter().any(|l| l == &s.label.name))
.map(|s| s.name.as_str())
.collect();
match matches.len() {
0 => CurrentState::None,
1 => CurrentState::One(matches[0]),
_ => CurrentState::Many(matches),
}
}
pub(crate) fn assignee_target_for(&self, state: &str) -> Option<AssigneeTarget> {
let rule = self.model.assignee_model.per_state.get(state)?;
let value = rule.get("assignees")?;
let s = value.as_str()?.trim();
match s {
"unchanged" => None,
"self" | "me" | "@me" => Some(AssigneeTarget::SelfUser),
"none" | "unassigned" | "" => Some(AssigneeTarget::None),
t if t.starts_with('{') => None,
literal => Some(AssigneeTarget::Login(literal.to_string())),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum CurrentState<'a> {
None,
One(&'a str),
Many(Vec<&'a str>),
}
#[cfg(test)]
mod tests {
use super::super::config::DEFAULT_MODEL_YAML;
use super::*;
fn model() -> StateModel {
serde_yaml::from_str(DEFAULT_MODEL_YAML).expect("default parses")
}
#[test]
fn sm_state_label() {
let m = model();
let sm = StateMachine::new(&m);
assert_eq!(sm.state_label("queued"), Some("unicorn:queued"));
assert_eq!(sm.state_label("nope"), None);
}
#[test]
fn sm_is_state() {
let m = model();
let sm = StateMachine::new(&m);
assert!(sm.is_state("approved"));
assert!(!sm.is_state("in-review"));
}
#[test]
fn sm_state_names() {
let m = model();
let sm = StateMachine::new(&m);
assert!(sm.state_names().contains(&"failed"));
assert_eq!(sm.state_names().len(), 7);
}
#[test]
fn sm_transition_allowed() {
let m = model();
let sm = StateMachine::new(&m);
assert!(sm.transition_allowed(None, "queued"));
assert!(sm.transition_allowed(Some("queued"), "approved"));
assert!(sm.transition_allowed(Some("approved"), "active-development"));
assert!(sm.transition_allowed(Some("active-development"), "done"));
assert!(sm.transition_allowed(Some("active-development"), "failed"));
assert!(sm.transition_allowed(Some("active-development"), "paused"));
assert!(sm.transition_allowed(Some("active-development"), "blocked"));
}
#[test]
fn sm_transition_rejects_unlisted() {
let m = model();
let sm = StateMachine::new(&m);
assert!(!sm.transition_allowed(Some("done"), "active-development"));
assert!(!sm.transition_allowed(Some("queued"), "done"));
assert!(!sm.transition_allowed(Some("approved"), "queued"));
}
#[test]
fn sm_allowed_targets_from() {
let m = model();
let sm = StateMachine::new(&m);
let mut from_active = sm.allowed_targets_from(Some("active-development"));
from_active.sort_unstable();
assert_eq!(from_active, vec!["blocked", "done", "failed", "paused"]);
assert!(sm.allowed_targets_from(Some("done")).is_empty());
}
#[test]
fn sm_resolve_one() {
let m = model();
let sm = StateMachine::new(&m);
let labels = vec!["unicorn".to_string(), "unicorn:approved".to_string()];
assert_eq!(
sm.resolve_current_state(&labels),
CurrentState::One("approved")
);
}
#[test]
fn sm_resolve_none() {
let m = model();
let sm = StateMachine::new(&m);
let labels = vec!["unicorn".to_string(), "blast:high".to_string()];
assert_eq!(sm.resolve_current_state(&labels), CurrentState::None);
}
#[test]
fn sm_resolve_many() {
let m = model();
let sm = StateMachine::new(&m);
let labels = vec!["unicorn:queued".to_string(), "unicorn:approved".to_string()];
match sm.resolve_current_state(&labels) {
CurrentState::Many(v) => {
assert!(v.contains(&"queued") && v.contains(&"approved"));
}
other => panic!("expected Many, got {other:?}"),
}
}
#[test]
fn sm_assignee_unchanged() {
let m = model();
let sm = StateMachine::new(&m);
assert_eq!(sm.assignee_target_for("approved"), None);
assert_eq!(sm.assignee_target_for("done"), None);
}
#[test]
fn sm_assignee_template_is_noop() {
let m = model();
let sm = StateMachine::new(&m);
assert_eq!(sm.assignee_target_for("queued"), None);
}
#[test]
fn sm_assignee_self_and_none() {
let yaml = r#"
version: 1
label_config: { base: x, approved: x:a, blast_prefix: "b:", status_prefix: "x:" }
states:
- { name: open, label: { name: "x:open", color: "AABBCC" } }
- { name: closed, label: { name: "x:closed", color: "AABBCC" }, terminal: true }
transitions:
- { from: null, to: open, trigger: issue_created }
- { from: open, to: closed, trigger: human_label }
assignee_model:
strategy: self
per_state:
open:
assignees: self
closed:
assignees: none
"#;
let m: StateModel = serde_yaml::from_str(yaml).expect("synthetic parses");
let sm = StateMachine::new(&m);
assert_eq!(
sm.assignee_target_for("open"),
Some(AssigneeTarget::SelfUser)
);
assert_eq!(sm.assignee_target_for("closed"), Some(AssigneeTarget::None));
}
}