From d7f60eaa311baee046b737d46191a0ae2a64df2b Mon Sep 17 00:00:00 2001 From: Bendik Aagaard Lynghaug Date: Mon, 21 Sep 2026 21:46:11 +0200 Subject: [PATCH] needs: also_moved_by - a need carried by more than one group's desk Roles overlap on one aggregate: a project manager prices a change, a site manager completes it. A need may list the other groups whose desks hold some of its moves; their buttons count toward reaching `done_when` and toward the strand check exactly as `handled_by`'s do. `handled_by` must still hold a desk of its own, so that someone is answerable for the record. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01L4jrCgLiKKHAEFuZUJjckH --- src/needs.rs | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/needs.rs b/src/needs.rs index 949edb6..755c071 100644 --- a/src/needs.rs +++ b/src/needs.rs @@ -86,6 +86,13 @@ pub struct Need { /// becoming open -> quoted) fails instead of passing. #[serde(default)] pub stages: Vec, + /// Other groups whose desks carry some of this need's moves: a + /// site manager closing what a project manager opened. Their + /// buttons count toward reaching `done_when` exactly as + /// `handled_by`'s do. `handled_by` must still hold a desk of its + /// own - someone has to be answerable for the record. + #[serde(default)] + pub also_moved_by: Vec, } fn root() -> String { @@ -392,9 +399,11 @@ pub fn check( let desks = desks_over(questions, &need.lands_in); let own: Vec<&Desk> = desks .iter() - .filter(|d| d.group.as_deref() == Some(need.handled_by.as_str())) + .filter(|d| { + d.group.as_deref().is_some_and(|g| g == need.handled_by || need.also_moved_by.iter().any(|m| m == g)) + }) .collect(); - if own.is_empty() { + if !desks.iter().any(|d| d.group.as_deref() == Some(need.handled_by.as_str())) { let others: Vec = desks .iter() .map(|d| format!("{} ({})", d.page, d.group.as_deref().unwrap_or("ungated"))) @@ -883,6 +892,29 @@ mod tests { ); } + #[test] + fn a_need_can_be_carried_by_more_than_one_groups_desk() { + // owners may only quote or lose; closing a quoted job is the + // site managers' button, on a desk of their own. + let (mut questions, aggregates) = site(" - { to: quoted, label: Quote }\n - { to: lost, label: Lose }\n"); + let more = build_questions(&[( + "site/index.yaml".to_string(), + "name: Site desk\nqualifies: site_managers\nalternatives:\n - name: Projects\n features:\n - name: \"\"\n resource:\n source: { kind: kv, bucket: projects }\n requires_group: site_managers\n transitions:\n - { from: quoted, to: won, label: Win }\n - { from: quoted, to: lost, label: Lose }\n".to_string(), + )]) + .unwrap(); + questions.extend(more); + + let mut file = needs(""); + // Alone, owners cannot finish it... + assert_eq!(fails(&check(&file, &questions, &aggregates)).len(), 2); + // ...with the site managers' buttons counted, they can. + file.needs[0].also_moved_by = vec!["site_managers".to_string()]; + assert!(check(&file, &questions, &aggregates).is_empty()); + // But someone has to be answerable: handled_by needs its own desk. + file.needs[0].handled_by = "nobody".to_string(); + assert!(fails(&check(&file, &questions, &aggregates))[0].starts_with("no desk gated on \"nobody\"")); + } + #[test] fn the_wrong_group_holding_the_desk_is_a_failure() { let (questions, aggregates) = site(FULL_DESK);