Skip to main content

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}