fn release_command(
input: &Path,
state_machine_path: Option<&Path>,
task_id_str: Option<&str>,
all: bool,
rhei_scope: &[String],
dry_run: bool,
) -> MietteResult<()> {
if task_id_str.is_some() == all {
return Err(miette!(
help = ticket_id_required_help(),
"`rhei release` takes either one ticket — `rhei release <ticket-id>`, or \
`--task <id>` — or `--all`, which sweeps every claimed ticket in scope"
));
}
let input_buf = normalize_workspace_input(input);
let input = input_buf.as_path();
let loaded = load_plan(input)?;
let scope = resolve_rhei_scope(&loaded, rhei_scope)?;
let resolved = resolve_state_machines_for_loaded_plan(input, &loaded, state_machine_path)?;
let machines = resolved.validator_set();
let targets = match task_id_str {
Some(id) => vec![release_target_by_id(&loaded, id, &scope, &machines)?],
None => {
report_panta_scope_narrowed(&loaded, "release", &scope);
claimed_tickets(&loaded, &machines, &scope)
}
};
if targets.is_empty() {
println!("No claimed tickets to release.");
return Ok(());
}
for target in &targets {
let verb = if dry_run { "Would release" } else { "Released" };
println!("{verb} Task {} (was assigned to {})", target.id, target.assignee);
if let Some(initial) = target.initial_state.as_deref() {
if normalized_state_name(&target.state, machines.for_task_str(&target.id)) != initial {
println!(
" note: still in '{}'. `rhei next` claims from '{}', so move it back with \
`rhei transition --task {} --from {} --to {}` if it should be picked up \
again.",
target.state, initial, target.id, target.state, initial
);
}
}
}
if dry_run {
println!("\nDry run — nothing was changed.");
return Ok(());
}
for target in &targets {
remove_task_assignee(&target.file, &target.local_id, &target.id)?;
}
Ok(())
}
struct ReleaseTarget {
id: String,
local_id: String,
assignee: String,
state: String,
initial_state: Option<String>,
file: PathBuf,
}
fn release_target_by_id(
loaded: &LoadedPlan,
task_id_str: &str,
scope: &RheiScope,
machines: &rhei_validator::MachineSet,
) -> MietteResult<ReleaseTarget> {
let task_id_str = resolve_cli_task_id(loaded, task_id_str, scope)?;
let target_id = parse_task_id(&task_id_str);
let task = find_task_by_id(&loaded.rhei.tasks, &target_id)
.ok_or_else(|| miette!(
help = task_id_help(),
"task '{}' not found in the plan", task_id_str))?;
let Some(assignee) = task.assignee.clone() else {
return Err(miette!(
help = "nothing to release. See who holds what with: rhei list <plan>",
"Task {} holds no claim — it has no **Assignee:** to release",
task_id_str
));
};
Ok(release_target(loaded, task, assignee, machines.for_task(&task.id)))
}
fn claimed_tickets(
loaded: &LoadedPlan,
machines: &rhei_validator::MachineSet,
scope: &RheiScope,
) -> Vec<ReleaseTarget> {
let mut all = Vec::new();
collect_plan_tasks(&loaded.rhei.tasks, &mut all);
all.into_iter()
.filter(|task| task_in_rhei_scope(scope, &task.id.to_string()))
.filter(|task| !is_terminal_state(task.state.as_str(), machines.for_task(&task.id)))
.filter_map(|task| {
task.assignee
.clone()
.map(|assignee| release_target(loaded, task, assignee, machines.for_task(&task.id)))
})
.collect()
}
fn release_target(
loaded: &LoadedPlan,
task: &rhei_core::ast::Task,
assignee: String,
machine: &rhei_validator::StateMachine,
) -> ReleaseTarget {
let id = task.id.to_string();
let file = loaded.task_file(&id, Path::new("."));
ReleaseTarget {
local_id: rhei_local_id_of(task),
id,
assignee,
state: task.state.clone(),
initial_state: initial_state_for_node(machine, &task.kind, task.profile_level()).ok(),
file,
}
}
fn remove_task_assignee(
task_file: &Path,
local_id: &str,
qualified_id: &str,
) -> MietteResult<()> {
let raw = fs::read_to_string(task_file)
.map_err(|err| file_io_report(task_file, "failed to read plan file", err))?;
let mut out: Vec<String> = Vec::with_capacity(raw.lines().count());
let mut in_target_task = false;
let mut target_found = false;
let mut removed = false;
let mut in_code_block = false;
for line in raw.lines() {
if let Some((_, id)) = node_heading_outside_code(line, &mut in_code_block) {
in_target_task = id == local_id;
target_found |= in_target_task;
}
if !in_code_block && in_target_task && line.starts_with("**Assignee:**") {
removed = true;
continue;
}
out.push(line.to_string());
}
if !target_found {
return Err(miette!(
help = task_id_help(),
"task '{}' not found in {}", qualified_id, task_file.display()));
}
if !removed {
return Err(miette!(
help = task_moved_help(),
"task '{}' has no **Assignee:** line in {}; the file changed since it was read",
qualified_id,
task_file.display()
));
}
let mut output = out.join("\n");
if raw.ends_with('\n') {
output.push('\n');
}
write_file_atomic(task_file, &output)
}