trusty-mpm 1.7.0

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
//! The `tm issue` operations: seed-labels, transition, current, repair (#1246).
//!
//! Why: these are the GitHub-mutating verbs that interpret the YAML state model.
//! Each is written generically over the extended [`TicketSystem`] trait so it is
//! fully unit-testable behind a fake backend (no live `gh`), and each maps every
//! action to a concrete label/assignee/comment mutation so issue state stays
//! reconstructable from GitHub artifacts (the visibility north star).
//! What: [`seed_labels`], [`transition`], [`current`], and [`repair`], plus the
//! [`SeedReport`]/[`TransitionReport`] result types the dispatcher prints.
//! Test: the `ops_*` tests in `ops_tests.rs` drive a `FakeSystem`.

use trusty_mpm::core::trusty_tools_config::ResolvedTicketing;

use crate::commands::ticket::labels::{AssigneeTarget, RepoLabel};
use crate::commands::ticket::system::TicketSystem;

use super::config::StateModel;
use super::state::{CurrentState, StateMachine};

/// Outcome of a `seed-labels` run (for printing + assertion).
///
/// Why: separating "what happened" from "how it's printed" makes the operation
/// pure-ish and lets tests assert the created/present split exactly.
/// What: the labels created and the labels already present, plus whether the run
/// was a dry-run.
/// Test: `ops_seed_creates_only_missing`, `ops_seed_dry_run_creates_nothing`.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct SeedReport {
    /// Labels that were (or would be) created.
    pub(crate) created: Vec<String>,
    /// Labels already present in the repo (left untouched).
    pub(crate) already_present: Vec<String>,
    /// Whether this was a dry-run (no `create_label` calls made).
    pub(crate) dry_run: bool,
    /// `true` when no session name was known, so the `ws/<session>` policy
    /// label was left out of the seed (#6914). The caller says so in its
    /// output rather than letting the omission pass silently. Always `false`
    /// for a scoped run (#7983): a run that asked for one family makes no
    /// claim about the policy set it never tried to seed.
    pub(crate) workstream_skipped: bool,
}

/// Whether `name` is selected by one `--only` filter (#7983).
///
/// Why: the two shapes the issue asks for — one label by name, or one family —
/// need to be distinguishable without a second flag, and a bare prefix match
/// would silently widen `unicorn` into every `unicorn:*` label.
/// What: a filter ending in a family separator (`:` or `/`) matches by prefix;
/// any other filter matches the label name exactly.
/// Test: `ops_seed_only_scopes_to_one_family_7983`,
/// `ops_seed_only_matches_a_single_label_by_name_7983`.
fn filter_matches(name: &str, filter: &str) -> bool {
    if filter.ends_with(':') || filter.ends_with('/') {
        name.starts_with(filter)
    } else {
        name == filter
    }
}

/// Narrow the desired set to the labels the `--only` filters name (#7983).
///
/// Why: `seed-labels` created 18 repo-wide labels for an agent that needed one
/// lifecycle family. Scoping is the fix; a filter that selects nothing is a
/// typo, and creating zero labels would read as success.
/// What: returns `desired` unchanged when `only` is empty; otherwise keeps the
/// labels matching any filter and errors on the first filter that matched
/// nothing, naming the available label names.
/// Test: `ops_seed_only_scopes_to_one_family_7983`,
/// `ops_seed_only_rejects_a_filter_that_matches_nothing_7983`.
fn apply_only_filters(desired: Vec<RepoLabel>, only: &[String]) -> anyhow::Result<Vec<RepoLabel>> {
    if only.is_empty() {
        return Ok(desired);
    }
    for filter in only {
        if !desired.iter().any(|l| filter_matches(&l.name, filter)) {
            let available: Vec<&str> = desired.iter().map(|l| l.name.as_str()).collect();
            anyhow::bail!(
                "--only `{filter}` matches no label this model would seed; \
                 available: [{}]",
                available.join(", ")
            );
        }
    }
    Ok(desired
        .into_iter()
        .filter(|l| only.iter().any(|f| filter_matches(&l.name, f)))
        .collect())
}

/// Outcome of a `transition` (for printing + assertion).
///
/// Why: records the resolved from/to and whether an assignee mutation applied,
/// so the audit comment and tests can reflect exactly what changed.
/// What: the resolved `from` (or `None` for the creation edge), the `to`, and
/// whether an assignee change was applied.
/// Test: `ops_transition_happy_path`, `ops_transition_assignee_unchanged`.
#[derive(Debug, PartialEq, Eq)]
pub(crate) struct TransitionReport {
    /// Resolved source state, or `None` for the `null → entry` creation edge.
    pub(crate) from: Option<String>,
    /// Destination state.
    pub(crate) to: String,
    /// Whether an assignee mutation was applied.
    pub(crate) assignee_changed: bool,
    /// The issue was ALREADY in the target state, so nothing was mutated
    /// (#8003).
    ///
    /// Why a field rather than an error: a caller asking for the state an issue
    /// already holds got what it wanted, so exit 0 is right — but "nothing
    /// changed" and "moved one rung" must not print the same line, which is
    /// what sent operators to a second `tm issue current` call to tell them
    /// apart. The distinction is reported, not swallowed.
    /// Test: `ops_transition_same_state_is_a_reported_no_op`,
    /// `ops_transition_same_state_makes_no_gh_mutation`.
    pub(crate) no_op: bool,
}

/// Every label the harness applies by policy, in seed order.
///
/// Why: #6914 — `gh issue edit --add-label` and `gh issue create --label` fail
/// on a label the repo has never seen, so `seed-labels` has to cover EVERY
/// label the harness applies, not just the model's. Two families make that up:
/// the project-configured lifecycle labels from `issue-state.yaml`, and the
/// framework's own set from `trusty_mpm::core::policy_labels` (`trusty-mpm`,
/// plus `ws/<session>` when a session name is known). One list, so a family cannot
/// be seeded by one caller and forgotten by another.
/// What: the model's state labels and extra labels first (a label-less state
/// like `open`/`closed` has nothing to seed), then the policy set. A name
/// already contributed by the model is not repeated.
///
/// #6918: the policy half now comes from
/// [`trusty_mpm::core::policy_labels::policy_labels_configured`], so a label an
/// operator declared in `agents.ticketing.extra_labels` is seeded by the same
/// call the built-in ones are. A default `ticketing` — what an absent block
/// resolves to — yields the identical set #6914 seeded.
/// Test: `ops_seed_creates_only_missing`,
/// `ops_seed_includes_policy_labels`,
/// `ops_seed_without_session_skips_workstream_label`,
/// `ops_seed_includes_configured_extra_labels`,
/// `ops_seed_absent_block_matches_builtin_output`.
fn desired_labels(
    model: &StateModel,
    ticketing: &ResolvedTicketing,
    session_name: Option<&str>,
) -> Vec<RepoLabel> {
    let mut out: Vec<RepoLabel> = model
        .states
        .iter()
        // A label-less state (`open`, `closed`) has nothing to seed.
        .filter_map(|s| s.label.as_ref())
        .map(|l| RepoLabel::new(l.name.clone(), l.color.clone(), l.description.clone()))
        .collect();
    out.extend(
        model
            .extra_labels
            .iter()
            .map(|l| RepoLabel::new(l.name.clone(), l.color.clone(), l.description.clone())),
    );
    // #6914: the framework's own labels come from the shared policy table that
    // session launch also uses — never a second list spelled out here.
    // #6918: read through the config-aware call so `agents.ticketing` applies.
    for label in trusty_mpm::core::policy_labels::policy_labels_configured(ticketing, session_name)
    {
        if !out.iter().any(|existing| existing.name == label.name) {
            out.push(label);
        }
    }
    out
}

/// `tm issue seed-labels` — idempotent create-missing of every label the
/// harness applies.
///
/// Why: a repo must carry the state, family, and policy labels before any
/// transition or `gh issue create --label` can apply them; create-missing is
/// non-destructive so re-runs are safe, and re-running is the documented
/// recovery when a `--add-label` fails on an unknown label.
/// What: lists existing repo labels, then for each label in
/// [`desired_labels`] not already present, creates it (unless `dry_run`).
/// Existing labels are left untouched, drifted colour/description included
/// (RFC §5.1) — seeding never rewrites what a project already styled. Records
/// `workstream_skipped` when `session_name` is `None`, so the caller can say
/// the `ws/<session>` label was left out. Returns a [`SeedReport`].
///
/// #7983: an empty `only` is the whole set — every label the model and the
/// policy table declare. A non-empty `only` narrows it through
/// [`apply_only_filters`] before the create-missing pass, and then
/// `workstream_skipped` is `false` whatever the session name is: a run that
/// asked for one family makes no claim about a policy set it never attempted.
/// Test: `ops_seed_creates_only_missing`, `ops_seed_dry_run_creates_nothing`,
/// `ops_seed_idempotent_when_all_present`, `ops_seed_includes_policy_labels`,
/// `ops_seed_without_session_skips_workstream_label`,
/// `ops_seed_leaves_present_policy_labels_untouched`,
/// `ops_seed_includes_configured_extra_labels`,
/// `ops_seed_absent_block_matches_builtin_output`,
/// `ops_seed_only_scopes_to_one_family_7983`,
/// `ops_seed_only_matches_a_single_label_by_name_7983`,
/// `ops_seed_only_rejects_a_filter_that_matches_nothing_7983`.
pub(crate) fn seed_labels<S: TicketSystem>(
    sys: &S,
    model: &StateModel,
    // #6918: the resolved `agents.ticketing` standard. A default value is the
    // built-in policy table, so an absent block seeds exactly what #6914 did.
    ticketing: &ResolvedTicketing,
    session_name: Option<&str>,
    dry_run: bool,
    // #7983: `--only` filters; empty means the whole set, as before.
    only: &[String],
) -> anyhow::Result<SeedReport> {
    // #7983: resolve and narrow the set BEFORE the repo read, so a typo'd
    // filter costs no `gh` call.
    let desired = apply_only_filters(desired_labels(model, ticketing, session_name), only)?;

    let existing = sys.list_repo_labels()?;
    let existing_names: std::collections::BTreeSet<&str> =
        existing.iter().map(|l| l.name.as_str()).collect();

    let mut report = SeedReport {
        dry_run,
        workstream_skipped: only.is_empty() && session_name.is_none_or(|n| n.trim().is_empty()),
        ..Default::default()
    };
    for label in desired {
        if existing_names.contains(label.name.as_str()) {
            report.already_present.push(label.name.clone());
            continue;
        }
        if !dry_run {
            sys.create_label(&label)?;
        }
        report.created.push(label.name.clone());
    }
    Ok(report)
}

/// `tm issue transition <issue#> <to-state>` — validated, atomic state change.
///
/// Why: the core verb. It must reject illegal edges BEFORE any `gh` mutation
/// (visibility + safety), perform the swap in one call, apply the per-state
/// assignee rule, and post an audit comment so the change is reconstructable.
/// What: resolves `<to>` to a known state; fetches the issue; resolves the
/// current state from its labels and open/closed flag (erroring clearly on
/// multiple); returns a [`TransitionReport::no_op`] when the issue is already
/// in `<to>` (#8003); checks the `from → to` edge; refuses an edge whose
/// `requires_note` is set when no `--note` was given; performs the single-call
/// swap (`swap_labels`), or a plain add/remove when one end is label-less;
/// applies the assignee rule via `set_assignee` (no-op for the factory
/// `unchanged` rules); posts an audit comment with any `note`; and closes the
/// issue when the target state IS GitHub's closed state. Returns a
/// [`TransitionReport`].
/// Test: `ops_transition_happy_path`, `ops_transition_rejects_invalid_terminal`,
/// `ops_transition_rejects_zero_state`, `ops_transition_rejects_multi_state`,
/// `ops_transition_same_state_is_a_reported_no_op`,
/// `ops_transition_same_state_makes_no_gh_mutation`,
/// plus `project_*` in `project_model_tests.rs`.
pub(crate) fn transition<S: TicketSystem>(
    sys: &S,
    model: &StateModel,
    issue: u64,
    to: &str,
    note: Option<&str>,
) -> anyhow::Result<TransitionReport> {
    let sm = StateMachine::new(model);

    // 1. Target must be a known state.
    if !sm.is_state(to) {
        anyhow::bail!(
            "unknown target state `{to}`; valid states: [{}]",
            sm.state_names().join(", ")
        );
    }

    // 2. Fetch the issue and resolve its current state from labels.
    let issue_obj = sys.validate(issue)?;
    let from: Option<String> = match sm.resolve_current_state(&issue_obj.labels, issue_obj.open) {
        CurrentState::One(s) => Some(s.to_string()),
        CurrentState::None => None,
        CurrentState::Many(states) => {
            anyhow::bail!(
                "issue #{issue} carries multiple state labels {states:?}; \
                 run `tm issue repair {issue}` to resolve to a single state first"
            );
        }
    };

    // 2b. #8003: the issue is already where the caller is asking it to go.
    // Reported as a no-op and returned BEFORE the edge check, because a
    // self-edge is not in any transition graph — leaving this to step 3 turned
    // "nothing to do" into "invalid transition", and leaving it to step 5 would
    // have swapped a label for itself.
    if from.as_deref() == Some(to) {
        return Ok(TransitionReport {
            from,
            to: to.to_string(),
            assignee_changed: false,
            no_op: true,
        });
    }

    // 3. Validate the edge BEFORE any gh mutation.
    if !sm.transition_allowed(from.as_deref(), to) {
        let from_disp = from.as_deref().unwrap_or("null");
        let allowed = sm.allowed_targets_from(from.as_deref());
        anyhow::bail!(
            "invalid transition {from_disp} → {to}; allowed from {from_disp}: [{}]",
            allowed.join(", ")
        );
    }

    // 4. The edge requires evidence and none was given → refuse before any
    //    mutation (trusty-tools closes an issue only with live-verification
    //    evidence in the comment).
    let note = note.filter(|n| !n.trim().is_empty());
    if sm.requires_note(from.as_deref(), to) && note.is_none() {
        let from_disp = from.as_deref().unwrap_or("null");
        anyhow::bail!(
            "transition {from_disp} → {to} requires evidence; \
             re-run with `--note \"<what proves it>\"`"
        );
    }

    // 5. The label mutation, always ONE call when both ends are labelled, so an
    //    issue can never be observed carrying two state labels.
    let to_label = sm.state_label(to);
    let from_label = from.as_deref().and_then(|f| sm.state_label(f));
    match (to_label, from_label) {
        (Some(add), Some(remove)) => sys.swap_labels(issue, add, remove)?,
        (Some(add), None) => sys.add_label(issue, add)?,
        // Moving to a label-less state (`open`, `closed`) drops the old label.
        (None, Some(remove)) => sys.remove_label(issue, remove)?,
        (None, None) => {}
    }

    // 6. Apply the per-state assignee rule (no-op for factory `unchanged`).
    let mut assignee_changed = false;
    if let Some(target) = sm.assignee_target_for(to) {
        // The `None` clear-all rule needs the current assignee set (read side).
        let current = if matches!(target, AssigneeTarget::None) {
            issue_obj.assignees.clone()
        } else {
            Vec::new()
        };
        sys.set_assignee(issue, &target, &current)?;
        assignee_changed = true;
    }

    // 7. Audit comment (visibility): record from → to + any note. Posted BEFORE
    //    any close so the evidence lands on an issue that is still open.
    let from_disp = from.as_deref().unwrap_or("(none)");
    let mut body = format!("tm issue transition: `{from_disp}` → `{to}`");
    if assignee_changed {
        body.push_str(" (assignee rule applied)");
    }
    if let Some(n) = note {
        body.push_str("\n\n");
        body.push_str(n);
    }
    sys.comment(issue, &body)?;

    // 8. A state that IS GitHub's closed state closes the issue.
    if sm.closes_issue(to) {
        sys.close_issue(issue)?;
    }

    Ok(TransitionReport {
        from,
        to: to.to_string(),
        assignee_changed,
        no_op: false,
    })
}

/// `tm issue current <issue#>` — report the issue's current state from labels.
///
/// Why: the read side of the visibility north star — reconstruct state from
/// GitHub artifacts alone.
/// What: fetches the issue, resolves the single state label; returns the state
/// name, or an error for zero/multiple (with a `repair` hint for multiple).
/// Test: `ops_current_reports_state`, `ops_current_errors_on_none`.
pub(crate) fn current<S: TicketSystem>(
    sys: &S,
    model: &StateModel,
    issue: u64,
) -> anyhow::Result<String> {
    let sm = StateMachine::new(model);
    let issue_obj = sys.validate(issue)?;
    match sm.resolve_current_state(&issue_obj.labels, issue_obj.open) {
        CurrentState::One(s) => Ok(s.to_string()),
        CurrentState::None => anyhow::bail!(
            "issue #{issue} carries no recognised state label; valid states: [{}]",
            sm.state_names().join(", ")
        ),
        CurrentState::Many(states) => anyhow::bail!(
            "issue #{issue} carries multiple state labels {states:?}; \
             run `tm issue repair {issue}` to resolve"
        ),
    }
}

/// `tm issue repair <issue#>` — resolve a multi-state issue to a single state.
///
/// Why: a crash mid-transition can leave two state labels on an issue, violating
/// the "exactly one state" invariant. `repair` removes the stale label(s),
/// keeping the most-advanced state (highest `order`) so recovery is deterministic.
/// What: fetches the issue; if exactly one (or zero) state label is present it
/// reports nothing to do (zero is an explicit clear error); if multiple are
/// present it keeps the highest-`order` state and removes the others' labels.
/// Returns the kept state name.
/// Test: `ops_repair_resolves_two_labels`, `ops_repair_noop_when_single`,
/// `ops_repair_errors_on_zero`.
pub(crate) fn repair<S: TicketSystem>(
    sys: &S,
    model: &StateModel,
    issue: u64,
) -> anyhow::Result<String> {
    let sm = StateMachine::new(model);
    let issue_obj = sys.validate(issue)?;
    let present: Vec<&str> = match sm.resolve_current_state(&issue_obj.labels, issue_obj.open) {
        CurrentState::One(s) => {
            // Nothing to repair — already a single, unambiguous state.
            return Ok(s.to_string());
        }
        CurrentState::None => anyhow::bail!(
            "issue #{issue} carries no state label; nothing to repair — \
             apply a state with `tm issue transition` instead"
        ),
        CurrentState::Many(states) => states,
    };

    // Keep the most-advanced state (highest declared `order`; fall back to the
    // first-declared on ties / missing order), remove the rest.
    let keep = present
        .iter()
        .max_by_key(|name| {
            model
                .states
                .iter()
                .find(|s| &s.name == *name)
                .and_then(|s| s.order)
                .unwrap_or(0)
        })
        .copied()
        .ok_or_else(|| anyhow::anyhow!("internal: empty multi-state set"))?;

    for name in present.iter().filter(|n| **n != keep) {
        if let Some(label) = sm.state_label(name) {
            sys.remove_label(issue, label)?;
        }
    }
    sys.comment(
        issue,
        &format!("tm issue repair: resolved multiple state labels to `{keep}`"),
    )?;
    Ok(keep.to_string())
}

#[cfg(test)]
#[path = "ops_tests.rs"]
mod ops_tests;