use std::path::Path;
use trusty_mpm::core::component_labels::ComponentLabels;
use trusty_mpm::core::gh_identity::GhEnv;
use trusty_mpm::core::issue_audit::{
AuditRow, IssueAudit, IssueFacts, audit_issue, render_audit, render_summary,
};
use trusty_mpm::core::issue_audit_gh::{AuditWindow, list_open_issues, view_issue};
use trusty_mpm::core::trusty_tools_config::ResolvedTicketing;
use anyhow::Context as _;
use crate::commands::issue::epic::audit_rows::epic_rows;
use crate::commands::issue::epic::backend::GhEpicBackend;
use crate::commands::issue::epic::state_model;
use crate::commands::ticket::labels::gh_list_repo_labels;
use crate::commands::ticket::runner::{CommandRunner, RealCommandRunner};
const CRATE_LABEL_DESCRIPTION_PREFIX: &str = "Crate:";
pub(crate) fn run(
ticketing: &ResolvedTicketing,
gh_env: &GhEnv,
runner: &dyn CommandRunner,
issue: Option<u64>,
recent: Option<usize>,
since: Option<String>,
lifecycle: Option<&Path>,
) -> anyhow::Result<()> {
let cwd = std::env::current_dir().ok();
let components = ComponentLabels::resolve(ticketing, cwd.as_deref());
let components = widen_with_live_crate_labels(components, runner);
let view = |number: u64| view_issue(number, None, gh_env);
let list = |window: &AuditWindow| list_open_issues(window, None, gh_env);
let epic_backend = GhEpicBackend::new(RealCommandRunner::with_gh_env(gh_env));
let epic_rows_for = |number: u64| {
let model = state_model(lifecycle)?;
epic_rows(&epic_backend, number, &model).with_context(|| format!("epic rows for #{number}"))
};
let reads = AuditReads {
view: &view,
list: &list,
epic_rows_for: &epic_rows_for,
};
let audits = collect_audits(issue, recent, since, ticketing, &components, &reads)?;
print!("{}", render_report(&audits));
exit_result(&audits)
}
struct AuditReads<'a> {
view: &'a dyn Fn(u64) -> anyhow::Result<IssueFacts>,
list: &'a dyn Fn(&AuditWindow) -> anyhow::Result<Vec<IssueFacts>>,
epic_rows_for: &'a dyn Fn(u64) -> anyhow::Result<Vec<AuditRow>>,
}
fn collect_audits(
issue: Option<u64>,
recent: Option<usize>,
since: Option<String>,
ticketing: &ResolvedTicketing,
components: &ComponentLabels,
reads: &AuditReads<'_>,
) -> anyhow::Result<Vec<IssueAudit>> {
match (issue, recent, since) {
(Some(number), _, _) => {
let facts = (reads.view)(number)?;
let mut audit = audit_issue(&facts, ticketing, components);
audit.rows.extend((reads.epic_rows_for)(number)?);
Ok(vec![audit])
}
(None, Some(n), _) => audit_window(&AuditWindow::Recent(n), ticketing, components, reads),
(None, None, Some(date)) => {
audit_window(&AuditWindow::Since(date), ticketing, components, reads)
}
(None, None, None) => anyhow::bail!(
"name an issue number, or pass --recent <n> / --since <YYYY-MM-DD> to audit a window"
),
}
}
fn widen_with_live_crate_labels(
components: ComponentLabels,
runner: &dyn CommandRunner,
) -> ComponentLabels {
let Ok(labels) = gh_list_repo_labels(runner) else {
return components;
};
let extra = labels
.into_iter()
.filter(|l| {
l.description
.trim_start()
.starts_with(CRATE_LABEL_DESCRIPTION_PREFIX)
})
.map(|l| l.name);
ComponentLabels::from_names(components.names().iter().cloned().chain(extra))
}
fn audit_window(
window: &AuditWindow,
ticketing: &ResolvedTicketing,
components: &ComponentLabels,
reads: &AuditReads<'_>,
) -> anyhow::Result<Vec<IssueAudit>> {
Ok((reads.list)(window)?
.iter()
.map(|f| audit_issue(f, ticketing, components))
.collect())
}
fn render_report(audits: &[IssueAudit]) -> String {
match audits {
[one] => render_audit(one),
many => render_summary(many),
}
}
fn exit_result(audits: &[IssueAudit]) -> anyhow::Result<()> {
let failing: Vec<String> = audits
.iter()
.filter(|a| a.failed())
.map(|a| format!("#{}", a.number))
.collect();
if failing.is_empty() {
return Ok(());
}
anyhow::bail!(
"{} issue(s) violate the ticketing standard: {}",
failing.len(),
failing.join(", ")
)
}
#[cfg(test)]
mod tests {
use std::cell::RefCell;
use super::*;
use crate::commands::issue::config::load_model_in;
use crate::commands::ticket::runner::CommandOutput;
use trusty_mpm::core::issue_audit::Verdict;
use trusty_mpm::core::trusty_tools_config::{TrustyToolsConfig, resolve_ticketing};
fn standard() -> ResolvedTicketing {
resolve_ticketing(&TrustyToolsConfig::default()).expect("the defaults resolve")
}
struct FakeRunner(RefCell<Option<anyhow::Result<CommandOutput>>>);
impl CommandRunner for FakeRunner {
fn run(&self, _program: &str, _args: &[&str]) -> anyhow::Result<CommandOutput> {
self.0
.borrow_mut()
.take()
.unwrap_or_else(|| anyhow::bail!("FakeRunner called more than once"))
}
}
fn ok_out(stdout: &str) -> FakeRunner {
FakeRunner(RefCell::new(Some(Ok(CommandOutput {
success: true,
stdout: stdout.to_string(),
stderr: String::new(),
}))))
}
fn fail_out() -> FakeRunner {
FakeRunner(RefCell::new(Some(Ok(CommandOutput {
success: false,
stdout: String::new(),
stderr: "gh: not authenticated".to_string(),
}))))
}
#[test]
fn widen_adds_a_crate_described_label() {
let runner = ok_out(
r#"[{"name": "trusty-audit", "color": "", "description": "Crate: trusty-audit"}]"#,
);
let widened = widen_with_live_crate_labels(
ComponentLabels::from_names(["trusty-mpm".into()]),
&runner,
);
assert!(
widened.accepts("trusty-audit"),
"set was {:?}",
widened.names()
);
assert!(widened.accepts("trusty-mpm"), "the original set is kept");
}
#[test]
fn widen_ignores_a_non_crate_label() {
let runner = ok_out(
r#"[{"name": "enhancement", "color": "", "description": "New feature or request"}]"#,
);
let widened = widen_with_live_crate_labels(
ComponentLabels::from_names(["trusty-mpm".into()]),
&runner,
);
assert!(
!widened.accepts("enhancement"),
"set was {:?}",
widened.names()
);
}
#[test]
fn widen_on_gh_failure_keeps_the_set() {
let runner = fail_out();
let original = ComponentLabels::from_names(["trusty-mpm".into()]);
let widened = widen_with_live_crate_labels(original.clone(), &runner);
assert_eq!(widened, original, "a gh failure must not change the set");
}
fn facts(json: &str) -> IssueFacts {
serde_json::from_str(json).expect("the fixture parses")
}
fn components() -> ComponentLabels {
ComponentLabels::from_names([String::from("trusty-mpm")])
}
const COMPLIANT: &str = r#"{
"number": 7093,
"milestone": {"title": "Backlog · mpm/core"},
"projectItems": [{"title": "trusty-mpm"}],
"labels": [{"name": "trusty-mpm"}],
"comments": [],
"state": "OPEN",
"createdAt": "2026-09-08T00:30:20Z",
"parent": null,
"blockedBy": {"nodes": [], "totalCount": 0},
"subIssues": {"nodes": [], "totalCount": 0}
}"#;
const NO_PROJECT: &str = r#"{
"number": 7104,
"milestone": {"title": "Backlog · mpm/core"},
"projectItems": [],
"labels": [{"name": "enhancement"}],
"comments": [],
"state": "OPEN",
"createdAt": "2026-09-08T02:51:28Z",
"parent": null,
"blockedBy": {"nodes": [], "totalCount": 0},
"subIssues": {"nodes": [], "totalCount": 0}
}"#;
#[test]
fn single_issue_output_is_the_per_requirement_report() {
let audits = vec![audit_issue(&facts(COMPLIANT), &standard(), &components())];
let text = render_report(&audits);
assert!(text.starts_with("#7093\n"), "{text}");
assert!(text.contains("milestone"), "{text}");
assert!(!text.contains("verdict"), "not the table: {text}");
}
#[test]
fn a_window_prints_the_summary_table() {
let audits = vec![
audit_issue(&facts(COMPLIANT), &standard(), &components()),
audit_issue(&facts(NO_PROJECT), &standard(), &components()),
];
let text = render_report(&audits);
assert!(text.contains("verdict"), "the table header renders: {text}");
assert!(text.contains("#7104"), "{text}");
assert!(text.contains("2 issue(s) audited, 1 with a FAIL"), "{text}");
}
#[test]
fn a_failing_audit_exits_nonzero() {
let audits = vec![audit_issue(&facts(NO_PROJECT), &standard(), &components())];
let err = exit_result(&audits).expect_err("a violation must exit 1");
assert!(err.to_string().contains("#7104"), "{err}");
}
#[test]
fn a_passing_audit_exits_zero() {
let audits = vec![audit_issue(&facts(COMPLIANT), &standard(), &components())];
assert!(exit_result(&audits).is_ok());
}
#[test]
fn a_window_audit_never_loads_the_state_model() {
let missing = Path::new("/nonexistent/issue-state.yaml");
let load = || -> anyhow::Result<String> {
let (model, _source) = load_model_in(Path::new("/nonexistent"), Some(missing), None)?;
Ok(model.label_config.status_prefix)
};
assert!(load().is_err(), "the fixture model path must be unloadable");
let view = |_: u64| Ok(facts(COMPLIANT));
let list = |_: &AuditWindow| Ok(vec![facts(COMPLIANT), facts(NO_PROJECT)]);
let epic_rows_for = |_: u64| -> anyhow::Result<Vec<AuditRow>> {
let prefix = load()?;
Ok(vec![AuditRow::new("phase linkage", Verdict::Info, prefix)])
};
let reads = AuditReads {
view: &view,
list: &list,
epic_rows_for: &epic_rows_for,
};
let window = collect_audits(None, Some(5), None, &standard(), &components(), &reads)
.expect("a windowed audit never reads the model");
assert_eq!(window.len(), 2, "{window:?}");
let err = collect_audits(Some(7093), None, None, &standard(), &components(), &reads)
.expect_err("the single-issue path is the one that loads the model");
assert!(
format!("{err:#}").contains("failed to read issue-state config"),
"{err:#}"
);
}
#[test]
fn an_empty_window_is_not_a_failure() {
assert!(exit_result(&[]).is_ok());
assert!(render_report(&[]).contains("0 issue(s) audited"));
}
}