pub(crate) mod audit;
pub(crate) mod config;
pub(crate) mod epic;
pub(crate) mod ops;
pub(crate) mod seed_ticketing;
pub(crate) mod standard;
pub(crate) mod standard_components;
pub(crate) mod standard_live;
pub(crate) mod state;
pub(crate) mod validate;
#[cfg(test)]
#[path = "project_model_tests.rs"]
mod project_model_tests;
use std::path::PathBuf;
use crate::cli::IssueCmd;
use crate::commands::ticket::runner::{CommandRunner, RealCommandRunner};
use crate::commands::ticket::system::{
GhTicketSystem, TicketSystem, TicketSystemKind, not_yet_supported,
};
use config::{ModelSource, StateModel, describe_source, load_model_with_source};
use seed_ticketing::{
TicketingSeedOutcome, seed_outcome_result, seed_ticketing_block, ticketing_config_path,
};
use trusty_mpm::core::trusty_tools_config::{
TICKETING_BLOCK_TEMPLATE, TrustyToolsConfig, resolve_ticketing,
};
pub(crate) fn issue(cmd: IssueCmd, system: TicketSystemKind) -> anyhow::Result<()> {
let gh_env = crate::gh_identity::load_gh_env()?;
let runner = RealCommandRunner::with_gh_env(&gh_env);
let backend = match system {
TicketSystemKind::Gh => GhTicketSystem::new(RealCommandRunner::with_gh_env(&gh_env)),
TicketSystemKind::Jira => return Err(not_yet_supported("jira")),
TicketSystemKind::Linear => return Err(not_yet_supported("linear")),
};
dispatch(&backend, &runner, &gh_env, cmd)
}
fn dispatch<S: TicketSystem>(
backend: &S,
runner: &dyn CommandRunner,
gh_env: &trusty_mpm::core::gh_identity::GhEnv,
cmd: IssueCmd,
) -> anyhow::Result<()> {
let ticketing = resolve_ticketing(&TrustyToolsConfig::load())?;
let lifecycle = ticketing.lifecycle_model.as_deref();
match cmd {
IssueCmd::SeedLabels {
config,
dry_run,
only,
} => {
let (model, source) = load_model_with_source(config.as_deref(), lifecycle)?;
let session = crate::commands::tmux_attach::current_tmux_session_name();
let report = ops::seed_labels(
backend,
&model,
&ticketing,
session.as_deref(),
dry_run,
&only,
)
.map_err(|e| with_source(e, &source))?;
if !only.is_empty() {
println!("scoped to --only [{}]", only.join(", "));
}
print_seed_report(&report);
}
IssueCmd::Standard { config } => {
let (model, _source) = load_model_with_source(config.as_deref(), lifecycle)?;
standard::print_standard(
&ticketing,
&model,
runner,
std::env::current_dir().ok().as_deref(),
);
}
IssueCmd::Transition {
issue,
to_state,
config,
note,
} => {
let (model, source) = load_model_with_source(config.as_deref(), lifecycle)?;
let epic_backend =
epic::backend::GhEpicBackend::new(RealCommandRunner::with_gh_env(gh_env));
let hooked = epic::hook::transition_with_tracker_sync(
backend,
&epic_backend,
&model,
issue,
&to_state,
note.as_deref(),
)
.map_err(|e| with_source(e, &source))?;
let report = hooked.report;
if report.no_op {
eprintln!(
"#{issue} is already `{}` — no change made (no label, assignee or comment \
was written)",
report.to
);
if let Some(synced) = hooked.synced {
eprintln!("{}", tracker_sync_line(&synced));
}
return Ok(());
}
let from = report.from.as_deref().unwrap_or("(none)");
println!("transitioned #{issue}: {from} → {}", report.to);
if report.assignee_changed {
println!(" assignee rule applied");
}
if let Some(synced) = hooked.synced {
println!("{}", tracker_sync_line(&synced));
}
}
IssueCmd::Current { issue, config } => {
let (model, source) = load_model_with_source(config.as_deref(), lifecycle)?;
let state =
ops::current(backend, &model, issue).map_err(|e| with_source(e, &source))?;
println!("{state}");
}
IssueCmd::States { config } => {
let (model, source) = load_model_with_source(config.as_deref(), lifecycle)?;
print_states(&model, &source);
}
IssueCmd::SeedConfig { force } => {
seed_config(force)?;
}
IssueCmd::Audit {
issue,
recent,
since,
} => {
audit::run(&ticketing, gh_env, runner, issue, recent, since, lifecycle)?;
}
IssueCmd::Epic(epic_cmd) => {
epic::run(epic_cmd, gh_env, lifecycle)?;
}
IssueCmd::Repair { issue, config } => {
let (model, source) = load_model_with_source(config.as_deref(), lifecycle)?;
let kept = ops::repair(backend, &model, issue).map_err(|e| with_source(e, &source))?;
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());
if report.workstream_skipped {
println!("skipped ws/<session>: no tmux session name to derive it from");
}
}
fn with_source(err: anyhow::Error, source: &ModelSource) -> anyhow::Error {
anyhow::anyhow!("{err:#}\n {}", describe_source(source))
}
fn tracker_sync_line(synced: &epic::sync::SyncReport) -> String {
if synced.unchanged {
format!(
" tracker #{}: phases block already current",
synced.tracker
)
} else {
format!(" tracker #{}: phases block regenerated", synced.tracker)
}
}
fn print_states(model: &StateModel, source: &ModelSource) {
println!("{}", describe_source(source));
println!("states ({}):", model.states.len());
for s in &model.states {
let term = if s.terminal { " [terminal]" } else { "" };
let label = s.label.as_ref().map_or("(no label)", |l| l.name.as_str());
println!(" {} → {}{}", s.name, label, term);
}
println!("transitions ({}):", model.transitions.len());
for t in &model.transitions {
let from = t.from.as_deref().unwrap_or("null");
let note = if t.requires_note {
" [--note required]"
} else {
""
};
println!(" {from} → {} ({:?}){note}", 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());
let config_yaml = ticketing_config_path()
.ok_or_else(|| anyhow::anyhow!("could not resolve home directory for the user config"))?;
let shown = config_yaml.display();
let outcome = seed_ticketing_block(&config_yaml)?;
match outcome {
TicketingSeedOutcome::Created => {
println!("created {shown} with the agents.ticketing block");
}
TicketingSeedOutcome::Appended => {
println!("appended the agents.ticketing block to {shown}");
}
TicketingSeedOutcome::AlreadyPresent => {
println!("{shown} already declares agents.ticketing — left unchanged");
}
TicketingSeedOutcome::AgentsBlockPresent => {
println!(
"{shown} declares `agents:` without `ticketing:` — left unchanged, \
since a second `agents:` key would make the file unreadable. \
Add this entry under the `agents:` block by hand:\n"
);
print!("{TICKETING_BLOCK_TEMPLATE}");
}
}
seed_outcome_result(outcome, &config_yaml)
}
#[cfg(test)]
mod source_annotation_tests {
use super::*;
#[test]
fn verb_failure_names_the_embedded_default_source_7580() {
let source = ModelSource::EmbeddedDefault {
searched_from: PathBuf::from("/repo/crates/trusty-mpm"),
};
let err = with_source(
anyhow::anyhow!("unknown target state `status:merged`; valid states: [queued]"),
&source,
);
let text = format!("{err}");
assert!(text.contains("unknown target state"), "{text}");
assert!(text.contains("BUILT-IN default"), "{text}");
}
#[test]
fn verb_failure_names_a_file_source() {
let source = ModelSource::File(PathBuf::from("/repo/issue-state.yaml"));
let text = format!("{}", with_source(anyhow::anyhow!("boom"), &source));
assert!(
text.contains("state model: /repo/issue-state.yaml"),
"{text}"
);
}
}