use super::config::{GhState, 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)
.and_then(|s| s.label.as_ref())
.map(|l| l.name.as_str())
}
pub(crate) fn closes_issue(&self, state: &str) -> bool {
self.model
.states
.iter()
.any(|s| s.name == state && s.gh_state == GhState::Closed)
}
pub(crate) fn requires_note(&self, from: Option<&str>, to: &str) -> bool {
self.model
.transitions
.iter()
.any(|t| t.from.as_deref() == from && t.to == to && t.requires_note)
}
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],
gh_open: bool,
) -> CurrentState<'a> {
let matches = self.labelled_states(issue_labels);
match matches.len() {
0 => self
.labelless_state(gh_open)
.map_or(CurrentState::None, CurrentState::One),
1 => CurrentState::One(matches[0]),
_ => CurrentState::Many(matches),
}
}
pub(crate) fn labelled_states(&self, issue_labels: &[String]) -> Vec<&'a str> {
self.model
.states
.iter()
.filter(|s| {
s.label
.as_ref()
.is_some_and(|lbl| issue_labels.iter().any(|l| same_label_name(l, &lbl.name)))
})
.map(|s| s.name.as_str())
.collect()
}
fn labelless_state(&self, gh_open: bool) -> Option<&'a str> {
let want = if gh_open {
GhState::Open
} else {
GhState::Closed
};
self.model
.states
.iter()
.find(|s| s.label.is_none() && s.gh_state == want)
.map(|s| s.name.as_str())
}
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())),
}
}
}
pub(crate) fn same_label_name(a: &str, b: &str) -> bool {
a.chars()
.flat_map(char::to_lowercase)
.eq(b.chars().flat_map(char::to_lowercase))
}
#[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, true),
CurrentState::One("approved")
);
}
#[test]
fn sm_resolve_matches_a_label_in_any_case_8703() {
let m = model();
let sm = StateMachine::new(&m);
let labels = vec!["Unicorn:APPROVED".to_string()];
assert_eq!(
sm.resolve_current_state(&labels, true),
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, true), 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, true) {
CurrentState::Many(v) => {
assert!(v.contains(&"queued") && v.contains(&"approved"));
}
other => panic!("expected Many, got {other:?}"),
}
}
fn labelless_model() -> StateModel {
let yaml = r#"
version: 1
label_config: { base: "", approved: "", blast_prefix: "", status_prefix: "s:" }
states:
- { name: open, gh_state: open, order: 0 }
- { name: "s:working", label: { name: "s:working", color: "AABBCC" }, order: 1 }
- { name: closed, gh_state: closed, order: 2 }
transitions:
- { from: open, to: "s:working", trigger: executor_start }
- { from: "s:working", to: closed, trigger: human_label, requires_note: true }
- { from: "s:working", to: open, trigger: human_label }
assignee_model: { strategy: unchanged, per_state: {} }
"#;
serde_yaml::from_str(yaml).expect("label-less model parses")
}
#[test]
fn sm_state_label_none_for_labelless() {
let m = labelless_model();
let sm = StateMachine::new(&m);
assert_eq!(sm.state_label("open"), None);
assert!(sm.is_state("open"), "label-less states are still states");
assert_eq!(sm.state_label("s:working"), Some("s:working"));
}
#[test]
fn sm_resolve_falls_back_to_labelless() {
let m = labelless_model();
let sm = StateMachine::new(&m);
assert_eq!(
sm.resolve_current_state(&["chore".to_string()], true),
CurrentState::One("open")
);
assert_eq!(
sm.resolve_current_state(&["chore".to_string()], false),
CurrentState::One("closed")
);
assert_eq!(
sm.resolve_current_state(&["s:working".to_string()], true),
CurrentState::One("s:working")
);
}
#[test]
fn sm_closes_issue() {
let m = labelless_model();
let sm = StateMachine::new(&m);
assert!(sm.closes_issue("closed"));
assert!(!sm.closes_issue("open"));
assert!(!sm.closes_issue("s:working"));
assert!(!sm.closes_issue("nonexistent"));
}
#[test]
fn sm_requires_note() {
let m = labelless_model();
let sm = StateMachine::new(&m);
assert!(sm.requires_note(Some("s:working"), "closed"));
assert!(!sm.requires_note(Some("s:working"), "open"));
assert!(!sm.requires_note(Some("open"), "s:working"));
}
#[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));
}
}