pub(crate) mod config;
pub(crate) mod ops;
pub(crate) mod state;
pub(crate) mod validate;
use std::path::PathBuf;
use crate::cli::IssueCmd;
use crate::commands::ticket::runner::RealCommandRunner;
use crate::commands::ticket::system::{
GhTicketSystem, TicketSystem, TicketSystemKind, not_yet_supported,
};
use config::{StateModel, load_model};
pub(crate) fn issue(cmd: IssueCmd, system: TicketSystemKind) -> anyhow::Result<()> {
let gh_env = crate::gh_identity::load_gh_env()?;
let backend = match system {
TicketSystemKind::Gh => {
GhTicketSystem::new(RealCommandRunner::with_env(gh_env.vars().to_vec()))
}
TicketSystemKind::Jira => return Err(not_yet_supported("jira")),
TicketSystemKind::Linear => return Err(not_yet_supported("linear")),
};
dispatch(&backend, cmd)
}
fn dispatch<S: TicketSystem>(backend: &S, cmd: IssueCmd) -> anyhow::Result<()> {
match cmd {
IssueCmd::SeedLabels { config, dry_run } => {
let model = load_model(config.as_deref())?;
let report = ops::seed_labels(backend, &model, dry_run)?;
print_seed_report(&report);
}
IssueCmd::Transition {
issue,
to_state,
config,
note,
} => {
let model = load_model(config.as_deref())?;
let report = ops::transition(backend, &model, issue, &to_state, note.as_deref())?;
let from = report.from.as_deref().unwrap_or("(none)");
println!("transitioned #{issue}: {from} → {}", report.to);
if report.assignee_changed {
println!(" assignee rule applied");
}
}
IssueCmd::Current { issue, config } => {
let model = load_model(config.as_deref())?;
let state = ops::current(backend, &model, issue)?;
println!("{state}");
}
IssueCmd::States { config } => {
let model = load_model(config.as_deref())?;
print_states(&model);
}
IssueCmd::SeedConfig { force } => {
seed_config(force)?;
}
IssueCmd::Repair { issue, config } => {
let model = load_model(config.as_deref())?;
let kept = ops::repair(backend, &model, issue)?;
println!("repaired #{issue}: resolved to `{kept}`");
}
}
Ok(())
}
fn print_seed_report(report: &ops::SeedReport) {
if report.dry_run {
println!("[dry-run] would create {} label(s):", report.created.len());
} else {
println!("created {} label(s):", report.created.len());
}
for name in &report.created {
println!(" + {name}");
}
println!("already present: {} label(s)", report.already_present.len());
}
fn print_states(model: &StateModel) {
println!("states ({}):", model.states.len());
for s in &model.states {
let term = if s.terminal { " [terminal]" } else { "" };
println!(" {} → {}{}", s.name, s.label.name, term);
}
println!("transitions ({}):", model.transitions.len());
for t in &model.transitions {
let from = t.from.as_deref().unwrap_or("null");
println!(" {from} → {} ({:?})", t.to, t.trigger);
}
}
fn seed_config(force: bool) -> anyhow::Result<()> {
let path: PathBuf = config::user_config_path()
.ok_or_else(|| anyhow::anyhow!("could not resolve home directory for the user config"))?;
if path.exists() && !force {
anyhow::bail!(
"config already exists at {} — pass --force to overwrite",
path.display()
);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
anyhow::anyhow!("failed to create config dir {}: {e}", parent.display())
})?;
}
std::fs::write(&path, config::DEFAULT_MODEL_YAML)
.map_err(|e| anyhow::anyhow!("failed to write {}: {e}", path.display()))?;
println!("wrote default issue-state model to {}", path.display());
Ok(())
}