tix/triage.rs
1//! Triage inbox: configuration, entry metadata, and the inbox query.
2//!
3//! Mission `triage-inbox-review-flow-01M3C600`. Triage is a **view over normal
4//! statuses**, not a second workflow: the inbox is whichever columns the optional
5//! `[triage]` section names, membership and category come from the #183 service,
6//! and every decision is an append-only `History` event on the issue file
7//! (C-001, C-002).
8//!
9//! WP01 owns two things:
10//!
11//! - [`config`] — the section's schema and its validation through #183;
12//! - the two optional frontmatter keys that make the view answerable, which is
13//! what this file holds: when an issue entered, and from where.
14//!
15//! WP02 adds [`inbox`] — the read-only query over those same two things: scope,
16//! entries, age, and counts, all derived from one issue snapshot.
17//!
18//! WP03 adds [`decision`] — the four decisions and their refusals — and
19//! [`history`], the event intent that records one of them. Both stay inside the
20//! same three things: normal statuses, normal relationships, and the existing
21//! `## History` section.
22//!
23//! WP07 adds [`import_policy`] — where a *remote* import places a newly
24//! created file, so an import cannot enter the board through the side door.
25//! It is a policy over the same columns, not a status rule of its own.
26//!
27//! There is no queue store, no row, and no second issue set anywhere in this
28//! module. Everything downstream derives from an issue snapshot.
29
30pub mod batch;
31pub mod config;
32pub mod decision;
33pub mod history;
34pub mod import_policy;
35pub mod inbox;
36
37pub use batch::TriagePlan;
38pub use config::TriageConfig;
39pub use decision::{
40 DecisionKind, DecisionOutcome, DecisionService, DuplicateTarget, Refusal, TriageDecision,
41};
42pub use history::TriageDecisionRecord;
43
44use crate::model::Issue;
45
46/// The entry timestamp to compute queue age from, with its one documented
47/// fallback (T003, FR-005).
48///
49/// Research R4 rejects `created` (it predates the queue) and `updated` (it moves
50/// on every edit) as age proxies, so an issue that recorded no entry time has an
51/// **unknown** age — and this enum says so rather than substituting a date that
52/// measures something else (data-model §2: "must not silently invent an exact
53/// age").
54///
55/// The shape is the point. Returning `None` from an `Option<&str>` would let a
56/// caller `unwrap_or` a borrowed `updated` without appearing to make a decision;
57/// [`EntryTime::Unknown`] has to be matched before any number can be produced, so
58/// the fallback is written down at every call site or the code does not compile.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum EntryTime<'a> {
61 /// The issue's `entered_at`, verbatim.
62 Recorded(&'a str),
63 /// The issue has no `entered_at`. Report the age as unknown.
64 Unknown,
65}
66
67/// The timestamp queue age is derived from.
68///
69/// The **only** supported source of an inbox age; see [`EntryTime`] for why the
70/// fallback is a variant rather than a date.
71#[must_use]
72pub fn entry_time(issue: &Issue) -> EntryTime<'_> {
73 match issue.entered_at.as_deref() {
74 Some(at) => EntryTime::Recorded(at),
75 None => EntryTime::Unknown,
76 }
77}
78
79/// The origin of the issue's current entry, or `None` when it recorded none.
80#[must_use]
81pub fn entry_source(issue: &Issue) -> Option<&str> {
82 issue.source.as_deref()
83}
84
85/// Stamp an issue as having entered triage at `at`, with an optional `source`.
86///
87/// Called on **enter and re-enter** (data-model §2, T003): a defer refreshes
88/// [`Issue::entered_at`], so the queue measures the wait since the issue most
89/// recently joined (data-model §5). The value is therefore overwritten, never
90/// merged — keeping the oldest stamp would answer "how long has this been in the
91/// inbox" with "how long has this existed".
92///
93/// `source` takes exactly what the caller passes, `None` included. A source
94/// describes *this* entry, so carrying a previous entry's origin forward would
95/// credit an entry to something that did not cause it.
96///
97/// Frontmatter only. The `History` event that records the decision belongs to the
98/// decision service (WP03); WP01 owns the metadata and appends nothing to
99/// [`crate::model::History`].
100pub fn record_entry(issue: &mut Issue, at: &str, source: Option<&str>) {
101 issue.entered_at = Some(at.to_string());
102 issue.source = source.map(str::to_string);
103}
104
105/// Stamp an issue that **lands in the inbox**, and leave every other one alone.
106///
107/// The one rule every entry path shares — a create (CLI, TUI form, MCP), an import, a
108/// defer — and the question it answers is the *landing column*, not which flag or policy
109/// produced it: an issue in the inbox has the stamp its age is measured from, and an
110/// issue that never entered the queue gains nothing.
111///
112/// One function rather than an `if` per surface, because that is exactly how the others
113/// drifted: the CLI's create core had a copy and the TUI's form did not, and the
114/// import's copy tested `Mapping::routed` — a *policy* notion — so an import under
115/// `--triage-policy remote` (or a default whose remote-mapped column happens to be the
116/// entry column) landed in the queue unstamped, and the queue reported `unknown` for an
117/// issue it was listing.
118///
119/// `source` is the origin data-model §2 documents for this path (`new`, `import`).
120///
121/// # Errors
122///
123/// Propagates a `[triage]` section the board cannot satisfy — the same refusal
124/// [`inbox::scope`] gives every other triage path.
125pub fn record_entry_if_inbox(
126 config: &crate::config::Config,
127 issue: &mut Issue,
128 source: &str,
129) -> crate::error::Result<()> {
130 if let Some(scope) = inbox::scope(config)? {
131 let landing = issue.status.display();
132 if landing == scope.entry_status || landing == scope.deferred_status {
133 record_entry(issue, &crate::model::utc_now_rfc3339(), Some(source));
134 }
135 }
136 Ok(())
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142 use crate::model::{Issue, Status};
143
144 /// Every key the model always writes, plus nothing triage-specific: the shape
145 /// of every issue file that exists today (research R1).
146 const LEGACY: &str = "---\nnumber: 1\ntitle: Legacy\nstatus: todo\nlabels: []\n\
147 priority: medium\nfiles: []\ncreated: 2026-01-01\n\
148 updated: 2026-01-01\n---\n\nBody text.";
149
150 fn legacy_issue() -> Issue {
151 Issue::parse(LEGACY).expect("the baseline fixture parses")
152 }
153
154 // Every WP01 test is named `triage_config_*`, including these, because the
155 // work package's gate is `cargo test triage_config` and it must select the
156 // whole work package — entry metadata included.
157
158 #[test]
159 fn triage_config_metadata_is_absent_on_a_legacy_issue_and_stays_that_way() {
160 let issue = legacy_issue();
161 assert_eq!(issue.entered_at, None, "no queue, no stamp");
162 assert_eq!(issue.source, None, "and no origin");
163
164 let out = issue.to_file_contents();
165 assert!(
166 !out.contains("entered_at") && !out.contains("source:"),
167 "an issue that never entered triage must not gain a key (C-001):\n{out}"
168 );
169 assert_eq!(out, LEGACY, "so a legacy file round-trips byte-identically");
170 }
171
172 #[test]
173 fn triage_config_metadata_round_trips_through_frontmatter() {
174 let mut issue = legacy_issue();
175 record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("import"));
176
177 let out = issue.to_file_contents();
178 assert!(
179 out.contains("entered_at: 2026-09-30T09:12:44Z"),
180 "the stamp reaches the file:\n{out}"
181 );
182 assert!(
183 out.contains("source: import"),
184 "and so does the origin:\n{out}"
185 );
186
187 let reloaded = Issue::parse(&out).expect("re-parses");
188 assert_eq!(
189 reloaded.entered_at.as_deref(),
190 Some("2026-09-30T09:12:44Z"),
191 "verbatim, no reformatting"
192 );
193 assert_eq!(reloaded.source.as_deref(), Some("import"));
194 assert_eq!(
195 reloaded.to_file_contents(),
196 out,
197 "and the second write is byte-identical"
198 );
199 }
200
201 #[test]
202 fn triage_config_metadata_entry_time_is_read_not_reconstructed() {
203 let mut issue = legacy_issue();
204 record_entry(&mut issue, "2026-09-30T09:12:44Z", None);
205 assert_eq!(
206 entry_time(&issue),
207 EntryTime::Recorded("2026-09-30T09:12:44Z")
208 );
209 assert_eq!(entry_source(&issue), None, "source stays optional");
210 }
211
212 #[test]
213 fn triage_config_metadata_falls_back_to_unknown_and_never_to_updated() {
214 // T003 / R4. The fixture is old enough that `created` and `updated` would
215 // both produce a confident, wrong age — the issue is not *in the queue*
216 // and has been for however long the file says it has existed.
217 let issue = legacy_issue();
218 assert_eq!(issue.updated, "2026-01-01", "the bait");
219 assert_eq!(issue.created, "2026-01-01");
220
221 // `EntryTime::Unknown` rather than `None`: the caller has to match the
222 // variant before it can put a number on screen.
223 match entry_time(&issue) {
224 EntryTime::Recorded(at) => {
225 panic!("a legacy issue has no entry time; `updated` offered `{at}`")
226 }
227 EntryTime::Unknown => {}
228 }
229 }
230
231 #[test]
232 fn triage_config_metadata_reentry_overwrites_the_stamp_and_the_source() {
233 let mut issue = legacy_issue();
234 record_entry(&mut issue, "2026-09-01T00:00:00Z", Some("new"));
235 // A defer re-enters, so the wait restarts (data-model §5): the *most*
236 // recent entry is what the queue measures.
237 record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("defer"));
238
239 assert_eq!(
240 entry_time(&issue),
241 EntryTime::Recorded("2026-09-30T09:12:44Z"),
242 "the newer stamp wins; keeping the oldest would measure the wrong wait"
243 );
244 assert_eq!(
245 entry_source(&issue),
246 Some("defer"),
247 "the source describes this entry, not the previous one"
248 );
249
250 // ...and an entry with no origin does not inherit the last one's.
251 record_entry(&mut issue, "2026-10-01T00:00:00Z", None);
252 assert_eq!(entry_source(&issue), None, "no stale origin survives");
253 }
254
255 #[test]
256 fn triage_config_metadata_recording_never_touches_the_status() {
257 // T006: "no accidental status conversion". Entry metadata is frontmatter
258 // about *when*; the column is the workflow's to change (C-002).
259 let mut issue = legacy_issue();
260 let before = issue.status.clone();
261 assert_eq!(before, Status::new("todo"));
262
263 record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("new"));
264
265 assert_eq!(
266 issue.status, before,
267 "stamping an entry must not move the issue"
268 );
269 assert_eq!(
270 Issue::parse(&issue.to_file_contents()).unwrap().status,
271 before,
272 "nor may the round trip"
273 );
274 }
275
276 #[test]
277 fn triage_config_metadata_is_optional_and_never_a_store_row() {
278 // C-001: the metadata lives in the issue file. One file, one parse — there
279 // is no second structure to keep in step, so a stale in-memory stamp can
280 // simply be re-read from the frontmatter it was written to.
281 let mut issue = legacy_issue();
282 record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("new"));
283 let written = Issue::parse(&issue.to_file_contents()).unwrap();
284
285 assert_eq!(written.number, 1, "the issue number is unchanged");
286 assert_eq!(written.filename, issue.filename, "as is the source file");
287 assert_eq!(written.entered_at, issue.entered_at);
288 }
289}