tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
#[allow(clippy::wildcard_imports)]
use super::*;

/// `triage_list` (T038/T041): the queue, with the CLI's own text and a structured
/// mirror of the same one snapshot.
///
/// The text comes from `commands::triage::render_list` — the exact string
/// `tix triage list` prints — so a client that logs MCP output and CLI output sees
/// one document rather than two descriptions of one queue (NFR-003). The
/// structured value is derived from the same [`inbox::collect`] pass the renderer
/// made, over the same corpus and the same `today`.
pub(super) fn triage_list(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let by_age = match opt_bool(args, "age") {
        Ok(value) => value.unwrap_or(false),
        Err(()) => return Err(()),
    };
    let counts_only = match opt_bool(args, "count") {
        Ok(value) => value.unwrap_or(false),
        Err(()) => return Err(()),
    };
    // The refusal comes **first**, and from one place. A project with no `[triage]`
    // section has no inbox on any triage surface, and
    // `mcp_triage_without_a_section_refuses_with_the_clis_own_message` compares this
    // sentence with what `tix triage list` prints, byte for byte.
    let scope = match triage_scope(config) {
        Ok(scope) => scope,
        Err(outcome) => return outcome,
    };
    let text = match cmd_triage::render_list(store, by_age, counts_only) {
        Ok(text) => text,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let corpus = match crate::search::Corpus::build(store) {
        Ok(corpus) => corpus,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let metrics = inbox::collect(&corpus.issues, &scope, &crate::model::today());
    let mut rows: Vec<&inbox::QueueEntry<'_>> = metrics.entries.iter().collect();
    if by_age {
        // The CLI's `--age` rule, restated rather than re-invented: `render_list`'s
        // comparator is private to `commands::triage`, exactly as `ui::triage`'s copy
        // is, so this is the third spelling of one ordering. Oldest first, and an
        // unknown age **last** on both ends rather than coerced to `today`.
        //
        // The copy is safe because it is *checked*:
        // `mcp_triage_list_prints_the_clis_own_queue_and_counts` compares this
        // structure's row order with the order of the CLI's own rendered rows, so a
        // comparator that drifted from the CLI's would fail there rather than quietly
        // returning the text in one order and the data in another.
        rows.sort_by(|a, b| match (a.age_days, b.age_days) {
            (Some(x), Some(y)) => y.cmp(&x),
            (Some(_), None) => std::cmp::Ordering::Less,
            (None, Some(_)) => std::cmp::Ordering::Greater,
            (None, None) => std::cmp::Ordering::Equal,
        });
    }
    let structured = json!({
        "counts": triage_counts_value(&metrics),
        // `count: true` asks for the counts without the rows, and this returns the
        // same thing the text does — a client that asked not to receive 10,000 rows
        // does not receive them in `structuredContent` either.
        "entries": if counts_only {
            json!([])
        } else {
            triage_entries_value(&rows)
        },
    });
    Ok(success_result(text, structured))
}

/// `triage_preview` / `triage_apply` (T039): the shared plan and apply, projected.
///
/// One pair of tools covers a single issue and a batch, because both go through
/// [`crate::triage::batch::build_plan`] — one preview engine, one refusal
/// vocabulary, one per-item outcome shape, exactly as the TUI's modal does
/// (`ModalScope::One` or `Many`). A single-issue call therefore reports the same
/// per-item status words a 10-issue call reports, which is what makes NFR-003 a
/// structural claim rather than a promise.
pub(super) fn triage_run(config: &Config, store: &Store, args: &Value, apply: bool) -> ToolOutcome {
    if apply {
        // C-004. Checked **before** anything is resolved, so a client that forgot the
        // confirmation gets the refusal without having paid for a plan.
        match opt_bool(args, "confirm") {
            Ok(Some(true)) => {}
            Ok(_) => {
                return Ok(error_result(
                    "confirm is required and must be true — a triage apply writes files \
                     and appends History, so nothing is written without it (C-004). Call \
                     triage_preview first; it is read-only."
                        .to_string(),
                ));
            }
            Err(()) => return Err(()),
        }
    }
    // An absent or non-string `decision` is a **protocol** error (`-32602`); a
    // well-formed but unknown decision is a **tool** result carrying the service's
    // own message, because the client sent well-formed JSON and deserves to read
    // why it was refused.
    let raw = match req_string(args, "decision") {
        Ok(raw) => raw,
        Err(()) => return Err(()),
    };
    let kind = match raw.trim().parse::<DecisionKind>() {
        Ok(kind) => kind,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let reason = match opt_string(args, "reason") {
        Ok(value) => value.unwrap_or_default(),
        Err(()) => return Err(()),
    };
    // A blank actor is passed through rather than replaced by the git identity: the
    // CLI refuses `--actor ""` with `missing_actor`, and an MCP client that sent an
    // empty string should hear the same thing. Falling back here would make the two
    // surfaces disagree about whether a decision was anonymous.
    let actor = match opt_string(args, "actor") {
        Ok(value) => value.unwrap_or_else(crate::storage::actor_identity),
        Err(()) => return Err(()),
    };
    let mut request = TriageDecision::new(kind, crate::model::utc_now_rfc3339(), actor, reason);
    // `to` is read **raw**, and refused whenever the decision cannot use it — blank
    // included, because the flag itself is the mistake. That is the CLI's own rule, and
    // reading `to` only for an accept (the first cut of this handler) let a client name
    // a column that was silently dropped while `tix triage batch --decision decline
    // --to …` refused the identical request (review cycle 1). On an *accept* a blank
    // `--to` means "no explicit target", so the configured `accepted_status` applies —
    // also the CLI's rule.
    let to = match opt_string(args, "to") {
        Ok(value) => value,
        Err(()) => return Err(()),
    };
    if let Some(target) = to.as_deref() {
        if kind != DecisionKind::Accept {
            return Ok(error_result(format!(
                "`to` names an accept target, and this decision is a {kind} — a {kind} \
                 decision has no target column, so `to: \"{target}\"` would be silently \
                 ignored. Name the target on an accept, and use `of` for a duplicate."
            )));
        }
    }
    if kind == DecisionKind::Accept {
        request.target_status = to.filter(|t| !t.trim().is_empty());
        if request.target_status.is_none() {
            request.target_status = config
                .triage
                .as_ref()
                .and_then(|triage| triage.accepted_status.clone());
        }
    }
    // `of` is passed through **raw** for the same reason: `build_plan` refuses it with
    // the CLI's own sentence when the decision has no duplicate target (blank included)
    // and resolves it otherwise, so this handler has no opinion about it at all.
    let of = match opt_string(args, "of") {
        Ok(value) => value,
        Err(()) => return Err(()),
    };
    let selection = match triage_selection(store, args) {
        Ok(selection) => selection,
        Err(outcome) => return outcome,
    };
    // `--of` on a non-duplicate decision, an unresolvable target, and a project with
    // no `[triage]` section are all plan-level refusals, detected before the first
    // write so a whole selection either starts or does not.
    let plan = match batch::build_plan(store, selection, request, of.as_deref()) {
        Ok(plan) => plan,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    if !apply {
        return Ok(success_result(
            cmd_triage::render_plan(&plan),
            triage_plan_value(&plan, false),
        ));
    }
    let mut confirmed = plan.clone();
    confirmed.confirm();
    let report = match batch::apply(store, &confirmed) {
        Ok(report) => report,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let text = cmd_triage::render_report(&report, &confirmed);
    let structured = triage_report_value(&report, &confirmed);
    Ok(success_result(text, structured))
}

/// `triage_status` (T038/T041): the summary, CLI text plus its structured mirror.
pub(super) fn triage_status(config: &Config, store: &Store, _args: &Value) -> ToolOutcome {
    let scope = match triage_scope(config) {
        Ok(scope) => scope,
        Err(outcome) => return outcome,
    };
    let text = match cmd_triage::render_status(store) {
        Ok(text) => text,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let corpus = match crate::search::Corpus::build(store) {
        Ok(corpus) => corpus,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let metrics = inbox::collect(&corpus.issues, &scope, &crate::model::today());
    Ok(success_result(
        text,
        json!({ "counts": triage_counts_value(&metrics) }),
    ))
}

pub(super) fn dispatch(
    config: &Config,
    store: &Store,
    name: &str,
    arguments: &Value,
) -> Option<ToolOutcome> {
    match name {
        "triage_list" => Some(triage_list(config, store, arguments)),
        "triage_status" => Some(triage_status(config, store, arguments)),
        "triage_preview" => Some(triage_run(config, store, arguments, false)),
        "triage_apply" => Some(triage_run(config, store, arguments, true)),
        _ => None,
    }
}

/// Resolve the inbox scope, or the refusal every triage view shares.
///
/// The refusal is rendered through [`crate::error::TixError`] rather than returned
/// as a bare sentence, so it carries the same `invalid input: …` prefix every other
/// refusal on this surface carries — and is then byte-equal to what the CLI's own
/// `scope_of` produces from the same constant, which is what
/// `t041_the_no_inbox_refusal_is_the_clis_own_sentence` asserts.
pub(super) fn triage_scope(config: &Config) -> std::result::Result<inbox::InboxScope, ToolOutcome> {
    match inbox::scope(config) {
        Ok(Some(scope)) => Ok(scope),
        Ok(None) => Err(Ok(error_result(
            crate::error::TixError::InvalidInput(NO_INBOX.to_string()).to_string(),
        ))),
        Err(e) => Err(Ok(error_result(crate::error::render_error(&e)))),
    }
}

/// The counts and histogram, as one object (FR-005).
///
/// Every figure is `inbox::collect`'s — `accepted` is the completion reading and
/// `declined` is the `canceled` category, both through #183 — so this cannot drift
/// from `tix triage status` or from `tix status` (C-002).
pub(super) fn triage_counts_value(metrics: &inbox::QueueMetrics<'_>) -> Value {
    json!({
        "total": metrics.total,
        "awaiting": metrics.awaiting,
        "deferred": metrics.deferred,
        "accepted": metrics.accepted,
        "declined": metrics.declined,
        "duplicated": metrics.duplicated,
        "unknown_age": metrics.unknown_age,
        "age": metrics.age_distribution.iter()
            .map(|bucket| json!({ "bucket": bucket.label, "count": bucket.count }))
            .collect::<Vec<_>>(),
    })
}

/// The queue rows, in the order the query returned them (FR-001, FR-005).
///
/// `entered_at` is the stamp the age was derived from and is `null` exactly when
/// `age_days` is — an entry nobody can age says so twice rather than reporting an
/// age of zero (data-model §2).
pub(super) fn triage_entries_value(entries: &[&inbox::QueueEntry<'_>]) -> Value {
    Value::Array(
        entries
            .iter()
            .map(|entry| {
                let issue = entry.issue;
                json!({
                    "number": issue.number,
                    "title": issue.title,
                    "status": issue.status.display(),
                    "age_days": entry.age_days,
                    "entered_at": match entry.entered_at {
                        crate::triage::EntryTime::Recorded(at) => json!(at),
                        crate::triage::EntryTime::Unknown => Value::Null,
                    },
                    "source": crate::triage::entry_source(issue),
                    // Three answers, not two: no decision (`null`), the decision
                    // (`{…}`), and a recorded line that will not re-parse
                    // (`"unreadable"`). The last is the diagnostic a maintainer
                    // needs — collapsing it into `null` said "nobody has decided
                    // this" about a file that carries a decision (tix #1494).
                    "last_decision": match crate::triage::history::last_decision_state(issue) {
                        crate::triage::history::LastDecision::Readable(record) => json!({
                            "kind": record.kind.as_str(),
                            "prior_state": record.prior_state,
                            "result": record.result,
                            "reason": record.reason,
                            "actor": record.actor,
                            "timestamp": record.timestamp,
                            "target_issue": record.target_issue,
                        }),
                        crate::triage::history::LastDecision::Unreadable => json!("unreadable"),
                        crate::triage::history::LastDecision::None => Value::Null,
                    },
                })
            })
            .collect(),
    )
}

/// The selection a triage request names: one issue, a selector list, or a filter.
///
/// Three grammars, exactly one allowed — the rule `tix triage batch` enforces
/// through the same builder, so two named grammars produce the CLI's own message
/// rather than a schema complaint.
pub(super) fn triage_selection(store: &Store, args: &Value) -> ToolOutcome2<bulk::Selection> {
    // Blank counts as absent, for all three: a whitespace-only `select` is not a
    // grammar a client named, and letting it through hands the refusal to the shared
    // builder, whose message names `--search` — a flag this surface does not have.
    // `issue` is filtered the same way, and for the same reason: an empty `issue` is
    // not a named grammar either (review cycle 2).
    let one = opt_selector(args, "issue")
        .map_err(|()| proto())?
        .filter(|raw| !raw.trim().is_empty());
    let select = opt_string(args, "select")
        .map_err(|()| proto())?
        .filter(|raw| !raw.trim().is_empty());
    let filter = opt_string(args, "filter")
        .map_err(|()| proto())?
        .filter(|raw| !raw.trim().is_empty());
    let named =
        usize::from(one.is_some()) + usize::from(select.is_some()) + usize::from(filter.is_some());
    if named == 0 {
        return Err(refuse(
            "name the issues to decide: `issue` for one issue, or `select`/`filter` for \
             a selection. A decision with no issue in it has nothing to report on."
                .to_string(),
        ));
    }
    if named > 1 {
        return Err(refuse(
            "name exactly one of `issue`, `select` or `filter` — they are three grammars \
             for the same selection, and this tool does not guess which you meant"
                .to_string(),
        ));
    }
    // The shared builder splits and trims the comma list
    // (`cmd_bulk::SelectionRequest::new` → `split_selectors`), so this does not
    // split it a second time: one grammar, one splitter, and a trailing comma is
    // dropped there rather than refused here with a message about an empty
    // selector.
    let mut selectors: Vec<String> = select.into_iter().collect();
    if let Some(one) = one {
        selectors.push(one);
    }
    // The **shared** request and resolver, so a selector that matches nothing or
    // matches twice is the CLI's diagnostic (`unknown_issue`, ambiguity) and not a
    // second implementation of selector resolution.
    let request = cmd_bulk::SelectionRequest::new(
        &selectors,
        filter.as_deref(),
        None,
        bulk::SelectionScope::Active,
    )
    .map_err(|e| refuse(e.to_string()))?;
    cmd_bulk::resolve(store, &request).map_err(|e| refuse(e.to_string()))
}

/// The plan as JSON: selection order, proposed changes, the History intent and the
/// per-item status — the same shape [`bulk_plan_value`] emits, for the same reason
/// (one projection per surface would be a second contract).
pub(super) fn triage_plan_value(plan: &TriagePlan, applied: bool) -> Value {
    json!({
        "applied": applied,
        "decision": plan.decision.kind.as_str(),
        "selection": plan.selection.issues().iter()
            .map(|i| json!({ "number": i.number, "title": i.title }))
            .collect::<Vec<_>>(),
        "items": plan.items.iter().map(|item| json!({
            "number": item.number,
            "title": item.title,
            "status": item.status(),
            "changes": item.changes.iter()
                .map(|(field, change)| {
                    let (from, to) = change.as_from_to();
                    json!({ "field": field, "from": from, "to": to })
                })
                .collect::<Vec<_>>(),
            "history": item.history.iter()
                .map(|h| json!({ "kind": h.kind, "detail": h.detail }))
                .collect::<Vec<_>>(),
            "diagnostic": item.diagnostic.as_ref().map(|d| json!({
                "issue": d.issue,
                "field": d.field,
                "message": d.message,
            })),
        })).collect::<Vec<_>>(),
        "summary": plan.summary(),
    })
}

/// The report as JSON: exactly one outcome per selected issue (NFR-002), with the
/// shared status word rather than a re-derived string.
pub(super) fn triage_report_value(report: &bulk::ApplyReport, plan: &TriagePlan) -> Value {
    let titles: Vec<(u32, String)> = plan
        .items
        .iter()
        .map(|i| (i.number, i.title.clone()))
        .collect();
    json!({
        "applied": report.applied(),
        "no_op": report.noop(),
        "rejected": report.rejected(),
        "failed": report.failed(),
        "outcomes": report.outcomes.iter().map(|(number, outcome)| {
            let title = titles.iter().find(|(n, _)| n == number)
                .map(|(_, t)| t.as_str()).unwrap_or("");
            json!({
                "number": number,
                "title": title,
                "status": outcome.status(),
                "line": outcome.as_line(*number, title),
            })
        }).collect::<Vec<_>>(),
    })
}