use std::io::IsTerminal;
use crate::commands::parse_selector;
use crate::config::Config;
use crate::editor::read_tty_line;
use crate::error::{Result, TixError};
use crate::model::{HistoryEventKind, Issue};
use crate::relationships::BlockedState;
use crate::storage::Store;
pub fn completion_column(config: &Config) -> Option<String> {
crate::workflow::completion_column(config.default_config(), config)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MoveGuard {
Clear,
Blocked(BlockedState),
}
impl MoveGuard {
pub fn needs_acknowledgement(&self) -> bool {
matches!(self, MoveGuard::Blocked(_))
}
pub fn warning(&self) -> Option<String> {
match self {
MoveGuard::Clear => None,
MoveGuard::Blocked(state) => crate::commands::blocked_note(state),
}
}
}
fn wip_gate(
config: &Config,
store: &Store,
issue: &Issue,
column: &str,
override_reason: Option<&str>,
override_wip: bool,
confirmed: bool,
) -> Result<Option<crate::wip::HistoryIntent>> {
let issues = store.list()?;
let context = crate::wip::MoveContext::new(config, &issues);
let mut request = crate::wip::MoveRequest::new(issue, column);
if let Some(reason) = override_reason {
request = request.because(reason);
}
let decision = match config
.default_config()
.wip
.effective_limit(column)
.cloned()
.filter(|_| override_wip)
{
Some(limit) => {
let over = crate::wip::OverrideRequest {
confirmed,
..crate::wip::OverrideRequest::new(
crate::storage::actor_identity(),
crate::model::utc_now_rfc3339(),
override_reason.unwrap_or_default(),
crate::wip::BoundLimit::of(column, &limit),
)
};
crate::wip::evaluate(&context, &request.overriding(&over))
}
None => crate::wip::evaluate(&context, &request),
};
match decision.outcome {
crate::wip::WipOutcome::Allow => Ok(None),
crate::wip::WipOutcome::Warn => {
eprintln!("{}", crate::wip::render_decision(&decision));
Ok(None)
}
crate::wip::WipOutcome::Override => Ok(decision.history.clone()),
crate::wip::WipOutcome::Rejected
if decision.diagnostics.first().is_some_and(|diagnostic| {
diagnostic.kind == crate::wip::WipDiagnosticKind::RejectedTransition
}) =>
{
Ok(None)
}
crate::wip::WipOutcome::Block | crate::wip::WipOutcome::Rejected => {
Err(TixError::Refused(crate::wip::render_decision(&decision)))
}
}
}
pub fn blocked_move_guard(
config: &Config,
store: &Store,
issue: &Issue,
column: &str,
) -> Result<MoveGuard> {
if completion_column(config).as_deref() != Some(column) {
return Ok(MoveGuard::Clear);
}
if issue.status.is(column) {
return Ok(MoveGuard::Clear);
}
let (issues, done_column) = store.graph_source()?;
let graph = crate::commands::graph_over(&issues, &done_column);
let state = graph.blocked_state(issue.number);
Ok(if state.is_blocked {
MoveGuard::Blocked(state)
} else {
MoveGuard::Clear
})
}
#[derive(Debug, Clone)]
pub struct GuardedMove {
pub issue: Issue,
pub warning: Option<String>,
pub acknowledged: bool,
}
#[allow(clippy::too_many_arguments)]
pub fn apply_guarded(
config: &Config,
store: &Store,
raw: &str,
column: &str,
commit: Option<&str>,
override_reason: Option<&str>,
acknowledged: bool,
wip: Option<&crate::wip::HistoryIntent>,
) -> Result<GuardedMove> {
let issue = store.get(parse_selector(store, raw)?)?;
let guard = blocked_move_guard(config, store, &issue, column)?;
let warning = guard.warning();
if guard.needs_acknowledgement() && !acknowledged {
return Err(TixError::InvalidInput(format!(
"{} — moving to '{column}' with open blockers requires an explicit \
confirmation; re-run with --yes to record the decision",
warning.unwrap_or_else(|| "blocked".to_string())
)));
}
let acknowledgement = guard.needs_acknowledgement();
let moved = store.transaction(|| -> Result<crate::model::Issue> {
let mut moved = apply(store, raw, column, commit, override_reason, wip)?;
if acknowledgement {
let reason = match &guard {
MoveGuard::Blocked(state) => state.reason.clone(),
MoveGuard::Clear => String::new(),
};
moved.history.append_now(
&crate::storage::actor_identity(),
HistoryEventKind::BlockedMoveAcknowledged,
&format!("moved to {column}: {reason}"),
);
moved.bump_updated();
moved.filename = store.update(&moved.filename, &moved)?;
}
Ok(moved)
})?;
Ok(GuardedMove {
issue: moved,
warning,
acknowledged: acknowledgement,
})
}
#[allow(clippy::too_many_arguments)]
pub fn run(
config: &Config,
store: &Store,
raw: &str,
column: &str,
commit: Option<&str>,
override_reason: Option<&str>,
yes: bool,
override_wip: bool,
) -> Result<()> {
let (board_name, resolution) = crate::workflow::active_resolution(config);
let issue = store.get(parse_selector(store, raw)?)?;
let guard = blocked_move_guard(config, store, &issue, column)?;
if let Some(warning) = guard.warning() {
eprintln!(
"{}",
crate::diagnostics::Diagnostic::warning("W-tix-move", warning.clone()).render_auto()
);
if !yes {
return Err(TixError::InvalidInput(format!(
"{warning} — moving to '{column}' with open blockers requires an \
explicit confirmation; re-run with --yes to record the decision"
)));
}
}
let wip = wip_gate(
config,
store,
&issue,
column,
override_reason,
override_wip,
yes,
)?;
let reaches_completion = resolution
.resolve(column)
.is_some_and(crate::workflow::WorkflowCategory::is_complete);
let commit = if reaches_completion && commit.is_none() {
if let Some(head) = std::env::current_dir()
.ok()
.as_deref()
.and_then(crate::git::current_head)
{
Some(head)
} else if std::io::stdin().is_terminal() {
let trimmed = read_tty_line("Commit hash (or leave empty): ")?;
if trimmed.is_empty() {
None
} else {
Some(trimmed)
}
} else {
return Err(TixError::InvalidInput(format!(
"moving to '{column}' requires a commit hash — pass --commit <hash>"
)));
}
} else {
commit.map(str::to_string)
};
let moved = apply_guarded(
config,
store,
raw,
column,
commit.as_deref(),
override_reason,
yes,
wip.as_ref(),
)?;
let issue = moved.issue;
match issue.commit.as_deref() {
Some(hash) => println!("Moved {} to {column} (commit {hash})", issue.filename),
None => println!("Moved {} to {column}", issue.filename),
}
if moved.acknowledged {
println!("Acknowledged blocked move to {column}");
}
if let Some(category) = crate::workflow::category_of(&issue, &resolution) {
println!(
" {column} is `{}` ({}) on board {board_name}",
category.key(),
category.label()
);
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_lines)]
pub fn run_batch(
config: &Config,
store: &Store,
args: &[String],
select: Option<&str>,
filter: Option<&str>,
search: Option<&str>,
commit: Option<&str>,
override_reason: Option<&str>,
yes: bool,
override_wip: bool,
dry_run: bool,
) -> Result<crate::cli::ExitCode> {
use crate::cli::ExitCode;
let Some(column) = args.last().cloned() else {
return Err(TixError::InvalidInput(
"`tix move` needs a target column as its last value".to_string(),
));
};
let mut selectors: Vec<String> = args[..args.len() - 1].to_vec();
if let Some(select) = select {
selectors.push(select.to_string());
}
let request = crate::commands::bulk::SelectionRequest::new(
&selectors,
filter,
search,
crate::bulk::SelectionScope::Active,
)?;
let selection = crate::commands::bulk::resolve(store, &request)?;
if selection.is_empty() {
return Err(TixError::NotFound(
"no issues match the move selection".to_string(),
));
}
if dry_run {
print_batch_move_plan(&selection, &column, true);
return Ok(ExitCode::Success);
}
if selection.len() == 1 {
let raw = selection.issues()[0].number.to_string();
run(
config,
store,
&raw,
&column,
commit,
override_reason,
yes,
override_wip,
)?;
return Ok(ExitCode::Success);
}
if !yes {
print_batch_move_plan(&selection, &column, false);
return Ok(ExitCode::UsageConfig);
}
let (_, resolution) = crate::workflow::active_resolution(config);
let reaches_completion = resolution
.resolve(&column)
.is_some_and(crate::workflow::WorkflowCategory::is_complete);
let commit: Option<String> = if reaches_completion {
match commit {
Some(value) => Some(value.to_string()),
None => {
if let Some(head) = std::env::current_dir()
.ok()
.as_deref()
.and_then(crate::git::current_head)
{
Some(head)
} else if std::io::stdin().is_terminal() {
let answer = read_tty_line("Commit hash (or leave empty): ")?;
let answer = answer.trim();
(!answer.is_empty()).then(|| answer.to_string())
} else {
return Err(TixError::InvalidInput(format!(
"moving to '{column}' requires a commit hash — pass --commit <hash>"
)));
}
}
}
} else {
commit.map(str::to_string)
};
let mut moved = 0usize;
let mut refused: Vec<(u32, String)> = Vec::new();
for issue in selection.issues() {
let wip = match wip_gate(
config,
store,
issue,
&column,
override_reason,
override_wip,
yes,
) {
Ok(intent) => intent,
Err(error) => {
refused.push((issue.number, error.to_string()));
continue;
}
};
match apply_guarded(
config,
store,
&issue.filename,
&column,
commit.as_deref(),
override_reason,
yes,
wip.as_ref(),
) {
Ok(outcome) => {
moved += 1;
match outcome.issue.commit.as_deref() {
Some(hash) => println!("#{} moved to {column} (commit {hash})", issue.number),
None => println!("#{} moved to {column}", issue.number),
}
}
Err(error) => refused.push((issue.number, error.to_string())),
}
}
for (number, reason) in &refused {
println!("#{number} refused: {reason}");
}
println!("{} moved, {} refused", moved, refused.len());
Ok(if refused.is_empty() {
ExitCode::Success
} else {
ExitCode::UsageConfig
})
}
fn print_batch_move_plan(selection: &crate::bulk::Selection, column: &str, dry_run: bool) {
println!(
"selection: {} issue{} ({}, {} scope)",
selection.len(),
if selection.len() == 1 { "" } else { "s" },
selection.source().as_str(),
selection.scope().as_str()
);
for issue in selection.issues() {
println!(" #{} {}", issue.number, issue.title);
}
println!("would move {} issue(s) to {column}", selection.len());
if dry_run {
println!("dry run: nothing written");
} else {
println!("nothing written; re-run with --yes to apply");
}
}
#[allow(clippy::too_many_arguments)]
pub fn apply(
store: &Store,
raw: &str,
column: &str,
commit: Option<&str>,
override_reason: Option<&str>,
wip: Option<&crate::wip::HistoryIntent>,
) -> Result<Issue> {
let selector = parse_selector(store, raw)?;
let issue = store.get(selector)?;
if let Some(reason) = override_reason {
if reason.chars().any(crate::triage::decision::is_line_break) {
return Err(TixError::InvalidInput(
"invalid_reason: the override reason has a line break; it is written to \
one History line, so a line break would look like a second event"
.to_string(),
));
}
}
let commit: Option<String> = match commit {
Some("auto") => Some(crate::commands::resolve_auto_commit(store, &issue)?),
Some(other) => Some(other.to_string()),
None => None,
};
let outcome = store.set_status(
&issue.filename,
crate::storage::StatusChange::to(column)
.with_commit(commit.as_deref())
.with_override(override_reason),
)?;
let mut moved = outcome.issue;
if outcome.written && crate::triage::record_move_entry(&store.config, &mut moved)? {
moved.filename = store.update(&moved.filename, &moved)?;
}
if let Some(intent) = wip
&& outcome.written
{
moved.history.append(crate::model::HistoryEvent::new(
intent.timestamp.clone(),
&intent.actor,
intent.kind,
&intent.detail,
));
moved.bump_updated();
moved.filename = store.update(&moved.filename, &moved)?;
}
Ok(moved)
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::commands::create::create;
use crate::model::Issue;
use crate::model::Status;
use crate::storage::IssueSelector;
use std::fs;
use tempfile::TempDir;
fn make_store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = crate::config::Config {
issues_dir: dir.clone(),
..crate::config::Config::default()
};
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn seed_named(store: &Store, title: &str) -> Issue {
create(
&store.config,
store,
title,
Some("body"),
None,
None,
Some(""),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.unwrap()
}
fn seed(store: &Store) -> Issue {
thread_local! {
static COUNTER: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
}
let n = COUNTER.with(|c| {
let n = c.get();
c.set(n + 1);
n
});
seed_named(store, &format!("Move me {n}"))
}
#[test]
fn apply_into_done_records_commit() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store);
let moved = apply(
&store,
&seeded.number.to_string(),
"done",
Some("abc123"),
None,
None,
)
.unwrap();
assert_eq!(moved.status.display(), "done");
assert_eq!(moved.commit.as_deref(), Some("abc123"));
assert_eq!(moved.number, seeded.number);
let reloaded = store
.get(crate::storage::IssueSelector::Filename(
moved.filename.clone(),
))
.unwrap();
assert_eq!(reloaded.commit.as_deref(), Some("abc123"));
}
#[test]
fn apply_out_of_done_clears_commit() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store);
apply(
&store,
&seeded.number.to_string(),
"done",
Some("abc123"),
None,
None,
)
.unwrap();
let moved_out = apply(
&store,
&seeded.number.to_string(),
"in progress",
None,
None,
None,
)
.unwrap();
assert_eq!(moved_out.status.display(), "in progress");
assert_eq!(
moved_out.commit, None,
"moving out of done clears the commit"
);
}
#[test]
fn apply_out_of_done_is_refused_on_a_board_that_declared_a_category_map() {
let (tmp, store) = store_with_category_map();
let _ = tmp;
let seeded = seed(&store);
let moved = apply(
&store,
&seeded.number.to_string(),
"done",
Some("abc123"),
None,
None,
)
.unwrap();
let before = std::fs::read_to_string(store.issues_dir.join(&moved.filename)).unwrap();
let refused =
apply(&store, &seeded.number.to_string(), "todo", None, None, None).unwrap_err();
assert!(
matches!(&refused, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
"expected the named `invalid_transition`, got: {refused:?}"
);
let text = refused.to_string();
assert!(
text.contains("needs an explicit override with a reason"),
"and the message must say how to proceed: {text}"
);
assert_eq!(
std::fs::read_to_string(store.issues_dir.join(&moved.filename)).unwrap(),
before,
"a refusal must be byte-identical, not merely unsuccessful"
);
let moved_out = apply(
&store,
&seeded.number.to_string(),
"todo",
None,
Some("reopening: filed against the wrong epic"),
None,
)
.unwrap();
assert_eq!(moved_out.status.display(), "todo");
assert_eq!(moved_out.commit, None, "and the commit is cleared");
let events = moved_out.history.events();
let decision = events
.iter()
.rev()
.find(|e| e.kind == HistoryEventKind::TransitionAccepted)
.expect("the accepted move records a decision");
let parsed = crate::model::TransitionDecision::from_event(decision).unwrap();
assert_eq!(parsed.from, "done");
assert_eq!(parsed.to, "todo");
assert_eq!(parsed.from_category, "completed");
assert_eq!(parsed.to_category, "unstarted");
assert_eq!(
parsed.override_reason.as_deref(),
Some("reopening: filed against the wrong epic"),
"the reason is recorded, so the audit trail explains the override"
);
}
#[test]
fn apply_into_done_without_commit_leaves_none() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store);
let moved = apply(&store, &seeded.number.to_string(), "done", None, None, None).unwrap();
assert_eq!(moved.status.display(), "done");
assert_eq!(moved.commit, None);
}
#[test]
fn apply_rejects_unknown_column() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store);
let err = apply(
&store,
&seeded.number.to_string(),
"bogus",
None,
None,
None,
)
.unwrap_err();
assert!(
matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "unknown_column"),
"expected the named `unknown_column` diagnostic, got: {err:?}"
);
assert_eq!(
err.to_string(),
"transition rejected (unknown_column) for todo -> bogus: `bogus` is not a \
column on board `default`; valid columns: todo, in progress, in review, done"
);
}
#[test]
fn apply_invalid_selector_is_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
let _ = seed(&store);
let err = apply(&store, "999", "todo", None, None, None).unwrap_err();
assert!(matches!(err, TixError::NotFound(_)));
}
fn store_with_category_map() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = crate::config::Config {
issues_dir: dir.clone(),
kanban: crate::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec!["todo".into(), "doing".into(), "done".into()],
done_column: Some("done".to_string()),
category_map: indexmap::IndexMap::from([
("todo".to_string(), "unstarted".to_string()),
("doing".to_string(), "active".to_string()),
("done".to_string(), "completed".to_string()),
]),
..Default::default()
},
)]),
},
..crate::config::Config::default()
};
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn store_with_done_column(done_column: &str) -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = crate::config::Config {
issues_dir: dir.clone(),
kanban: crate::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
crate::config::BoardConfiguration {
columns: vec!["todo".into(), "doing".into(), done_column.into()],
done_column: Some(done_column.to_string()),
..Default::default()
},
)]),
},
..crate::config::Config::default()
};
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn seed_blocked_pair(store: &Store) -> (Issue, Issue, Issue) {
let blocker = seed(store);
let subject = seed(store);
let friend = seed(store);
store
.link(
&blocker.number.to_string(),
&subject.number.to_string(),
crate::relationships::RelationKind::Blocks,
)
.unwrap();
store
.link(
&friend.number.to_string(),
&subject.number.to_string(),
crate::relationships::RelationKind::Related,
)
.unwrap();
(blocker, subject, friend)
}
fn acknowledged_events(issue: &Issue) -> Vec<String> {
issue
.history
.events()
.iter()
.filter(|e| e.kind == HistoryEventKind::BlockedMoveAcknowledged)
.map(|e| e.detail.clone())
.collect()
}
#[test]
fn t016_a_blocked_move_toward_completion_is_flagged_and_names_the_blocker() {
let (tmp, store) = make_store();
let _ = tmp;
let (blocker, subject, _) = seed_blocked_pair(&store);
let guard = blocked_move_guard(
&store.config,
&store,
&store.get(IssueSelector::Number(subject.number)).unwrap(),
"done",
)
.unwrap();
assert!(guard.needs_acknowledgement(), "{guard:?}");
let warning = guard.warning().unwrap();
assert!(
warning.contains(&blocker.number.to_string()),
"the warning names the blocking issue: {warning}"
);
assert!(
warning.contains("blocked:"),
"and uses the shared wording every surface renders: {warning}"
);
}
#[test]
fn t016_the_warning_is_the_service_reason_verbatim() {
let (tmp, store) = make_store();
let _ = tmp;
let (_, subject, _) = seed_blocked_pair(&store);
let issue = store.get(IssueSelector::Number(subject.number)).unwrap();
let (issues, done_column) = store.graph_source().unwrap();
let graph = crate::commands::graph_over(&issues, &done_column);
let expected = graph.blocked_state(subject.number).reason;
let guard = blocked_move_guard(&store.config, &store, &issue, "done").unwrap();
assert_eq!(
guard.warning().unwrap(),
format!("blocked: {expected}"),
"the guard must not re-word the service"
);
}
#[test]
fn t016_without_acknowledgement_the_move_is_refused_and_the_file_is_unchanged() {
let (tmp, store) = make_store();
let _ = tmp;
let (_, subject, _) = seed_blocked_pair(&store);
let sel = subject.number.to_string();
let before = fs::read_to_string(store.issues_dir.join(&subject.filename)).unwrap();
let err = apply_guarded(
&store.config,
&store,
&sel,
"done",
Some("abc123"),
None,
false,
None,
)
.unwrap_err();
let text = err.to_string();
assert!(
text.contains("blocked"),
"the refusal names the reason: {text}"
);
assert!(text.contains("--yes"), "and says how to proceed: {text}");
assert_eq!(
fs::read_to_string(store.issues_dir.join(&subject.filename)).unwrap(),
before,
"a refused move must not write (SC-005)"
);
}
#[test]
fn t016_with_acknowledgement_the_move_applies_and_records_exactly_one_event() {
let (tmp, store) = make_store();
let _ = tmp;
let (_, subject, _) = seed_blocked_pair(&store);
let moved = apply_guarded(
&store.config,
&store,
&subject.number.to_string(),
"done",
Some("abc123"),
None,
true,
None,
)
.unwrap();
assert!(moved.acknowledged);
assert_eq!(moved.issue.status.display(), "done");
let reread = store.get(IssueSelector::Number(subject.number)).unwrap();
let events = acknowledged_events(&reread);
assert_eq!(events.len(), 1, "exactly one event, got {events:?}");
assert!(
events[0].contains("done"),
"the event records the decision: {events:?}"
);
}
#[test]
fn t016_rerunning_the_move_is_idempotent() {
let (tmp, store) = make_store();
let _ = tmp;
let (_, subject, _) = seed_blocked_pair(&store);
let sel = subject.number.to_string();
let first = apply_guarded(
&store.config,
&store,
&sel,
"done",
Some("abc"),
None,
true,
None,
)
.unwrap();
assert!(first.acknowledged);
let after_first = store.get(IssueSelector::Number(subject.number)).unwrap();
let second = apply_guarded(
&store.config,
&store,
&sel,
"done",
Some("abc"),
None,
true,
None,
)
.unwrap();
assert!(!second.acknowledged, "a re-run acknowledges nothing");
assert!(
second.warning.is_none(),
"and must not warn again: {:?}",
second.warning
);
let after_second = store.get(IssueSelector::Number(subject.number)).unwrap();
assert_eq!(
acknowledged_events(&after_second).len(),
1,
"no second acknowledgement event"
);
assert_eq!(
after_second.history.events().len(),
after_first.history.events().len(),
"no History growth at all on a re-run"
);
}
#[test]
fn t016_history_is_append_only_across_an_acknowledged_move() {
let (tmp, store) = make_store();
let _ = tmp;
let (blocker, subject, _) = seed_blocked_pair(&store);
let blocker = store.get(IssueSelector::Number(blocker.number)).unwrap();
let before = store.get(IssueSelector::Number(subject.number)).unwrap();
let prior: Vec<String> = before
.history
.events()
.iter()
.map(|e| format!("{e:?}"))
.collect();
assert!(
!blocker.history.events().is_empty(),
"precondition: the edge recorded an event on its canonical owner"
);
apply_guarded(
&store.config,
&store,
&subject.number.to_string(),
"done",
Some("abc"),
None,
true,
None,
)
.unwrap();
let after = store.get(IssueSelector::Number(subject.number)).unwrap();
let now: Vec<String> = after
.history
.events()
.iter()
.map(|e| format!("{e:?}"))
.collect();
assert_eq!(
&now[..prior.len()],
&prior[..],
"prior entries must be byte-identical and in the same order"
);
assert_eq!(
now.len(),
prior.len() + 2,
"a transition decision and an acknowledgement, nothing else"
);
let appended: Vec<HistoryEventKind> = after.history.events()[prior.len()..]
.iter()
.map(|e| e.kind)
.collect();
assert_eq!(
appended,
vec![
HistoryEventKind::TransitionAccepted,
HistoryEventKind::BlockedMoveAcknowledged,
],
"in that order: the seam writes first, then the guard appends its own"
);
}
#[test]
fn t016_moving_to_a_non_completion_column_does_not_warn() {
let (tmp, store) = make_store();
let _ = tmp;
let (_, subject, _) = seed_blocked_pair(&store);
let issue = store.get(IssueSelector::Number(subject.number)).unwrap();
for column in ["todo", "in progress", "in review"] {
assert!(
store
.config
.default_config()
.columns
.iter()
.any(|c| c == column),
"precondition: `{column}` is a real column"
);
let guard = blocked_move_guard(&store.config, &store, &issue, column).unwrap();
assert!(
!guard.needs_acknowledgement(),
"blockers do not block a move to {column}"
);
}
let moved = apply_guarded(
&store.config,
&store,
&subject.number.to_string(),
"in progress",
None,
None,
false,
None,
)
.unwrap();
assert!(!moved.acknowledged);
assert_eq!(moved.issue.status.display(), "in progress");
}
#[test]
fn t016_a_custom_done_column_is_resolved_through_config() {
let (tmp, store) = store_with_done_column("shipped");
let _ = tmp;
let (blocker, subject, _) = seed_blocked_pair(&store);
let issue = store.get(IssueSelector::Number(subject.number)).unwrap();
assert_eq!(
completion_column(&store.config).as_deref(),
Some("shipped"),
"precondition: the configured completion column"
);
let guard = blocked_move_guard(&store.config, &store, &issue, "shipped").unwrap();
assert!(
guard.needs_acknowledgement(),
"moving to the configured completion column must be guarded"
);
assert!(
guard
.warning()
.unwrap()
.contains(&blocker.number.to_string())
);
let err = apply_guarded(
&store.config,
&store,
&subject.number.to_string(),
"shipped",
None,
None,
false,
None,
)
.unwrap_err();
assert!(err.to_string().contains("blocked"), "got: {err}");
let mut done_blocker = store.get(IssueSelector::Number(blocker.number)).unwrap();
done_blocker.status = Status::new("shipped".to_string());
store.update(&done_blocker.filename, &done_blocker).unwrap();
let moved = apply_guarded(
&store.config,
&store,
&subject.number.to_string(),
"shipped",
None,
None,
false,
None,
)
.unwrap();
assert!(
!moved.acknowledged,
"a blocker that reached the completion column does not block"
);
}
#[test]
fn t016_an_unblocked_issue_moves_freely_and_records_nothing() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store);
seed(&store);
let moved = apply_guarded(
&store.config,
&store,
&a.number.to_string(),
"done",
Some("abc"),
None,
false,
None,
)
.unwrap();
assert!(!moved.acknowledged);
assert!(moved.warning.is_none());
assert_eq!(
acknowledged_events(&moved.issue),
[] as [std::string::String; 0]
);
}
}