pub(crate) mod audit_rows;
pub(crate) mod backend;
pub(crate) mod close;
pub(crate) mod create;
pub(crate) mod defer;
pub(crate) mod hook;
pub(crate) mod plan;
pub(crate) mod render;
pub(crate) mod sync;
#[cfg(test)]
#[path = "tests.rs"]
mod tests;
use std::path::Path;
use crate::cli::EpicCmd;
use crate::commands::issue::config::{StateModel, load_model_with_source};
use crate::commands::ticket::runner::RealCommandRunner;
use backend::GhEpicBackend;
use create::{CreateOptions, CreateReport};
pub(crate) fn run(
cmd: EpicCmd,
gh_env: &trusty_mpm::core::gh_identity::GhEnv,
lifecycle: Option<&Path>,
) -> anyhow::Result<()> {
let backend = GhEpicBackend::new(RealCommandRunner::with_gh_env(gh_env));
match cmd {
EpicCmd::Create {
from,
milestone,
component,
phase_type,
project,
session,
tracker,
dry_run,
} => {
let opts = CreateOptions {
plan_path: from,
milestone: require_milestone(milestone)?,
components: require_components(component)?,
phase_type,
project,
session: require_session(session)?,
tracker,
dry_run,
model: state_model(lifecycle)?,
};
let report = create::create(&backend, &opts)?;
print_create(&report);
}
EpicCmd::Sync { epic } => {
let report = sync::sync(&backend, epic, &state_model(lifecycle)?)?;
if report.unchanged {
println!(
"#{}: phases block already matches its {} child issue(s) — nothing written",
report.tracker, report.rows
);
} else {
println!(
"#{}: phases block regenerated from {} child issue(s)",
report.tracker, report.rows
);
}
}
EpicCmd::Defer {
epic,
item,
why,
destination,
} => {
let opts = defer::DeferOptions {
item,
why,
destination,
};
let report = defer::defer(&backend, epic, &opts)?;
if report.unchanged {
println!(
"#{}: deferred block already carries this row — nothing written",
report.tracker
);
} else {
println!("#{}: deferred row appended: {}", report.tracker, report.row);
}
}
EpicCmd::Close { epic, evidence } => {
let report = close::close(&backend, epic, &evidence)?;
let posted = if report.comment_posted {
"closing comment posted"
} else {
"closing comment was already on the record"
};
println!(
"#{}: closed — {} phase issue(s) closed, {} outcome line(s), {posted}",
report.tracker,
report.phases.len(),
report.outcomes
);
}
}
Ok(())
}
pub(crate) fn state_model(lifecycle: Option<&Path>) -> anyhow::Result<StateModel> {
let (model, _source) = load_model_with_source(None, lifecycle)?;
Ok(model)
}
fn require_milestone(milestone: Option<String>) -> anyhow::Result<String> {
milestone.filter(|m| !m.trim().is_empty()).ok_or_else(|| {
anyhow::anyhow!(
"`--milestone <TITLE>` is required — every issue this files needs one. Run \
`tm issue standard` for the live list"
)
})
}
fn require_components(components: Vec<String>) -> anyhow::Result<Vec<String>> {
let components: Vec<String> = components
.into_iter()
.filter(|c| !c.trim().is_empty())
.collect();
if components.is_empty() {
anyhow::bail!(
"`--component <LABEL>` is required at least once — every issue this files needs one. \
Run `tm issue standard` for the labels this workspace's crates map to"
);
}
Ok(components)
}
fn require_session(session: Option<String>) -> anyhow::Result<String> {
session
.filter(|s| !s.trim().is_empty())
.or_else(crate::commands::tmux_attach::current_tmux_session_name)
.ok_or_else(|| anyhow::anyhow!(
"no workstream session name — this shell's own tmux pane ($TMUX_PANE) is unset or its \
session could not be read, so `ws/<session>` cannot be derived. Pass `--session <NAME>`"
))
}
fn print_create(report: &CreateReport) {
let prefix = if report.dry_run { "[dry-run] " } else { "" };
match report.tracker {
Some(n) => println!("{prefix}ISSUE: #{n}"),
None => println!("{prefix}ISSUE: (would be filed)"),
}
for (phase, issue) in &report.filed {
if report.dry_run {
println!("{prefix}PHASE_{phase} → (would be filed)");
} else {
println!("PHASE_{phase} → #{issue}");
}
}
for title in &report.skipped {
println!("{prefix}already filed, skipped: {title}");
}
for line in &report.waived {
println!("{prefix}no project attached — waiver comment posted on {line}");
}
println!("{prefix}plan: {}", report.plan_url);
}