1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
//! The `tm doctor` `issue_audit_recent` check — ticket hygiene on this week's
//! issues (#7097).
//!
//! Why: `tm issue audit <N>` catches a violation only when someone runs it on
//! that issue. This is the sweep that runs without being asked, over the issues
//! recent enough that fixing them is still cheap.
//!
//! What: [`check_issue_audit_recent`] audits every OPEN issue created in the
//! last [`AUDIT_WINDOW_DAYS`] days off the async executor;
//! [`build_issue_audit_check`] folds the outcome into a [`DoctorCheck`].
//!
//! # Never FAIL
//!
//! Ticket hygiene is not a broken stack, so the worst verdict this check can
//! reach is `Warn`, naming the failing issue numbers. A red `tm doctor` is a
//! signal an operator acts on immediately; a missing milestone is not that.
//!
//! # The Fail-Open Check
//!
//! A `gh` that is absent, unauthenticated, or erroring means the audit did not
//! run — which is NOT the same as running and finding nothing. That folds to
//! [`CheckStatus::Unknown`] with the error quoted, never to `Ok`.
//! `issue_audit_gh_failure_is_not_a_pass` is the test that holds this.
//!
//! Test: `doctor_issue_audit_tests.rs`.
use std::path::{Path, PathBuf};
use crate::core::component_labels::ComponentLabels;
use crate::core::doctor::{CheckStatus, DoctorCheck};
use crate::core::gh_identity::GhEnv;
use crate::core::issue_audit::{IssueAudit, PROJECT_ATTACH_HINT, audit_issue};
use crate::core::issue_audit_gh::{AuditWindow, list_open_issues};
use crate::core::trusty_tools_config::{TrustyToolsConfig, resolve_ticketing};
/// The check's stable name.
pub(super) const CHECK_NAME: &str = "issue_audit_recent";
/// How far back the sweep reaches.
///
/// Why: an issue filed this week is still being worked, so a missing milestone
/// or project is cheap to add. Older issues are backlog archaeology, and
/// reporting them every run would train an operator to ignore the row.
pub const AUDIT_WINDOW_DAYS: i64 = 7;
/// How many failing issues the warning names before it starts counting.
///
/// Why: a busy week can leave dozens of violations, and one doctor row that
/// lists all of them scrolls the rest of the report away. `tm issue audit
/// --recent` is where the full table belongs.
/// Test: `issue_audit_caps_the_named_failures`.
const NAMED_LIMIT: usize = 8;
/// Audit the recently-opened issues and report the result.
///
/// Why: see the module doc — the unprompted half of #7097.
/// What: off-loads the blocking `gh issue list` to `spawn_blocking` (the
/// pattern `check_gh_account` established, so the async executor is not
/// stalled), then folds via [`build_issue_audit_check`]. `project_dir` is the
/// directory `gh` runs in, which is what selects the repository.
/// Test: `build_issue_audit_check` covers every branch of the fold.
pub(super) async fn check_issue_audit_recent(project_dir: Option<&Path>) -> DoctorCheck {
let dir: Option<PathBuf> = project_dir.map(Path::to_path_buf);
let probe = tokio::task::spawn_blocking(move || audit_recent_window(dir.as_deref()))
.await
.unwrap_or_else(|e| Err(format!("issue-audit task failed: {e}")));
build_issue_audit_check(probe)
}
/// Audit every OPEN issue created within [`AUDIT_WINDOW_DAYS`] days.
///
/// Why: the whole blocking half in one place, so the async wrapper above holds
/// no logic of its own.
/// What: resolves the operator's `agents.ticketing` standard, resolves the
/// accepted component labels from the same directory `gh` runs in (#7123),
/// computes the window's start date in UTC, lists the matching open issues with
/// an ambient `gh` identity, and audits each. Every failure — a malformed
/// config block, a missing `gh`, a parse error — becomes an `Err(String)`,
/// which the fold turns into UNDETERMINED rather than a pass.
/// Test: exercised live; the fold's branches are unit-tested.
fn audit_recent_window(project_dir: Option<&Path>) -> Result<Vec<IssueAudit>, String> {
let ticketing = resolve_ticketing(&TrustyToolsConfig::load())
.map_err(|e| format!("the agents.ticketing block did not resolve: {e}"))?;
// #7123: the audited repository's own crate labels satisfy the
// owning-component rule, so the accepted set comes from the same directory
// `gh` reads the issues from.
let components = ComponentLabels::resolve(&ticketing, project_dir);
let since = (chrono::Utc::now() - chrono::Duration::days(AUDIT_WINDOW_DAYS))
.format("%Y-%m-%d")
.to_string();
let window = AuditWindow::Since(since);
let facts =
list_open_issues(&window, project_dir, &GhEnv::default()).map_err(|e| e.to_string())?;
Ok(facts
.iter()
.map(|f| audit_issue(f, &ticketing, &components))
.collect())
}
/// Fold an audit outcome into a [`DoctorCheck`] (pure).
///
/// Why: keeping the verdict pure makes all four branches — clean sweep, empty
/// window, violations found, and could-not-run — testable with no `gh`.
/// What: `Err` → [`CheckStatus::Unknown`] quoting the reason (see "The
/// Fail-Open Check" in the module doc); violations → `Warn` naming every
/// failing issue number and the requirement each missed; otherwise `Ok` with
/// the audited count. Never [`CheckStatus::Fail`].
/// Test: `issue_audit_clean_sweep_is_ok`, `issue_audit_empty_window_is_ok`,
/// `issue_audit_violations_warn_with_the_numbers`,
/// `issue_audit_gh_failure_is_not_a_pass`,
/// `issue_audit_check_never_fails`.
pub(super) fn build_issue_audit_check(probe: Result<Vec<IssueAudit>, String>) -> DoctorCheck {
let audits = match probe {
Ok(audits) => audits,
// #7097: the audit did not run. That is not "ran and found nothing".
Err(reason) => {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Unknown,
format!(
"could not audit recent issues — ticket hygiene UNKNOWN, not clean \
({reason}). `gh` must be installed and authenticated in the project \
directory; run `tm issue audit --recent 20` by hand to see the real state."
),
);
}
};
let failing: Vec<&IssueAudit> = audits.iter().filter(|a| a.failed()).collect();
if failing.is_empty() {
return DoctorCheck::new(
CHECK_NAME,
CheckStatus::Ok,
format!(
"{} open issue(s) from the last {AUDIT_WINDOW_DAYS} days meet the ticketing \
standard",
audits.len()
),
);
}
// #7097: one doctor row, not a backlog dump. The first `NAMED_LIMIT`
// failures name their missed requirements; the rest are counted, because a
// 42-issue list scrolls the whole report off the screen and `tm issue audit
// --recent` prints the full table anyway.
let mut detail = failing
.iter()
.take(NAMED_LIMIT)
.map(|a| format!("#{} ({})", a.number, a.failing_requirements().join(", ")))
.collect::<Vec<_>>()
.join("; ");
if let Some(rest) = failing.len().checked_sub(NAMED_LIMIT).filter(|n| *n > 0) {
detail.push_str(&format!("; and {rest} more"));
}
DoctorCheck::new(
CHECK_NAME,
CheckStatus::Warn,
format!(
// #7952: the project half names the owner-scoped attach — `gh issue
// edit --add-project` exits 0 without attaching.
"{} of {} open issue(s) from the last {AUDIT_WINDOW_DAYS} days violate the \
ticketing standard: {detail}. Fix with `gh issue edit <N> --milestone …` \
and `{PROJECT_ATTACH_HINT}`, or run `tm issue audit <N>` for the detail.",
failing.len(),
audits.len()
),
)
}
#[cfg(test)]
#[path = "doctor_issue_audit_tests.rs"]
mod tests;