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
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
#[allow(clippy::wildcard_imports)]
use super::*;

pub(super) fn edit_relationship(store: &Store, args: &Value) -> ToolOutcome {
    let id = req_string(args, "id")?;
    let kind_arg = opt_string(args, "kind")?;
    let kind = match &kind_arg {
        Some(raw) => match crate::commands::link::parse_kind(raw) {
            Ok(kind) => Some(kind),
            Err(e) => return Ok(error_result(crate::error::render_error(&e))),
        },
        None => None,
    };
    let to_arg = opt_selector(args, "to")?;
    let to = match &to_arg {
        Some(raw) => match resolve_number(store, raw) {
            Ok(number) => Some(number),
            Err(()) => {
                return Ok(with_number_resolution(store, raw, || {
                    error_result(format!("invalid input: no issue matches selector `{raw}`"))
                }));
            }
        },
        None => None,
    };
    if to.is_none() && kind.is_none() {
        // The identity edit. Refused rather than silently no-op'd, because a
        // caller who asked to change something and got "unchanged" back has been
        // misled about whether the change happened.
        return Ok(error_result(format!(
            "invalid input: nothing to change: pass `to` and/or `kind` to edit_relationship {id}"
        )));
    }
    match store.edit_relation(&id, to, kind) {
        Ok(outcome) => {
            // The **shared** wording (`link::edit_message`), not a re-derived copy.
            // The no-op branch matters: it is what tells a caller that an edit
            // changed nothing, and a surface that phrases it differently from the CLI
            // is a surface an agent has to learn twice.
            let text = crate::commands::link::edit_message(&outcome);
            Ok(success_result(
                text,
                json!({ "relationship": relationship_value(&outcome.relationship), "changed": outcome.changed }),
            ))
        }
        Err(e) => Ok(error_result(crate::error::render_error(&e))),
    }
}

pub(super) fn epic_hierarchy(_config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let reference = req_string(args, "epic")?;
    let (config, _path) = match registry_config(store) {
        Ok(loaded) => loaded,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let store = store_with_config(store, config);
    let issues = match store.list_all() {
        Ok(issues) => issues,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let index = crate::epics::EpicIndex::build(&store.config, &issues);
    let hierarchy = match index.hierarchy(&reference) {
        Ok(hierarchy) => hierarchy,
        Err(error) => return Ok(epic_refusal(error)),
    };
    let epic = match index.resolve(&reference) {
        Ok(epic) => epic.clone(),
        Err(error) => return Ok(epic_refusal(error)),
    };
    let mut value = epic_hierarchy_value(&hierarchy);
    // The members, so a surface can open an ordinary issue directly (FR-004).
    value["member_count"] = json!(epic.member_count);
    value["members"] = json!(
        epic.members
            .iter()
            .map(epic_member_value)
            .collect::<Vec<Value>>()
    );
    Ok(success_result(
        cmd_epic::navigation_lines(&hierarchy, &epic).join("\n"),
        value,
    ))
}

pub(super) fn get_parent(store: &Store, args: &Value) -> ToolOutcome {
    let number = match required_number(store, args) {
        Ok(number) => number,
        Err(()) => {
            return Ok(error_result(
                "invalid input: an `issue` selector is required".into(),
            ));
        }
    };
    let snapshot = match hierarchy_snapshot(store) {
        Ok(snapshot) => snapshot,
        Err(()) => return Ok(error_result("could not read the issue set".into())),
    };
    if !snapshot.index.contains(number) {
        return Ok(missing_issue(number));
    }
    let facts = crate::commands::parent::facts_for(
        &snapshot.index,
        &snapshot.issues,
        number,
        &snapshot.done_column,
    );
    // `parent_text` is the shared single-line wording, so the unresolved case
    // cannot be spelled one way here and another way in the TUI or `tix show`.
    let text = crate::commands::parent::parent_text(&facts);
    let resolved_ref = facts
        .parent_ref
        .filter(|reference| snapshot.index.contains_ref(reference));
    let parent_number = resolved_ref.and_then(|reference| reference.as_number());
    let recorded_parent = facts.parent_ref.map(|reference| reference.to_string());
    let unresolved_parent = recorded_parent.clone();
    let unresolved = facts.unresolved_reason.map(|reason| {
        json!({
            "parent": unresolved_parent,
            "parent_number": facts.parent,
            "reason": reason,
        })
    });
    Ok(success_result(
        text.clone(),
        json!({
            "issue": number,
            "parent": resolved_ref.map(|reference| reference.to_string()),
            "parent_number": parent_number,
            "parent_display": facts.parent_label,
            // The retained reference, kept even when the target is gone: the
            // reference survives so it can be repaired, and a surface that hid it
            // would make the repair invisible.
            "recorded_parent": recorded_parent,
            "recorded_parent_number": facts.parent,
            "unresolved": unresolved,
            "text": text,
        }),
    ))
}

pub(super) fn issue_blocked_state(store: &Store, args: &Value) -> ToolOutcome {
    let raw = selector(args, "issue")?;
    let number = match resolve_number(store, &raw) {
        Ok(number) => number,
        Err(()) => {
            return Ok(with_number_resolution(store, &raw, || {
                error_result(format!("invalid input: no issue matches selector `{raw}`"))
            }));
        }
    };
    let result = with_graph(store, |graph| {
        let state = graph.blocked_state(number);
        // `blocked_note` is the same function the CLI warning, the TUI prompt,
        // the board card and the detail view use, so `reason` here is
        // byte-identical to all of them (surface-parity §2).
        let note = crate::commands::blocked_note(&state);
        let blockers: Vec<Value> = state
            .blockers
            .iter()
            .map(|n| json!({ "number": n.number, "title": n.title }))
            .collect();
        let text = note.clone().unwrap_or_else(|| "not blocked".to_string());
        Ok((
            text,
            json!({
                "issue": number,
                "is_blocked": state.is_blocked,
                "blockers": blockers,
                "reason": state.reason,
                "done_column": graph.done_column(),
            }),
        ))
    });
    let (text, structured) = match result {
        Ok(ok) => ok,
        Err(result) => return Ok(result),
    };
    Ok(success_result(text, structured))
}

pub(super) fn issue_chain(store: &Store, args: &Value) -> ToolOutcome {
    let raw = selector(args, "issue")?;
    let root = match resolve_number(store, &raw) {
        Ok(number) => number,
        Err(()) => {
            return Ok(with_number_resolution(store, &raw, || {
                error_result(format!("invalid input: no issue matches selector `{raw}`"))
            }));
        }
    };
    let result = with_graph(store, |graph| {
        // The service's order, verbatim. Re-sorting here would let MCP and the
        // CLI report different chains for one snapshot.
        let chain = graph.dependency_chain(root);
        let paths: Vec<Value> = chain
            .paths
            .iter()
            .map(|path| {
                Value::Array(
                    path.iter()
                        .map(|node| json!({ "number": node.number, "title": node.title }))
                        .collect(),
                )
            })
            .collect();
        // Node numbers *and* titles: an agent has to be able to address the next
        // node, and a title alone is not a selector.
        let nodes: Vec<Value> = chain
            .nodes()
            .iter()
            .map(|node| json!({ "number": node.number, "title": node.title }))
            .collect();
        let text = crate::commands::link::chain_output(&chain);
        Ok((
            text,
            json!({
                "root": root,
                "paths": paths,
                "nodes": nodes,
                "truncated": chain.truncated,
            }),
        ))
    });
    let (text, structured) = match result {
        Ok(ok) => ok,
        Err(result) => return Ok(result),
    };
    Ok(success_result(text, structured))
}

pub(super) fn link_issue(config: &Config, store: &Store, args: &Value) -> ToolOutcome {
    let (_, resolution) = crate::workflow::active_resolution(config);
    let a = selector(args, "a")?;
    let b = selector(args, "b")?;
    // `kind` is REQUIRED. There is no default and no fallback to `related` --
    // an untype relationship must never be creatable by omission (C-001).
    let kind = match require_relation_kind(args, "kind") {
        Ok(kind) => kind,
        Err(result) => return Ok(result),
    };
    match store.link(&a, &b, kind) {
        Ok(outcome) => {
            // Report a real issue, and never panic inside a request handler.
            // The endpoint that owns the edge is the canonical `from`, but
            // reading it back is a lookup that can legitimately fail, so an
            // unresolvable endpoint is an error result rather than an `expect`
            // (F21). The old recovery also re-derived which endpoint owns the
            // edge by trying both, which is the service's job.
            let owner = outcome
                .written
                .as_ref()
                .map(crate::id::canonical_ref)
                .unwrap_or(outcome.relationship.from);
            let issue = match store.get(crate::storage::IssueSelector::Ref(owner)) {
                Ok(issue) => issue,
                Err(e) => {
                    return Ok(error_result(format!(
                        "linked, but issue {owner} could not be read back: {e}"
                    )));
                }
            };
            // The **shared** wording, not a re-derived copy. This used to inline both
            // branches of `link::link_message` verbatim, byte-identical — but the
            // point of `surface-parity.md` §5 is that the two surfaces *cannot*
            // diverge, and two copies of a format string cannot promise that. The
            // CLI's own text is what an agent will read in a transcript, so it should
            // be the same text rather than a lookalike.
            //
            // The information is the same as `tix link`'s plus the id, kind, both
            // endpoints and the display name, so an agent can act on it.
            let text = crate::commands::link::link_message(&outcome);
            Ok(success_result(
                text,
                json!({
                    "issue": issue_value(&issue, &resolution),
                    "relationship": relationship_value(&outcome.relationship),
                    "changed": outcome.changed,
                }),
            ))
        }
        Err(e) => Ok(error_result(crate::error::render_error(&e))),
    }
}

pub(super) fn list_children(store: &Store, args: &Value) -> ToolOutcome {
    let number = match required_number(store, args) {
        Ok(number) => number,
        Err(()) => {
            return Ok(error_result(
                "invalid input: an `issue` selector is required".into(),
            ));
        }
    };
    let snapshot = match hierarchy_snapshot(store) {
        Ok(snapshot) => snapshot,
        Err(()) => return Ok(error_result("could not read the issue set".into())),
    };
    if !snapshot.index.contains(number) {
        return Ok(missing_issue(number));
    }
    // `children_text` is the CLI's own renderer, so the ordering rule (issue
    // number) has one implementation rather than one per surface.
    let text = match crate::commands::parent::children_text(&snapshot.index, number) {
        Ok(text) => text,
        Err(e) => return Ok(error_result(crate::error::render_error(&e))),
    };
    let membership = snapshot.index.children_of(number);
    let children: Vec<Value> = membership
        .children
        .iter()
        .map(|child| {
            json!({
                "id": child.id.to_string(),
                "number": child.number,
                "display": child.display,
            })
        })
        .collect();
    Ok(success_result(
        text.clone(),
        json!({
            "issue": number,
            "direct_children_only": true,
            "count": children.len(),
            "children": children,
            "text": text,
        }),
    ))
}

pub(super) fn list_relationships(store: &Store, args: &Value) -> ToolOutcome {
    let issue = opt_selector(args, "issue")?;
    // An unresolvable selector is the repository's existing invalid-input error,
    // not a protocol error -- same as the CLI's `tix link list <bad>`.
    let subject = match &issue {
        Some(raw) => match resolve_number(store, raw) {
            Ok(number) => Some(number),
            Err(()) => {
                return Ok(with_number_resolution(store, raw, || {
                    error_result(format!("invalid input: no issue matches selector `{raw}`"))
                }));
            }
        },
        None => None,
    };
    // Optional, but an *invalid* value is still a rejection with the service's
    // own text -- silently ignoring an unknown `--kind` would make a filter that
    // matched nothing look like a board with no relationships.
    let kind = match args.get("kind") {
        None | Some(Value::Null) => None,
        Some(Value::String(raw)) => match crate::commands::link::parse_kind(raw) {
            Ok(kind) => Some(kind),
            Err(e) => return Ok(error_result(crate::error::render_error(&e))),
        },
        Some(_) => {
            return Ok(error_result(
                "invalid input: `kind` must be a string naming one of blocks, blocked_by, duplicates, related"
                    .to_string(),
            ));
        }
    };
    // `blocked_by` is an input spelling, never persisted. Filtering on it
    // literally would match nothing and a documented filter would silently
    // return an empty list, so it filters on the canonical `blocks` edge.
    //
    // The **shared** mapping, not a second copy: this used to be an inlined `match`
    // identical to `link::canonical_filter`. `surface-parity.md` §3 puts spelling and
    // direction rules in the service, and the sibling finding in the same review was
    // a format that had genuinely diverged while a comment claimed otherwise (#1345).
    let wanted = kind.map(crate::commands::link::canonical_filter);

    let result = with_graph(store, |graph| {
        let edges: Vec<&crate::relationships::Relationship> = match subject {
            Some(number) => graph.relationships_for(number),
            None => graph.all_relationships().iter().collect(),
        };
        let shown: Vec<&crate::relationships::Relationship> = edges
            .into_iter()
            .filter(|rel| wanted.is_none_or(|w| rel.kind == w))
            .collect();
        let values: Vec<Value> = shown.iter().map(|rel| relationship_value(rel)).collect();
        // The **shared** renderer, not a second copy of the format. It covers both
        // the rows and the empty-result wording, which this function used to spell
        // out again in a `match (subject, wanted)`.
        //
        // The rows were already shared (`relationship_line`); the wording was not,
        // and the row format had in fact diverged before that — this inlined
        // `rel.display`, so a row read `#0 --blocks--> #1  blocks #1 Gamma`, with the
        // kind and number twice, while `tix link list` stripped them. The tool's
        // `content` was therefore not byte-comparable across surfaces, which its own
        // description and `docs/mcp.md` both claimed.
        let text = crate::commands::link::listing_text(&shown, subject, wanted);
        Ok((text, values))
    });
    let (text, values) = match result {
        Ok(ok) => ok,
        Err(result) => return Ok(result),
    };
    Ok(success_result(text, json!({ "relationships": values })))
}

pub(super) fn remove_relationship(store: &Store, args: &Value) -> ToolOutcome {
    let id = req_string(args, "id")?;
    match store.remove_relation(&id) {
        Ok(outcome) => {
            // An absent id is a success, not an error: the outcome really did
            // leave the store in the requested state, and reporting it as a
            // failure would make the tool unsafe to retry.
            //
            // The **shared** wording (`link::remove_message`). Its no-op branch reads
            // the id off `outcome.relationship`, not off the request — and
            // `Store::remove_relation` populates that field with the requested id for
            // the absent case, so the two are byte-identical while only one of them
            // stays correct if that ever changes.
            let text = crate::commands::link::remove_message(&outcome);
            Ok(success_result(
                text,
                json!({ "relationship": relationship_value(&outcome.relationship), "changed": outcome.changed }),
            ))
        }
        Err(e) => Ok(error_result(crate::error::render_error(&e))),
    }
}

pub(super) fn dispatch(
    config: &Config,
    store: &Store,
    name: &str,
    arguments: &Value,
) -> Option<ToolOutcome> {
    match name {
        "epic_hierarchy" => Some(epic_hierarchy(config, store, arguments)),
        "link_issue" => Some(link_issue(config, store, arguments)),
        "list_relationships" => Some(list_relationships(store, arguments)),
        "edit_relationship" => Some(edit_relationship(store, arguments)),
        "remove_relationship" => Some(remove_relationship(store, arguments)),
        "issue_chain" => Some(issue_chain(store, arguments)),
        "issue_blocked_state" => Some(issue_blocked_state(store, arguments)),
        "list_children" => Some(list_children(store, arguments)),
        "get_parent" => Some(get_parent(store, arguments)),
        _ => None,
    }
}