majordomus_cli/devtask/readiness.rs
1//! Task readiness: a derived projection of the canonical status, and not a second
2//! vocabulary for it.
3//!
4//! # Why there is one at all
5//!
6//! [`crate::plan::ISSUE_STATUSES`] is the canonical vocabulary — `READY`, `BLOCKED`,
7//! `ACTIVE`, `VERIFY`, `DONE`, `CANCELLED` — and it is right for what it is: the six words
8//! `lib/project.awk` derives, held to byte equality with the shell engine by
9//! `test/cases/99_plan_capabilities.sh`. Nothing here changes, replaces or renames any of
10//! them; every [`TaskReadiness`] carries the canonical status it was derived from, so a caller
11//! that wants the canonical word has it on the same object.
12//!
13//! What it adds is exactly the distinctions the canonical six *already make in the data*
14//! and cannot express in the word:
15//!
16//! * **`BLOCKED` is two situations.** The plan's own derivation appends
17//! `milestone:<id>` to an issue's `blocked_by` when the milestone gate holds it, on top of
18//! whatever issue dependencies it has. An issue with every dependency `DONE`, held back
19//! only because the outcome it belongs to waits on another outcome, is not the same
20//! problem as an issue waiting on a peer: the first is resolved somewhere else entirely.
21//! [`TaskReadiness::Waiting`] is that case; [`TaskReadiness::Blocked`] is the other.
22//! * **`VERIFY` is two situations.** The plan reaches `VERIFY` when `verified_at` or
23//! `completed_at` is set, and it emits the `evidence_missing` finding for the second of
24//! those when the required evidence is not all there. So `VERIFY` covers both "the work
25//! is done and someone must confirm it" and "someone already declared it complete and the
26//! evidence does not back that up" — the second is a defect a person has to act on, the
27//! first is a queue. [`TaskReadiness::Review`] and [`TaskReadiness::CompletionBlocked`].
28//! * **an id the model does not have.** `plan.issues` cannot answer for one at all, and
29//! answering `BLOCKED` or an empty object for a typo is the one answer a work surface must
30//! never give. [`TaskReadiness::Undeclared`].
31//!
32//! and nothing else. There is no `in_review`, no `needs_triage`, no `wontfix`: three words
33//! that no field of any canonical record could distinguish, which is what makes them
34//! frontend strings rather than workflow.
35//!
36//! # The mapping is total and one-way
37//!
38//! Every canonical status maps to exactly one readiness, and [`TaskReadiness::canonical`] maps
39//! back. The three added distinctions are refinements *inside* a canonical status, never
40//! across two, so the round trip through `canonical` is lossless in the direction that
41//! matters: no readiness can claim a canonical status the plan did not derive.
42
43use schemars::JsonSchema;
44use serde::{Deserialize, Serialize};
45
46/// Where a development task stands, as work.
47///
48/// ```
49/// use majordomus_cli::devtask::TaskReadiness;
50///
51/// // every state names the canonical status it refines, and refines only one
52/// assert_eq!(TaskReadiness::Ready.canonical(), "READY");
53/// assert_eq!(TaskReadiness::Waiting.canonical(), "BLOCKED");
54/// assert_eq!(TaskReadiness::Blocked.canonical(), "BLOCKED");
55/// assert_eq!(TaskReadiness::Review.canonical(), "VERIFY");
56/// assert_eq!(TaskReadiness::CompletionBlocked.canonical(), "VERIFY");
57///
58/// // and one state is the absence of a record rather than a status of one
59/// assert!(TaskReadiness::Undeclared.canonical().is_empty());
60/// ```
61#[derive(
62 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
63)]
64#[serde(rename_all = "snake_case")]
65pub enum TaskReadiness {
66 /// Nothing holds it back and nobody has started it. Canonical `READY`.
67 Ready,
68 /// At least one issue it depends on is not `DONE`. Canonical `BLOCKED`.
69 Blocked,
70 /// Every issue dependency is satisfied; the milestone gate holds it. Canonical
71 /// `BLOCKED`, and a distinct situation because it is resolved in another milestone.
72 Waiting,
73 /// Execution began: the record carries `started_at`. Canonical `ACTIVE`.
74 InProgress,
75 /// Implementation is declared finished and completion has not been recorded: the record
76 /// carries `verified_at`. Canonical `VERIFY`.
77 Review,
78 /// Completion *was* recorded and the evidence the record itself requires is not all
79 /// there, so the plan refuses to call it `DONE`. Canonical `VERIFY`, and the case the
80 /// `evidence_missing` finding is about.
81 CompletionBlocked,
82 /// Finished on its own terms: not cancelled, completion recorded, every required
83 /// evidence token present. Canonical `DONE`.
84 Complete,
85 /// Withdrawn. Canonical `CANCELLED`.
86 Cancelled,
87 /// The canonical model does not declare this id. Not a status: the absence of a record.
88 Undeclared,
89}
90
91impl TaskReadiness {
92 /// The canonical status this refines, or the empty string for [`TaskReadiness::Undeclared`],
93 /// which refines none.
94 pub fn canonical(self) -> &'static str {
95 match self {
96 TaskReadiness::Ready => "READY",
97 TaskReadiness::Blocked | TaskReadiness::Waiting => "BLOCKED",
98 TaskReadiness::InProgress => "ACTIVE",
99 TaskReadiness::Review | TaskReadiness::CompletionBlocked => "VERIFY",
100 TaskReadiness::Complete => "DONE",
101 TaskReadiness::Cancelled => "CANCELLED",
102 TaskReadiness::Undeclared => "",
103 }
104 }
105
106 /// The word a projection prints.
107 pub fn as_str(self) -> &'static str {
108 match self {
109 TaskReadiness::Ready => "ready",
110 TaskReadiness::Blocked => "blocked",
111 TaskReadiness::Waiting => "waiting",
112 TaskReadiness::InProgress => "in_progress",
113 TaskReadiness::Review => "review",
114 TaskReadiness::CompletionBlocked => "completion_blocked",
115 TaskReadiness::Complete => "complete",
116 TaskReadiness::Cancelled => "cancelled",
117 TaskReadiness::Undeclared => "undeclared",
118 }
119 }
120
121 /// May a worker pick this up right now?
122 ///
123 /// ```
124 /// use majordomus_cli::devtask::TaskReadiness;
125 /// assert!(TaskReadiness::Ready.is_startable());
126 /// assert!(!TaskReadiness::InProgress.is_startable(), "somebody already has it");
127 /// ```
128 pub fn is_startable(self) -> bool {
129 self == TaskReadiness::Ready
130 }
131
132 /// Is there nothing further to do, whatever happens elsewhere?
133 pub fn is_terminal(self) -> bool {
134 matches!(self, TaskReadiness::Complete | TaskReadiness::Cancelled)
135 }
136
137 /// Every state, in the order a board reads them: the executable ones first, the
138 /// finished ones last. Declared once so that no surface writes the sequence down.
139 pub const ALL: &'static [TaskReadiness] = &[
140 TaskReadiness::Ready,
141 TaskReadiness::InProgress,
142 TaskReadiness::Review,
143 TaskReadiness::CompletionBlocked,
144 TaskReadiness::Blocked,
145 TaskReadiness::Waiting,
146 TaskReadiness::Complete,
147 TaskReadiness::Cancelled,
148 TaskReadiness::Undeclared,
149 ];
150
151 /// Derive the readiness from the facts the plan already holds.
152 ///
153 /// The arguments are exactly what [`crate::plan`] derives and what the canonical record
154 /// declares; nothing is read here, and nothing is guessed. `status` is the canonical
155 /// status, `blockers` the plan's own `blocked_by` list — whose `milestone:` prefix is
156 /// what separates [`TaskReadiness::Waiting`] from [`TaskReadiness::Blocked`] — and
157 /// `completion_recorded` / `evidence_covered` are what separate
158 /// [`TaskReadiness::CompletionBlocked`] from [`TaskReadiness::Review`].
159 ///
160 /// ```
161 /// use majordomus_cli::devtask::TaskReadiness;
162 ///
163 /// // blocked only by its milestone is waiting, not blocked
164 /// let waiting = TaskReadiness::derive("BLOCKED", &["milestone:m1".to_string()], false, true);
165 /// assert_eq!(waiting, TaskReadiness::Waiting);
166 ///
167 /// // blocked by a peer, milestone gate or not, is blocked
168 /// let blocked = TaskReadiness::derive(
169 /// "BLOCKED",
170 /// &["I0002".to_string(), "milestone:m1".to_string()],
171 /// false,
172 /// true,
173 /// );
174 /// assert_eq!(blocked, TaskReadiness::Blocked);
175 ///
176 /// // completion recorded without the evidence it requires
177 /// let stuck = TaskReadiness::derive("VERIFY", &[], true, false);
178 /// assert_eq!(stuck, TaskReadiness::CompletionBlocked);
179 ///
180 /// // verified and waiting to be closed
181 /// assert_eq!(TaskReadiness::derive("VERIFY", &[], false, false), TaskReadiness::Review);
182 ///
183 /// // a status the plan never derives is not silently mapped onto one that looks fine
184 /// assert_eq!(TaskReadiness::derive("NONESUCH", &[], false, false), TaskReadiness::Undeclared);
185 /// ```
186 pub fn derive(
187 status: &str,
188 blockers: &[String],
189 completion_recorded: bool,
190 evidence_covered: bool,
191 ) -> TaskReadiness {
192 match status {
193 "READY" => TaskReadiness::Ready,
194 "BLOCKED" => {
195 let only_the_gate = !blockers.is_empty()
196 && blockers.iter().all(|b| b.starts_with(MILESTONE_BLOCKER));
197 if only_the_gate {
198 TaskReadiness::Waiting
199 } else {
200 TaskReadiness::Blocked
201 }
202 }
203 "ACTIVE" => TaskReadiness::InProgress,
204 "VERIFY" => {
205 if completion_recorded && !evidence_covered {
206 TaskReadiness::CompletionBlocked
207 } else {
208 TaskReadiness::Review
209 }
210 }
211 "DONE" => TaskReadiness::Complete,
212 "CANCELLED" => TaskReadiness::Cancelled,
213 // A status the plan does not declare cannot be mapped onto one that reads as
214 // work. The plan itself reports it as an `unknown_status` finding; here it is
215 // the absence of a usable state, which is what `Undeclared` means.
216 _ => TaskReadiness::Undeclared,
217 }
218 }
219}
220
221/// The prefix the plan's own `blocked_by` uses for the milestone gate. Stated once, because
222/// two readers of the same token is how a prefix quietly changes on one side only.
223pub const MILESTONE_BLOCKER: &str = "milestone:";
224
225/// What kind of thing is holding a task back. The five are the five the canonical model can
226/// actually produce; there is no `other`.
227#[derive(
228 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, JsonSchema,
229)]
230#[serde(rename_all = "snake_case")]
231pub enum BlockerKind {
232 /// An issue this one depends on that is not `DONE`.
233 Issue,
234 /// The milestone gate: an outcome this one's milestone requires is not `DONE`.
235 Milestone,
236 /// A required evidence token the record does not carry, with completion recorded.
237 Evidence,
238 /// A dependency that resolves to nothing: the model has no such issue.
239 Reference,
240 /// A dependency cycle this task is in or downstream of, so it can never become ready.
241 Cycle,
242}
243
244impl BlockerKind {
245 /// The word a projection prints.
246 pub fn as_str(self) -> &'static str {
247 match self {
248 BlockerKind::Issue => "issue",
249 BlockerKind::Milestone => "milestone",
250 BlockerKind::Evidence => "evidence",
251 BlockerKind::Reference => "reference",
252 BlockerKind::Cycle => "cycle",
253 }
254 }
255
256 /// Is this a defect in the model rather than work still to be done? A reference that
257 /// resolves to nothing and a cycle are both bugs in the records; the other three are
258 /// the ordinary state of a plan being executed.
259 ///
260 /// ```
261 /// use majordomus_cli::devtask::BlockerKind;
262 /// assert!(BlockerKind::Reference.is_defect());
263 /// assert!(!BlockerKind::Issue.is_defect());
264 /// ```
265 pub fn is_defect(self) -> bool {
266 matches!(self, BlockerKind::Reference | BlockerKind::Cycle)
267 }
268}
269
270/// One thing standing between a task and being startable, with what to do about it.
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
272pub struct Blocker {
273 /// What kind of thing it is.
274 pub kind: BlockerKind,
275 /// The thing: an issue id, a milestone id, an evidence token.
276 pub subject: String,
277 /// What is wrong, in one line a person can act on.
278 pub detail: String,
279}
280
281impl Blocker {
282 /// One blocker.
283 pub fn new(kind: BlockerKind, subject: impl Into<String>, detail: impl Into<String>) -> Self {
284 Self {
285 kind,
286 subject: subject.into(),
287 detail: detail.into(),
288 }
289 }
290}
291
292#[cfg(test)]
293mod tests {
294 use super::*;
295 use crate::plan::ISSUE_STATUSES;
296
297 /// Every canonical status the plan can derive maps to a readiness that names it back.
298 /// The assertion that keeps this a projection rather than a parallel vocabulary: a
299 /// seventh canonical status added to `lib/project.awk` and mirrored into
300 /// `ISSUE_STATUSES` fails here rather than reaching a surface as `undeclared`.
301 #[test]
302 fn every_canonical_status_is_covered_and_maps_back_to_itself() {
303 for status in ISSUE_STATUSES {
304 let r = TaskReadiness::derive(status, &[], false, false);
305 assert_ne!(
306 r,
307 TaskReadiness::Undeclared,
308 "{status} reaches no readiness state"
309 );
310 assert_eq!(
311 r.canonical(),
312 *status,
313 "{status} maps to {} which names {} instead",
314 r.as_str(),
315 r.canonical()
316 );
317 }
318 }
319
320 /// The refinements refine: each added state names the same canonical status as the state
321 /// it was split from, so no readiness can claim a status the plan did not derive.
322 #[test]
323 fn no_readiness_claims_a_status_the_plan_does_not_declare() {
324 for r in TaskReadiness::ALL {
325 let c = r.canonical();
326 if c.is_empty() {
327 assert_eq!(*r, TaskReadiness::Undeclared);
328 continue;
329 }
330 assert!(
331 ISSUE_STATUSES.contains(&c),
332 "{} claims the status {c}, which the plan does not declare",
333 r.as_str()
334 );
335 }
336 }
337
338 /// `ALL` is the whole vocabulary: a variant added and not listed would be invisible to
339 /// every board that reads the order from here.
340 #[test]
341 fn the_declared_order_holds_every_state_once() {
342 let mut seen: Vec<&str> = TaskReadiness::ALL.iter().map(|r| r.as_str()).collect();
343 let n = seen.len();
344 seen.sort_unstable();
345 seen.dedup();
346 assert_eq!(seen.len(), n, "a state is listed twice");
347 // one per canonical status, plus the three refinements, plus the absence
348 assert_eq!(n, ISSUE_STATUSES.len() + 3);
349 }
350
351 /// A blocked issue with no blockers at all would be a contradiction in the plan; it is
352 /// reported as `blocked` rather than as `waiting`, because "only the gate holds it" is a
353 /// claim about a gate that is not there.
354 #[test]
355 fn blocked_with_no_blocker_named_is_not_waiting() {
356 assert_eq!(
357 TaskReadiness::derive("BLOCKED", &[], false, false),
358 TaskReadiness::Blocked
359 );
360 }
361
362 /// Only `Ready` is startable, and the two finished states are the only terminal ones.
363 #[test]
364 fn startable_and_terminal_are_disjoint_and_narrow() {
365 let startable: Vec<&str> = TaskReadiness::ALL
366 .iter()
367 .filter(|r| r.is_startable())
368 .map(|r| r.as_str())
369 .collect();
370 assert_eq!(startable, ["ready"]);
371 let terminal: Vec<&str> = TaskReadiness::ALL
372 .iter()
373 .filter(|r| r.is_terminal())
374 .map(|r| r.as_str())
375 .collect();
376 assert_eq!(terminal, ["complete", "cancelled"]);
377 }
378}