tix-tracker 0.10.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
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
//! Batch triage: **one decision across a selection**, through #173's
//! preview/confirmation/outcome core.
//!
//! Mission `triage-inbox-review-flow-01M3C600`, WP04 (T018–T022). The four
//! decisions themselves are [`crate::triage::decision`]'s; what is new here is
//! only that they are asked about *many* issues at once, previewed, confirmed
//! once, and reported one outcome per issue (contract `decision-history.md`
//! §Batch behavior, C-005).
//!
//! ## The shape is #173's, deliberately
//!
//! This module is an **adapter**, and everything a caller already knows about a
//! bulk operation is the same object here:
//!
//! - a [`crate::bulk::Selection`] resolved **once** (data-model §1);
//! - a read-only plan of [`crate::bulk::ItemPlan`] items carrying
//!   [`crate::bulk::FieldChange`]s and a [`crate::bulk::HistoryIntent`];
//! - a [`crate::bulk::Confirmation`] that names the membership it authorized;
//! - one [`crate::bulk::ItemOutcome`] per selected issue, assembled into a
//!   [`crate::bulk::ApplyReport`].
//!
//! There is no second batch engine, no second outcome vocabulary and no
//! rollback model (WP04 Context & Constraints). What this module does *not*
//! reuse is [`crate::bulk::BatchPlan`], whose `patch` is an
//! [`crate::bulk::IssuePatch`] — a **field** patch. A triage decision is not a
//! field patch: a duplicate creates *N* typed relations (one per selected
//! issue) rather than one pair operation, and `BatchPlan::summary` would print
//! `nothing to do` for it because its non-field branch knows only `pair` and
//! `archive`. Extending `IssuePatch` with a triage branch would put triage
//! vocabulary inside #173's plan builder instead of adapting to it, which is the
//! coupling the work package asks to avoid. The *cores* C-005 names —
//! selection, preview, confirmation, per-item outcomes — are reused whole.
//!
//! ## Writes: one file write per item, and nothing else
//!
//! [`apply`] judges each item against the store's **fresh** state and, for one
//! applied decision, performs exactly one [`crate::storage::Store::update`]:
//! the issue as [`crate::triage::decision::DecisionService::apply`] left it
//! (status, entry metadata, and the one appended event), plus the typed
//! relation when the decision is a duplicate. One write carries both so that
//! SC-003 ("one corresponding `History` event" per applied decision) and SC-005
//! ("only a typed relation") hold together: going through
//! [`crate::storage::Store::link`] instead would append a second
//! `relationship_added` event, and writing the two halves in two calls would
//! either double-log or wipe the edge.
//!
//! A rejected or failed item writes nothing, and **does not undo** the items
//! that succeeded — there is no rollback to promise and none is claimed.

use std::collections::BTreeMap;

use crate::bulk::plan::ItemDiagnostic;
use crate::bulk::{
    ApplyReport, Confirmation, FieldChange, HistoryIntent, ItemOutcome, ItemPlan, Selection,
};
use crate::error::{Result, TixError, render_diagnostics_plain};
use crate::model::Issue;
use crate::relationships::{IssueView, Relationship, RelationshipGraph};
use crate::storage::{IssueSelector, Store};
use crate::triage::decision::{
    DecisionKind, DecisionOutcome, DecisionService, DuplicateTarget, TriageDecision,
};
use crate::triage::inbox;

/// A decision, a selection, and what each selected issue would become.
///
/// A **value**: it survives between the preview and the confirmation without
/// holding a reference to anything, which is what lets a surface show a table,
/// take a `yes`, and only then call [`apply`]. It deliberately does not carry a
/// resolved duplicate target or a built [`DecisionService`] — both are re-read
/// from the store at apply time, because an `Issue` identity the preview held
/// can be edited away before the write happens.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TriagePlan {
    /// The exact selection this plan was built from, so an apply can check that
    /// the thing it was authorized for is the thing in front of it.
    pub selection: Selection,
    /// What was asked for. `target_issue` is filled in by
    /// [`build_plan`] from the resolved `--of`, so a preview and the apply that
    /// follows it name the same number.
    pub decision: TriageDecision,
    /// The `--of` selector, **verbatim**, for a duplicate batch.
    ///
    /// Stored as typed rather than resolved because re-resolving is the point:
    /// the target is read again at apply time, from the current store.
    pub duplicate_of: Option<String>,
    /// One entry per selected issue, in selection order — including the ones
    /// that cannot be applied, because "every selected issue receives one
    /// outcome" starts with the preview showing one too.
    pub items: Vec<ItemPlan>,
    /// The confirmation, absent until one is issued. `None` is the ordinary
    /// state; see [`Confirmation`].
    pub confirmation: Option<Confirmation>,
}

impl TriagePlan {
    /// The items that would be written.
    pub fn actionable(&self) -> impl Iterator<Item = &ItemPlan> {
        self.items.iter().filter(|item| item.is_actionable())
    }

    /// The items that cannot be applied.
    pub fn blocked(&self) -> impl Iterator<Item = &ItemPlan> {
        self.items.iter().filter(|item| item.diagnostic.is_some())
    }

    /// The one-line summary a surface prints above the item table.
    ///
    /// Every count is named, including the zeros, for the reason
    /// [`crate::bulk::BatchPlan::summary`] gives: a batch that silently skipped
    /// everything would otherwise look identical to one that did it all.
    ///
    /// There is no `pair`/`archive` branch because a triage batch has neither —
    /// every effect it proposes, a relation included, belongs to an individual
    /// item and is already in the counts.
    pub fn summary(&self) -> String {
        let actionable = self.actionable().count();
        let blocked = self.blocked().count();
        let noop = self.items.iter().filter(|item| item.is_noop()).count();
        let mut parts = vec![format!("{actionable} of {} would change", self.items.len())];
        if noop > 0 {
            parts.push(format!("{noop} already in that state"));
        }
        if blocked > 0 {
            parts.push(format!("{blocked} refused"));
        }
        parts.join("; ")
    }

    /// Issue a confirmation for **this** plan.
    ///
    /// The fingerprint is [`Selection::fingerprint`]'s — membership, not a hash
    /// of contents — so a preview confirms a set of issues rather than a
    /// moment's file bytes (`contracts/patch-plan.md` §4).
    pub fn confirm(&mut self) {
        let fingerprint = self.selection.fingerprint();
        self.confirmation = Some(Confirmation { fingerprint });
    }

    /// Whether a confirmation on this plan is still valid.
    ///
    /// The same rule [`crate::bulk::BatchPlan::confirmation_is_valid`] applies,
    /// spelled the same way: a confirmation authorizes the selection it was
    /// issued against, and any change to it requires a new preview.
    pub fn confirmation_is_valid(&self) -> bool {
        match &self.confirmation {
            None => false,
            Some(confirmation) => confirmation.fingerprint == self.selection.fingerprint(),
        }
    }
}

/// Build a read-only plan. **Nothing is written**, by construction.
///
/// # Errors
///
/// Propagates the store's own diagnostics when `--of` cannot be resolved — the
/// same "ambiguity and not-found are the existing diagnostics, propagated
/// unwrapped" rule [`Selection::resolve`] follows — and when the project has no
/// `[triage]` section to be an inbox at all. A `--of` on a non-duplicate
/// decision is refused rather than ignored.
///
/// Per-issue problems are **not** errors: they are the plan's
/// [`ItemPlan::diagnostic`]s, so one bad item leaves the other N previewable.
pub fn build_plan(
    store: &Store,
    selection: Selection,
    mut decision: TriageDecision,
    duplicate_of: Option<&str>,
) -> Result<TriagePlan> {
    if decision.kind != DecisionKind::Duplicate && duplicate_of.is_some() {
        // Reworded to name the argument by **meaning**, not by the CLI flag: this
        // sentence is shared with the MCP surface (tix #1488), whose argument is
        // `of` and whose client cannot type `--of`. A surface that has a flag
        // spelling adds it around this message rather than inside it.
        return Err(TixError::InvalidInput(format!(
            "a duplicate target was named, and this decision is a {} — only a \
             duplicate decision takes one",
            decision.kind
        )));
    }

    // `--of` resolves **once**, before any item is judged: it is a property of
    // the decision, not of any issue in the selection, so one answer is shared
    // by every item (T018, "Resolve selection once").
    let target = duplicate_of
        .map(|raw| store.resolve_issue(raw))
        .transpose()?;
    decision.target_issue = target.as_ref().map(|issue| issue.number);

    let service = service(store, target.as_ref())?;
    // The relationship service, for the one decision that creates an edge. Built
    // for every batch rather than only a duplicate's: it costs one directory
    // read, and an `Option` here would be a branch no test could make false.
    let repo = store.list()?;
    let views: Vec<IssueView<'_>> = repo.iter().map(Issue::issue_view).collect();
    let done_column = store.relationship_done_column();
    let graph = RelationshipGraph::new(&views, &done_column);

    let items = selection
        .issues()
        .iter()
        .map(|issue| plan_item(&service, &graph, issue, &decision))
        .collect();

    Ok(TriagePlan {
        selection,
        decision,
        duplicate_of: duplicate_of.map(str::to_string),
        items,
        confirmation: None,
    })
}

/// Apply a confirmed plan: one outcome per selected issue, in selection order.
///
/// # Errors
///
/// Refuses an absent or stale confirmation **before the store is read at all**,
/// and fails if the `--of` target cannot be resolved or the project has no
/// `[triage]` section — plan-level conditions, detected before the first write
/// so a whole batch either starts or does not. Individual items never fail the
/// call: a refusal or a write failure is that item's [`ItemOutcome`], and the
/// items that succeeded keep their results.
pub fn apply(store: &Store, plan: &TriagePlan) -> Result<ApplyReport> {
    // ---- The gate. First statement, before `store` is read at all. ----
    if plan.confirmation.is_none() || !plan.confirmation_is_valid() {
        return Err(TixError::InvalidInput(
            "a triage apply needs a confirmation for exactly this plan — preview it \
             first, and preview again if the selection or the decision changed"
                .to_string(),
        ));
    }

    // The target is resolved again, from the store's current state. A plan that
    // carried the `Issue` the preview read would apply a decision to an identity
    // nobody can see any more; the selector is re-run and the number re-recorded.
    let target = plan
        .duplicate_of
        .as_deref()
        .map(|raw| store.resolve_issue(raw))
        .transpose()?;
    let mut decision = plan.decision.clone();
    decision.target_issue = target.as_ref().map(|issue| issue.number);

    let service = service(store, target.as_ref())?;
    let repo = store.list()?;
    let views: Vec<IssueView<'_>> = repo.iter().map(Issue::issue_view).collect();
    let done_column = store.relationship_done_column();
    let graph = RelationshipGraph::new(&views, &done_column);

    let mut outcomes = Vec::with_capacity(plan.items.len());
    let mut write_failures = Vec::new();

    for item in &plan.items {
        // A plan-time refusal is **not** retried: it is the decision's own
        // answer to inputs the preview already showed, and re-judging it here
        // would give a bulk apply its own policy. An item whose diagnostic is
        // absent *is* judged again, because the decision's output is the bytes
        // this loop writes and the board may have moved underneath it.
        if let Some(diagnostic) = &item.diagnostic {
            outcomes.push((
                item.number,
                ItemOutcome::Rejected(diagnostic.message.clone()),
            ));
            continue;
        }
        let number = item.number;
        match apply_one(store, &service, &graph, number, &decision) {
            Ok(outcome) => outcomes.push((number, outcome)),
            // Whatever succeeded before it stands; nothing is undone, because
            // nothing was ever promised as all-or-nothing (C-005).
            Err((diagnostic, error)) => {
                write_failures.push(diagnostic.clone());
                outcomes.push((
                    number,
                    ItemOutcome::Failed {
                        item: diagnostic,
                        error,
                    },
                ));
            }
        }
    }

    Ok(ApplyReport {
        outcomes,
        write_failures,
    })
}

// ---------------------------------------------------------------------------
// One item
// ---------------------------------------------------------------------------

/// What this decision would change about one issue — or why it cannot.
///
/// Pure: it asks the service, which is filesystem-free, and turns the answer
/// into #173's plan vocabulary.
fn plan_item(
    service: &DecisionService<'_>,
    graph: &RelationshipGraph<'_>,
    issue: &Issue,
    decision: &TriageDecision,
) -> ItemPlan {
    let outcome = match service.apply(issue, decision) {
        Ok(outcome) => outcome,
        Err(refusal) => return rejected(issue, "decision", refusal.to_string()),
    };

    let DecisionOutcome::Applied {
        issue: updated,
        event,
        relation,
    } = outcome
    else {
        // Already in the requested state: no change, no event, no diagnostic.
        return empty_item(issue);
    };

    // The relation is checked against #169's service **at plan time** too, so a
    // duplicate the store would refuse (`tix link` would say `conflicting_edge`)
    // shows as a refused item rather than as a proposal that cannot be written.
    if let Some(relation) = relation.as_ref() {
        if let Some(message) = blocking(graph, relation) {
            return rejected(issue, "relationship", message);
        }
    }

    ItemPlan {
        number: issue.number,
        title: issue.title.clone(),
        changes: diff(issue, &updated),
        path_change: None,
        // The event `apply` will append. Exactly one: the service appends it to
        // `updated` itself, so the intent and the bytes cannot disagree.
        history: vec![HistoryIntent {
            kind: event.kind.as_str(),
            detail: event.detail.clone(),
        }],
        diagnostic: None,
        wip: None,
    }
}

/// One item of each shape that writes nothing: a refusal, and a no-op.
fn empty_item(issue: &Issue) -> ItemPlan {
    ItemPlan {
        number: issue.number,
        title: issue.title.clone(),
        changes: BTreeMap::new(),
        path_change: None,
        history: Vec::new(),
        diagnostic: None,
        wip: None,
    }
}

/// The same no-op shape, with the problem that stopped it.
fn rejected(issue: &Issue, field: &'static str, message: String) -> ItemPlan {
    ItemPlan {
        number: issue.number,
        title: issue.title.clone(),
        changes: BTreeMap::new(),
        path_change: None,
        history: Vec::new(),
        diagnostic: Some(ItemDiagnostic {
            issue: issue.number,
            field,
            message,
        }),
        wip: None,
    }
}

/// What the decision actually changes in the file.
///
/// Only what differs. A decision that moves nothing and clears nothing — a
/// decline back into the entry column, a duplicate — therefore proposes no
/// field change, which is the truth: its write is the `## History` line, and
/// `ItemPlan::is_actionable` counts that.
fn diff(from: &Issue, to: &Issue) -> BTreeMap<&'static str, FieldChange> {
    let mut changes = BTreeMap::new();
    if from.status.display() != to.status.display() {
        changes.insert(
            "status",
            FieldChange::Set {
                from: from.status.display().to_string(),
                to: to.status.display().to_string(),
            },
        );
    }
    optional(
        &mut changes,
        "entered_at",
        from.entered_at.as_deref(),
        to.entered_at.as_deref(),
    );
    optional(
        &mut changes,
        "source",
        from.source.as_deref(),
        to.source.as_deref(),
    );
    changes
}

/// One optional frontmatter key's change, as `Set`/`Clear` — or nothing at all
/// when both sides agree, absent on both or absent on the same side.
fn optional(
    changes: &mut BTreeMap<&'static str, FieldChange>,
    field: &'static str,
    from: Option<&str>,
    to: Option<&str>,
) {
    if from == to {
        return;
    }
    match (from, to) {
        (Some(older), None) => {
            changes.insert(
                field,
                FieldChange::Clear {
                    from: older.to_string(),
                },
            );
        }
        (older, newer) => {
            changes.insert(
                field,
                FieldChange::Set {
                    from: older.unwrap_or_default().to_string(),
                    to: newer.unwrap_or_default().to_string(),
                },
            );
        }
    }
}

/// Why #169's service would refuse this edge, or `None`.
///
/// The same `validate_create` [`crate::storage::Store::link`] runs and the same
/// renderer it renders with, so a duplicate created here is one `tix link` would
/// also have created (C-006).
fn blocking(graph: &RelationshipGraph<'_>, relation: &Relationship) -> Option<String> {
    let verdict = graph.validate_create(relation.from, relation.to, relation.kind);
    if !verdict.blocking() {
        return None;
    }
    Some(render_diagnostics_plain(
        verdict
            .errors
            .iter()
            .map(|d| (d.kind.as_str(), d.message.as_str())),
    ))
}

// ---------------------------------------------------------------------------
// One item's write
// ---------------------------------------------------------------------------

/// Apply one item against the store's fresh state.
///
/// The `Err` half carries the item's diagnostic **and** the line a surface
/// prints, so the caller can report *which* item failed and on what rather than
/// only what — and so the contract's `write_failed` kind (`decision-history.md`
/// §Diagnostics) is spelled at the write, where it is true, instead of being
/// inferred later from the fact that anything at all went wrong.
#[allow(clippy::type_complexity)]
fn apply_one(
    store: &Store,
    service: &DecisionService<'_>,
    graph: &RelationshipGraph<'_>,
    number: u32,
    decision: &TriageDecision,
) -> std::result::Result<ItemOutcome, (ItemDiagnostic, String)> {
    let issue = store
        .get(IssueSelector::Number(number))
        .map_err(|e| failed(number, "read", e.to_string()))?;

    // The decision is judged again, because its **output** is the bytes written:
    // `Store::update` needs the issue as the service leaves it, and there is no
    // way to write that without re-asking. A refusal here is the same refusal the
    // preview reported, found after the board moved — and since nothing was
    // attempted, the item is *rejected*, not failed.
    let outcome = match service.apply(&issue, decision) {
        Ok(outcome) => outcome,
        Err(refusal) => return Ok(ItemOutcome::Rejected(refusal.to_string())),
    };

    let DecisionOutcome::Applied {
        issue: updated,
        event: _,
        relation,
    } = outcome
    else {
        return Ok(ItemOutcome::NoOp);
    };

    let mut written = updated;
    if let Some(relation) = relation {
        // One batch creates one edge per item, each with a different `from` (the
        // selection de-duplicates by number) and the same `to`, so no item's
        // write can invalidate another's check: the graph built above is the
        // state each item is judged against, once.
        if let Some(message) = blocking(graph, &relation) {
            return Ok(ItemOutcome::Rejected(message));
        }
        written.relationships.push(relation);
    }

    // **One write, one event.** `written` already carries the event the service
    // appended; the relation rides the same serialization, so nothing has to be
    // written twice and there is no second event to keep out of the way.
    //
    // One transaction **per item** (#1580), never one over the batch: the batch
    // contract is a per-item outcome, and a failure on one item must not roll back
    // its siblings. The `update` is a single call but internally a write followed
    // by a layout reconcile (a possible move), so the transaction makes those two
    // mutations one unit for the item.
    let filename = written.filename.clone();
    let filename = store
        .transaction(|| store.update(&filename, &written))
        .map_err(|e| failed(number, "write", format!("write_failed: {e}")))?;
    Ok(ItemOutcome::Applied { filename })
}

/// One failure: the diagnostic a report carries, and the line it prints.
#[allow(clippy::type_complexity)]
fn failed(number: u32, field: &'static str, message: String) -> (ItemDiagnostic, String) {
    (
        ItemDiagnostic {
            issue: number,
            field,
            message: message.clone(),
        },
        message,
    )
}

// ---------------------------------------------------------------------------
// The service
// ---------------------------------------------------------------------------

/// The decision service over the store's config, scope, and resolved target.
///
/// The scope comes from `inbox::scope` — the same call `list` and the CLI make,
/// so a batch cannot be built over an unvalidated `[triage]` section that the
/// inbox itself would have refused (C-002).
fn service<'a>(store: &'a Store, target: Option<&'a Issue>) -> Result<DecisionService<'a>> {
    let scope = inbox::scope(&store.config)?.ok_or_else(|| {
        TixError::InvalidInput(
            "a triage batch needs a `[triage]` section in tix.toml, and this project \
             has none"
                .to_string(),
        )
    })?;
    Ok(DecisionService::new(
        &store.config,
        scope,
        match target {
            Some(issue) => DuplicateTarget::Resolved(issue),
            None => DuplicateTarget::Unknown,
        },
    )?)
}