huskies: merge 857

This commit is contained in:
dave
2026-04-29 17:38:38 +00:00
parent 8a42839b37
commit fc86774618
12 changed files with 566 additions and 159 deletions
+226 -156
View File
@@ -2,14 +2,21 @@
//! //!
//! All pipeline state lives in the CRDT. These functions never consult the //! All pipeline state lives in the CRDT. These functions never consult the
//! filesystem for work-item data — CRDT lookup failures propagate as errors. //! filesystem for work-item data — CRDT lookup failures propagate as errors.
//!
//! Every lifecycle function routes through the typed state machine
//! ([`crate::pipeline_state::apply_transition`]) so that illegal transitions
//! are rejected and every stage change emits a [`TransitionFired`] event.
use std::num::NonZeroU32;
use std::path::Path; use std::path::Path;
use std::process::Command; use std::process::Command;
use crate::io::story_metadata::clear_front_matter_field_in_content; use crate::io::story_metadata::clear_front_matter_field_in_content;
use crate::pipeline_state::{
ApplyError, ArchiveReason, BranchName, GitSha, PipelineEvent, Stage, apply_transition,
stage_label,
};
use crate::slog; use crate::slog;
type ContentTransform = Option<Box<dyn Fn(&str) -> String>>;
/// Determine the item type ("story", "bug", "spike", or "refactor") from the item ID. /// Determine the item type ("story", "bug", "spike", or "refactor") from the item ID.
/// ///
/// For slug-format IDs (e.g. `"4_bug_login_crash"`), the type is embedded in the ID. /// For slug-format IDs (e.g. `"4_bug_login_crash"`), the type is embedded in the ID.
@@ -40,78 +47,21 @@ pub(crate) fn item_type_from_id(item_id: &str) -> &'static str {
"story" "story"
} }
/// Move a work item to a new pipeline stage via the database. type ContentTransform = Box<dyn Fn(&str) -> String>;
///
/// Looks up the item in the CRDT to verify it exists in one of the expected /// Build a content-transform closure that clears the given front-matter fields.
/// `sources` stages, then updates the stage. Optionally clears front-matter fn fields_to_clear_transform(fields: &[&str]) -> Option<ContentTransform> {
/// fields from the stored content. Returns the source stage on success. if fields.is_empty() {
fn move_item<'a>( return None;
story_id: &str, }
sources: &'a [&'a str], let fields: Vec<String> = fields.iter().map(|s| s.to_string()).collect();
target_dir: &str, Some(Box::new(move |content: &str| {
extra_done_dirs: &[&str], let mut result = content.to_string();
missing_ok: bool, for field in &fields {
fields_to_clear: &[&str], result = clear_front_matter_field_in_content(&result, field);
) -> Result<Option<&'a str>, String> {
// Check if the item is already in the target stage or a done stage.
// Use the typed projection for compile-safe stage comparison.
if let Ok(Some(typed_item)) = crate::pipeline_state::read_typed(story_id) {
let current_dir = typed_item.stage.dir_name();
if current_dir == target_dir || extra_done_dirs.contains(&current_dir) {
return Ok(None); // Idempotent: already there.
} }
result
// Verify it's in one of the expected source stages. }))
let src_dir = sources.iter().find(|&&s| current_dir == s).copied();
let src_dir = match src_dir {
Some(s) => s,
None if missing_ok => {
// Item is in CRDT but not in an expected source stage — do not
// overwrite its current stage. This prevents promote_ready_backlog_stories
// from demoting a story that has already advanced to merge/done (bug 524).
return Ok(None);
}
None => {
let locs = sources
.iter()
.map(|s| format!("work/{s}/"))
.collect::<Vec<_>>()
.join(" or ");
return Err(format!("Work item '{story_id}' not found in {locs}."));
}
};
// Optionally clear front-matter fields from the stored content.
let transform: ContentTransform = if fields_to_clear.is_empty() {
None
} else {
let fields: Vec<String> = fields_to_clear.iter().map(|s| s.to_string()).collect();
Some(Box::new(move |content: &str| {
let mut result = content.to_string();
for field in &fields {
result = clear_front_matter_field_in_content(&result, field);
}
result
}))
};
crate::db::move_item_stage(story_id, target_dir, transform.as_ref().map(|f| f.as_ref()));
slog!("[lifecycle] Moved '{story_id}' from work/{src_dir}/ to work/{target_dir}/");
return Ok(Some(src_dir));
}
if missing_ok {
slog!("[lifecycle] Work item '{story_id}' not found; skipping move to work/{target_dir}/");
return Ok(None);
}
let locs = sources
.iter()
.map(|s| format!("work/{s}/"))
.collect::<Vec<_>>()
.join(" or ");
Err(format!("Work item '{story_id}' not found in {locs}."))
} }
/// Move a work item (story, bug, or spike) from `1_backlog` to `work/2_current/`. /// Move a work item (story, bug, or spike) from `1_backlog` to `work/2_current/`.
@@ -121,7 +71,18 @@ fn move_item<'a>(
/// that has already advanced past the coding stage. /// that has already advanced past the coding stage.
/// Idempotent: if already in `2_current/`, returns Ok. If not found, logs and returns Ok. /// Idempotent: if already in `2_current/`, returns Ok. If not found, logs and returns Ok.
pub fn move_story_to_current(story_id: &str) -> Result<(), String> { pub fn move_story_to_current(story_id: &str) -> Result<(), String> {
move_item(story_id, &["1_backlog"], "2_current", &[], true, &[]).map(|_| ()) match apply_transition(story_id, PipelineEvent::DepsMet, None) {
Ok(_) => Ok(()),
Err(ApplyError::NotFound(_)) => {
slog!("[lifecycle] Work item '{story_id}' not found; skipping move to work/2_current/");
Ok(())
}
Err(ApplyError::InvalidTransition(_)) => {
// Already promoted or in a later stage — idempotent no-op.
Ok(())
}
Err(ApplyError::Projection(_)) => Ok(()),
}
} }
/// Check whether a feature branch `feature/story-{story_id}` exists and has /// Check whether a feature branch `feature/story-{story_id}` exists and has
@@ -160,68 +121,165 @@ pub fn feature_branch_has_unmerged_changes(project_root: &Path, story_id: &str)
/// Idempotent if already in `5_done/` or `6_archived/`. Errors if not found in any earlier stage. /// Idempotent if already in `5_done/` or `6_archived/`. Errors if not found in any earlier stage.
/// Spikes may transition directly from `3_qa/` to `5_done/`, skipping the merge stage. /// Spikes may transition directly from `3_qa/` to `5_done/`, skipping the merge stage.
pub fn move_story_to_done(story_id: &str) -> Result<(), String> { pub fn move_story_to_done(story_id: &str) -> Result<(), String> {
move_item( let item = read_typed_or_err(story_id)?;
story_id, let dir = item.stage.dir_name();
&["2_current", "3_qa", "4_merge"],
"5_done", // Idempotent: already at or past done.
&["6_archived"], if dir >= "5_done" {
false, return Ok(());
&["merge_failure", "blocked"], }
)
.map(|_| ()) let event = match &item.stage {
Stage::Merge { .. } => PipelineEvent::MergeSucceeded {
merge_commit: GitSha("accepted".to_string()),
},
Stage::Coding | Stage::Qa | Stage::Backlog => PipelineEvent::Close,
_ => {
return Err(format!(
"Work item '{story_id}' is in {} — cannot move to done.",
stage_label(&item.stage)
));
}
};
let transform = fields_to_clear_transform(&["merge_failure", "blocked"]);
apply_transition(story_id, event, transform.as_ref().map(|f| f.as_ref()))
.map(|_| ())
.map_err(|e| e.to_string())
} }
/// Move a story/bug from `work/2_current/` or `work/3_qa/` to `work/4_merge/`. /// Move a story/bug from `work/2_current/` or `work/3_qa/` to `work/4_merge/`.
/// ///
/// Idempotent if already in `4_merge/`. Errors if not found in `2_current/` or `3_qa/`. /// Idempotent if already in `4_merge/`. Errors if not found in `2_current/` or `3_qa/`.
pub fn move_story_to_merge(story_id: &str) -> Result<(), String> { pub fn move_story_to_merge(story_id: &str) -> Result<(), String> {
move_item( let item = read_typed_or_err(story_id)?;
story_id, let dir = item.stage.dir_name();
&["2_current", "3_qa"],
"4_merge", // Idempotent: already at or past merge.
&["5_done", "6_archived"], if dir >= "4_merge" {
false, return Ok(());
&["blocked"], }
)
.map(|_| ()) let branch = BranchName(format!("feature/story-{story_id}"));
let commits = NonZeroU32::new(1).expect("1 is non-zero");
let event = match &item.stage {
Stage::Coding => PipelineEvent::QaSkipped {
feature_branch: branch,
commits_ahead: commits,
},
Stage::Qa => PipelineEvent::GatesPassed {
feature_branch: branch,
commits_ahead: commits,
},
_ => {
return Err(format!(
"Work item '{story_id}' not found in work/2_current/ or work/3_qa/."
));
}
};
let transform = fields_to_clear_transform(&["blocked"]);
apply_transition(story_id, event, transform.as_ref().map(|f| f.as_ref()))
.map(|_| ())
.map_err(|e| e.to_string())
} }
/// Move a story/bug from `work/2_current/` to `work/3_qa/`. /// Move a story/bug from `work/2_current/` to `work/3_qa/`.
/// ///
/// Idempotent if already in `3_qa/`. Errors if not found in `2_current/`. /// Idempotent if already in `3_qa/`. Errors if not found in `2_current/`.
pub fn move_story_to_qa(story_id: &str) -> Result<(), String> { pub fn move_story_to_qa(story_id: &str) -> Result<(), String> {
move_item( let item = read_typed_or_err(story_id)?;
let dir = item.stage.dir_name();
// Idempotent: already at or past qa.
if dir >= "3_qa" {
return Ok(());
}
let transform = fields_to_clear_transform(&["blocked"]);
apply_transition(
story_id, story_id,
&["2_current"], PipelineEvent::GatesStarted,
"3_qa", transform.as_ref().map(|f| f.as_ref()),
&["5_done", "6_archived"],
false,
&["blocked"],
) )
.map(|_| ()) .map(|_| ())
.map_err(|e| e.to_string())
} }
/// Move a story from `work/3_qa/` back to `work/2_current/`, clearing `review_hold` and writing notes. /// Move a story from `work/3_qa/` back to `work/2_current/`, clearing `review_hold` and writing notes.
pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> { pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> {
let moved = move_item( let notes_owned = notes.to_string();
story_id, let transform: Box<dyn Fn(&str) -> String> = Box::new(move |content: &str| {
&["3_qa"], let mut result = clear_front_matter_field_in_content(content, "review_hold");
"2_current", if !notes_owned.is_empty() {
&[], result =
false, crate::io::story_metadata::write_rejection_notes_to_content(&result, &notes_owned);
&["review_hold"],
)?;
if moved.is_some() && !notes.is_empty() {
// Append rejection notes to the stored content.
if let Some(content) = crate::db::read_content(story_id) {
let updated =
crate::io::story_metadata::write_rejection_notes_to_content(&content, notes);
crate::db::write_content(story_id, &updated);
// Re-sync to DB.
crate::db::write_item_with_content(story_id, "2_current", &updated);
} }
result
});
apply_transition(
story_id,
PipelineEvent::GatesFailed {
reason: notes.to_string(),
},
Some(&*transform),
)
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Map a (current stage, target stage name) pair to the appropriate PipelineEvent.
fn map_stage_move_to_event(
from: &Stage,
target: &str,
story_id: &str,
) -> Result<PipelineEvent, String> {
let branch = || BranchName(format!("feature/story-{story_id}"));
let nz1 = || NonZeroU32::new(1).expect("1 is non-zero");
match (from, target) {
(Stage::Upcoming, "backlog") => Ok(PipelineEvent::Triage),
(Stage::Backlog, "current") => Ok(PipelineEvent::DepsMet),
(Stage::Coding, "qa") => Ok(PipelineEvent::GatesStarted),
(Stage::Coding, "merge") => Ok(PipelineEvent::QaSkipped {
feature_branch: branch(),
commits_ahead: nz1(),
}),
(Stage::Qa, "merge") => Ok(PipelineEvent::GatesPassed {
feature_branch: branch(),
commits_ahead: nz1(),
}),
(Stage::Coding, "backlog") | (Stage::Qa, "backlog") | (Stage::Merge { .. }, "backlog") => {
Ok(PipelineEvent::Demote)
}
(Stage::Qa, "current") => Ok(PipelineEvent::GatesFailed {
reason: "manual move".to_string(),
}),
(Stage::Merge { .. }, "done") => Ok(PipelineEvent::MergeSucceeded {
merge_commit: GitSha("manual".to_string()),
}),
(Stage::Coding | Stage::Qa | Stage::Backlog, "done") => Ok(PipelineEvent::Close),
(
Stage::Archived {
reason: ArchiveReason::Blocked { .. },
..
},
"backlog",
)
| (
Stage::Archived {
reason: ArchiveReason::MergeFailed { .. },
..
},
"backlog",
) => Ok(PipelineEvent::Unblock),
_ => Err(format!(
"Invalid target_stage '{target}'. Cannot transition from {} to {target}.",
stage_label(from),
)),
} }
Ok(())
} }
/// Move any work item to an arbitrary pipeline stage by searching all stages. /// Move any work item to an arbitrary pipeline stage by searching all stages.
@@ -230,56 +288,68 @@ pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> {
/// Idempotent: if the item is already in the target stage, returns Ok. /// Idempotent: if the item is already in the target stage, returns Ok.
/// Returns `(from_stage, to_stage)` on success. /// Returns `(from_stage, to_stage)` on success.
pub fn move_story_to_stage(story_id: &str, target_stage: &str) -> Result<(String, String), String> { pub fn move_story_to_stage(story_id: &str, target_stage: &str) -> Result<(String, String), String> {
const STAGES: &[(&str, &str)] = &[ // Validate target.
("backlog", "1_backlog"), let target_dir = match target_stage {
("current", "2_current"), "backlog" => "1_backlog",
("qa", "3_qa"), "current" => "2_current",
("merge", "4_merge"), "qa" => "3_qa",
("done", "5_done"), "merge" => "4_merge",
("archived", "6_archived"), "done" => "5_done",
]; _ => {
return Err(format!(
let target_dir = STAGES
.iter()
.filter(|(name, _)| *name != "archived")
.find(|(name, _)| *name == target_stage)
.map(|(_, dir)| *dir)
.ok_or_else(|| {
format!(
"Invalid target_stage '{target_stage}'. Must be one of: backlog, current, qa, merge, done" "Invalid target_stage '{target_stage}'. Must be one of: backlog, current, qa, merge, done"
) ));
})?;
let all_dirs: Vec<&str> = STAGES.iter().map(|(_, dir)| *dir).collect();
match move_item(story_id, &all_dirs, target_dir, &[], false, &[])
.map_err(|_| format!("Work item '{story_id}' not found in any pipeline stage."))?
{
Some(src_dir) => {
let from_stage = STAGES
.iter()
.find(|(_, dir)| *dir == src_dir)
.map(|(name, _)| *name)
.unwrap_or(src_dir);
Ok((from_stage.to_string(), target_stage.to_string()))
} }
None => Ok((target_stage.to_string(), target_stage.to_string())), };
let item = read_typed_or_err(story_id)?;
let from_name = stage_to_name(&item.stage);
// Idempotent: already in the target stage.
if item.stage.dir_name() == target_dir {
return Ok((target_stage.to_string(), target_stage.to_string()));
} }
let event = map_stage_move_to_event(&item.stage, target_stage, story_id)?;
apply_transition(story_id, event, None).map_err(|e| e.to_string())?;
Ok((from_name.to_string(), target_stage.to_string()))
} }
/// Move a bug from `work/2_current/` or `work/1_backlog/` to `work/5_done/`. /// Move a bug from `work/2_current/` or `work/1_backlog/` to `work/5_done/`.
/// ///
/// Idempotent if already in `5_done/`. Errors if not found in `2_current/` or `1_backlog/`. /// Idempotent if already in `5_done/`. Errors if not found in `2_current/` or `1_backlog/`.
pub fn close_bug_to_archive(bug_id: &str) -> Result<(), String> { pub fn close_bug_to_archive(bug_id: &str) -> Result<(), String> {
move_item( let item = read_typed_or_err(bug_id)?;
bug_id,
&["2_current", "1_backlog"], if item.stage.dir_name() >= "5_done" {
"5_done", return Ok(());
&[], }
false,
&[], apply_transition(bug_id, PipelineEvent::Close, None)
) .map(|_| ())
.map(|_| ()) .map_err(|e| e.to_string())
}
/// Read a typed pipeline item or return a user-facing error.
fn read_typed_or_err(story_id: &str) -> Result<crate::pipeline_state::PipelineItem, String> {
crate::pipeline_state::read_typed(story_id)
.map_err(|e| format!("Work item '{story_id}': {e}"))?
.ok_or_else(|| format!("Work item '{story_id}' not found in any pipeline stage."))
}
/// Map a Stage variant to the short name used by `move_story_to_stage` return values.
fn stage_to_name(s: &Stage) -> &'static str {
match s {
Stage::Upcoming => "upcoming",
Stage::Backlog => "backlog",
Stage::Coding => "current",
Stage::Qa => "qa",
Stage::Merge { .. } => "merge",
Stage::Done { .. } => "done",
Stage::Archived { .. } => "archived",
}
} }
#[cfg(test)] #[cfg(test)]
@@ -122,6 +122,7 @@ pub async fn handle_delete(
fn stage_display_name(stage: &str) -> &str { fn stage_display_name(stage: &str) -> &str {
use crate::pipeline_state::Stage; use crate::pipeline_state::Stage;
match Stage::from_dir(stage) { match Stage::from_dir(stage) {
Some(Stage::Upcoming) => "upcoming",
Some(Stage::Backlog) => "backlog", Some(Stage::Backlog) => "backlog",
Some(Stage::Coding) => "in-progress", Some(Stage::Coding) => "in-progress",
Some(Stage::Qa) => "QA", Some(Stage::Qa) => "QA",
+1
View File
@@ -138,6 +138,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
}, },
}; };
match &item.stage { match &item.stage {
Stage::Upcoming => state.backlog.push(story), // upcoming shown with backlog
Stage::Backlog => state.backlog.push(story), Stage::Backlog => state.backlog.push(story),
Stage::Coding => state.current.push(story), Stage::Coding => state.current.push(story),
Stage::Qa => state.qa.push(story), Stage::Qa => state.qa.push(story),
+1
View File
@@ -49,6 +49,7 @@ pub fn is_config_file(path: &Path, git_root: &Path) -> bool {
pub fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)> { pub fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)> {
use crate::pipeline_state::Stage; use crate::pipeline_state::Stage;
let (action, msg) = match Stage::from_dir(stage)? { let (action, msg) = match Stage::from_dir(stage)? {
Stage::Upcoming => ("create", format!("huskies: triage {item_id}")),
Stage::Backlog => ("create", format!("huskies: create {item_id}")), Stage::Backlog => ("create", format!("huskies: create {item_id}")),
Stage::Coding => ("start", format!("huskies: start {item_id}")), Stage::Coding => ("start", format!("huskies: start {item_id}")),
Stage::Qa => ("qa", format!("huskies: queue {item_id} for QA")), Stage::Qa => ("qa", format!("huskies: queue {item_id} for QA")),
+99
View File
@@ -0,0 +1,99 @@
//! Single entry point for pipeline state transitions.
//!
//! [`apply_transition`] is the **only** function that should mutate a story's
//! pipeline stage. It reads the current typed stage from the CRDT, validates
//! the transition via the pure [`super::transition()`] function, writes the new
//! stage back to the CRDT, and returns a [`TransitionFired`] event for
//! downstream subscribers.
use super::{
PipelineEvent, StoryId, TransitionFired, event_label, read_typed, stage_label, transition,
};
use chrono::Utc;
/// Error type for [`apply_transition`].
#[derive(Debug)]
pub enum ApplyError {
/// The story was not found in the CRDT.
NotFound(String),
/// The CRDT projection failed.
Projection(super::ProjectionError),
/// The transition was invalid for the current stage.
InvalidTransition(super::TransitionError),
}
impl std::fmt::Display for ApplyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound(id) => write!(f, "story '{id}' not found in CRDT"),
Self::Projection(e) => write!(f, "projection error: {e}"),
Self::InvalidTransition(e) => write!(f, "{e}"),
}
}
}
impl std::error::Error for ApplyError {}
impl From<super::ProjectionError> for ApplyError {
fn from(e: super::ProjectionError) -> Self {
Self::Projection(e)
}
}
impl From<super::TransitionError> for ApplyError {
fn from(e: super::TransitionError) -> Self {
Self::InvalidTransition(e)
}
}
/// Apply a pipeline event to a story, validating the transition and writing
/// the new stage to the CRDT.
///
/// This is the single canonical entry point for all pipeline stage mutations.
/// Returns a [`TransitionFired`] describing what happened, suitable for
/// broadcasting to event-bus subscribers and CRDT log consumers.
///
/// An optional `content_transform` allows callers to modify the stored content
/// (e.g. clearing front-matter fields) atomically with the stage change.
pub fn apply_transition(
story_id: &str,
event: PipelineEvent,
content_transform: Option<&dyn Fn(&str) -> String>,
) -> Result<TransitionFired, ApplyError> {
let item = read_typed(story_id)?.ok_or_else(|| ApplyError::NotFound(story_id.to_string()))?;
let before = item.stage.clone();
let after = transition(before.clone(), event.clone())?;
let new_dir = after.dir_name();
// Write the new stage to the CRDT (with optional content transform).
crate::db::move_item_stage(story_id, new_dir, content_transform);
let fired = TransitionFired {
story_id: StoryId(story_id.to_string()),
before,
after,
event,
at: Utc::now(),
};
crate::slog!(
"[pipeline/transition] #{}: {} + {} → {}",
story_id,
stage_label(&fired.before),
event_label(&fired.event),
stage_label(&fired.after),
);
Ok(fired)
}
/// Convenience: apply a transition, returning an `Err(String)` on failure
/// (matches the existing lifecycle function signatures).
pub fn apply_transition_str(
story_id: &str,
event: PipelineEvent,
content_transform: Option<&dyn Fn(&str) -> String>,
) -> Result<TransitionFired, String> {
apply_transition(story_id, event, content_transform).map_err(|e| e.to_string())
}
+4
View File
@@ -23,6 +23,7 @@
// the dead_code lint is suppressed for the module. // the dead_code lint is suppressed for the module.
#![allow(dead_code)] #![allow(dead_code)]
mod apply;
mod events; mod events;
mod projection; mod projection;
mod subscribers; mod subscribers;
@@ -52,6 +53,9 @@ pub use events::{EventBus, TransitionFired, TransitionSubscriber};
pub use projection::{ProjectionError, project_stage}; pub use projection::{ProjectionError, project_stage};
pub use projection::{read_all_typed, read_typed}; pub use projection::{read_all_typed, read_typed};
#[allow(unused_imports)]
pub use apply::{ApplyError, apply_transition, apply_transition_str};
#[allow(unused_imports)] #[allow(unused_imports)]
pub use subscribers::{ pub use subscribers::{
AutoAssignSubscriber, FileRendererSubscriber, MatrixBotSubscriber, PipelineItemsSubscriber, AutoAssignSubscriber, FileRendererSubscriber, MatrixBotSubscriber, PipelineItemsSubscriber,
+22
View File
@@ -70,6 +70,7 @@ impl TryFrom<&PipelineItemView> for PipelineItem {
/// loose CRDT data becomes typed. /// loose CRDT data becomes typed.
pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError> { pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError> {
match view.stage.as_str() { match view.stage.as_str() {
"0_upcoming" => Ok(Stage::Upcoming),
"1_backlog" => Ok(Stage::Backlog), "1_backlog" => Ok(Stage::Backlog),
"2_current" => Ok(Stage::Coding), "2_current" => Ok(Stage::Coding),
"3_qa" => Ok(Stage::Qa), "3_qa" => Ok(Stage::Qa),
@@ -192,6 +193,26 @@ mod tests {
StoryId(s.to_string()) StoryId(s.to_string())
} }
#[test]
fn project_upcoming_item() {
let view = PipelineItemView {
story_id: "42_story_test".to_string(),
stage: "0_upcoming".to_string(),
name: Some("Test Story".to_string()),
agent: None,
retry_count: None,
blocked: None,
depends_on: None,
claimed_by: None,
claimed_at: None,
merged_at: None,
qa_mode: None,
mergemaster_attempted: None,
};
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Upcoming));
}
#[test] #[test]
fn project_backlog_item() { fn project_backlog_item() {
let view = PipelineItemView { let view = PipelineItemView {
@@ -345,6 +366,7 @@ mod tests {
#[test] #[test]
fn reverse_projection_stage_dirs() { fn reverse_projection_stage_dirs() {
let cases: Vec<(Stage, &str, bool)> = vec![ let cases: Vec<(Stage, &str, bool)> = vec![
(Stage::Upcoming, "0_upcoming", false),
(Stage::Backlog, "1_backlog", false), (Stage::Backlog, "1_backlog", false),
(Stage::Coding, "2_current", false), (Stage::Coding, "2_current", false),
(Stage::Qa, "3_qa", false), (Stage::Qa, "3_qa", false),
+132
View File
@@ -406,4 +406,136 @@ fn transition_error_display() {
assert_eq!(err.to_string(), "invalid transition: Backlog + Accepted"); assert_eq!(err.to_string(), "invalid transition: Backlog + Accepted");
} }
// ── Upcoming / Triage ──────────────────────────────────────────────
#[test]
fn triage_upcoming_to_backlog() {
let s = Stage::Upcoming;
let s = transition(s, PipelineEvent::Triage).unwrap();
assert!(matches!(s, Stage::Backlog));
}
#[test]
fn cannot_triage_from_backlog() {
let result = transition(Stage::Backlog, PipelineEvent::Triage);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn abandon_from_upcoming() {
let result = transition(Stage::Upcoming, PipelineEvent::Abandon).unwrap();
assert!(matches!(
result,
Stage::Archived {
reason: ArchiveReason::Abandoned,
..
}
));
}
#[test]
fn supersede_from_upcoming() {
let result = transition(
Stage::Upcoming,
PipelineEvent::Supersede {
by: sid("999_story_new"),
},
)
.unwrap();
assert!(matches!(
result,
Stage::Archived {
reason: ArchiveReason::Superseded { .. },
..
}
));
}
#[test]
fn cannot_deps_met_from_upcoming() {
let result = transition(Stage::Upcoming, PipelineEvent::DepsMet);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
// ── Reject ─────────────────────────────────────────────────────────
#[test]
fn reject_from_active_stages() {
for s in [Stage::Backlog, Stage::Coding, Stage::Qa] {
let result = transition(
s.clone(),
PipelineEvent::Reject {
reason: "not needed".into(),
},
);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Rejected { .. },
..
})
));
}
let m = Stage::Merge {
feature_branch: fb("f"),
commits_ahead: nz(1),
};
let result = transition(
m,
PipelineEvent::Reject {
reason: "not needed".into(),
},
);
assert!(matches!(
result,
Ok(Stage::Archived {
reason: ArchiveReason::Rejected { .. },
..
})
));
}
#[test]
fn cannot_reject_from_done() {
let s = Stage::Done {
merged_at: chrono::Utc::now(),
merge_commit: sha("abc"),
};
let result = transition(
s,
PipelineEvent::Reject {
reason: "too late".into(),
},
);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
#[test]
fn cannot_reject_from_archived() {
let s = Stage::Archived {
archived_at: chrono::Utc::now(),
reason: ArchiveReason::Completed,
};
let result = transition(
s,
PipelineEvent::Reject {
reason: "already done".into(),
},
);
assert!(matches!(
result,
Err(TransitionError::InvalidTransition { .. })
));
}
// ── ProjectionError Display ───────────────────────────────────────── // ── ProjectionError Display ─────────────────────────────────────────
+46 -3
View File
@@ -47,6 +47,15 @@ pub enum PipelineEvent {
Supersede { by: StoryId }, Supersede { by: StoryId },
/// Story put on review hold. /// Story put on review hold.
ReviewHold { reason: String }, ReviewHold { reason: String },
/// Story rejected by QA or reviewer.
Reject { reason: String },
/// Story triaged from upcoming to backlog.
Triage,
/// Direct completion — item closed without going through the full pipeline
/// (e.g. spike auto-merge, bug closure, manual acceptance).
Close,
/// Manual demotion back to backlog from an active stage.
Demote,
} }
// ── Per-node execution events ─────────────────────────────────────────────── // ── Per-node execution events ───────────────────────────────────────────────
@@ -81,6 +90,10 @@ pub fn event_label(e: &PipelineEvent) -> &'static str {
PipelineEvent::Abandon => "Abandon", PipelineEvent::Abandon => "Abandon",
PipelineEvent::Supersede { .. } => "Supersede", PipelineEvent::Supersede { .. } => "Supersede",
PipelineEvent::ReviewHold { .. } => "ReviewHold", PipelineEvent::ReviewHold { .. } => "ReviewHold",
PipelineEvent::Reject { .. } => "Reject",
PipelineEvent::Triage => "Triage",
PipelineEvent::Close => "Close",
PipelineEvent::Demote => "Demote",
} }
} }
@@ -103,6 +116,9 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
let now = Utc::now(); let now = Utc::now();
match (state, event) { match (state, event) {
// ── Triage: upcoming → backlog ──────────────────────────────────
(Upcoming, Triage) => Ok(Backlog),
// ── Forward path ──────────────────────────────────────────────── // ── Forward path ────────────────────────────────────────────────
(Backlog, DepsMet) => Ok(Coding), (Backlog, DepsMet) => Ok(Coding),
(Coding, GatesStarted) => Ok(Qa), (Coding, GatesStarted) => Ok(Qa),
@@ -161,7 +177,8 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
}), }),
// ── Abandon / supersede from any active or done stage ─────────── // ── Abandon / supersede from any active or done stage ───────────
(Backlog, Abandon) (Upcoming, Abandon)
| (Backlog, Abandon)
| (Coding, Abandon) | (Coding, Abandon)
| (Qa, Abandon) | (Qa, Abandon)
| (Merge { .. }, Abandon) | (Merge { .. }, Abandon)
@@ -170,7 +187,8 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
reason: ArchiveReason::Abandoned, reason: ArchiveReason::Abandoned,
}), }),
(Backlog, Supersede { by }) (Upcoming, Supersede { by })
| (Backlog, Supersede { by })
| (Coding, Supersede { by }) | (Coding, Supersede { by })
| (Qa, Supersede { by }) | (Qa, Supersede { by })
| (Merge { .. }, Supersede { by }) | (Merge { .. }, Supersede { by })
@@ -179,13 +197,38 @@ pub fn transition(state: Stage, event: PipelineEvent) -> Result<Stage, Transitio
reason: ArchiveReason::Superseded { by }, reason: ArchiveReason::Superseded { by },
}), }),
// ── Unblock: only from Archived(Blocked) → Backlog ───────────── // ── Reject from any active stage or QA ──────────────────────────
(Backlog, Reject { reason })
| (Coding, Reject { reason })
| (Qa, Reject { reason })
| (Merge { .. }, Reject { reason }) => Ok(Archived {
archived_at: now,
reason: ArchiveReason::Rejected { reason },
}),
// ── Demote: send an active item back to backlog ────────────────
(Coding, Demote) | (Qa, Demote) | (Merge { .. }, Demote) => Ok(Backlog),
// ── Close: direct completion from any active stage ─────────────
(Backlog, Close) | (Coding, Close) | (Qa, Close) | (Merge { .. }, Close) => Ok(Done {
merged_at: now,
merge_commit: GitSha("closed".to_string()),
}),
// ── Unblock: from Archived(Blocked) or Archived(MergeFailed) → Backlog
( (
Archived { Archived {
reason: ArchiveReason::Blocked { .. }, reason: ArchiveReason::Blocked { .. },
.. ..
}, },
Unblock, Unblock,
)
| (
Archived {
reason: ArchiveReason::MergeFailed { .. },
..
},
Unblock,
) => Ok(Backlog), ) => Ok(Backlog),
// ── Everything else is invalid ────────────────────────────────── // ── Everything else is invalid ──────────────────────────────────
+32
View File
@@ -48,8 +48,30 @@ impl fmt::Display for AgentName {
/// - `agent` — local execution state, not pipeline state /// - `agent` — local execution state, not pipeline state
/// - `retry_count` — also local /// - `retry_count` — also local
/// - `blocked` — folded into `Archived { reason: Blocked { .. } }` /// - `blocked` — folded into `Archived { reason: Blocked { .. } }`
///
/// ## Canonical state machine (story 857)
///
/// The following named lifecycle states map to `Stage` variants:
///
/// | Lifecycle state | Stage variant |
/// |-----------------|-----------------------------------|
/// | upcoming | `Upcoming` |
/// | backlog | `Backlog` |
/// | current | `Coding` |
/// | qa_pending | `Qa` |
/// | merge_pending | `Merge { .. }` |
/// | done | `Done { .. }` |
/// | blocked | `Archived { Blocked { .. } }` |
/// | merge_failure | `Archived { MergeFailed { .. } }` |
/// | archived | `Archived { Completed }` |
/// | superseded | `Archived { Superseded { .. } }` |
/// | rejected | `Archived { Rejected { .. } }` |
/// | abandoned | `Archived { Abandoned }` |
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Stage { pub enum Stage {
/// Story has been created but not yet triaged into the backlog.
Upcoming,
/// Story exists, waiting for dependencies or auto-assign promotion. /// Story exists, waiting for dependencies or auto-assign promotion.
Backlog, Backlog,
@@ -96,6 +118,8 @@ pub enum ArchiveReason {
MergeFailed { reason: String }, MergeFailed { reason: String },
/// Held in review at human request. /// Held in review at human request.
ReviewHeld { reason: String }, ReviewHeld { reason: String },
/// Story rejected by QA or reviewer with an explanation.
Rejected { reason: String },
} }
// ── Stage convenience methods ────────────────────────────────────────────── // ── Stage convenience methods ──────────────────────────────────────────────
@@ -106,6 +130,11 @@ impl Stage {
matches!(self, Stage::Coding | Stage::Qa | Stage::Merge { .. }) matches!(self, Stage::Coding | Stage::Qa | Stage::Merge { .. })
} }
/// Returns true if this is the Upcoming variant.
pub fn is_upcoming(&self) -> bool {
matches!(self, Stage::Upcoming)
}
/// Returns the filesystem directory name for this stage. /// Returns the filesystem directory name for this stage.
pub fn dir_name(&self) -> &'static str { pub fn dir_name(&self) -> &'static str {
stage_dir_name(self) stage_dir_name(self)
@@ -132,6 +161,7 @@ impl Stage {
/// accessing the rich metadata fields. /// accessing the rich metadata fields.
pub fn from_dir(s: &str) -> Option<Self> { pub fn from_dir(s: &str) -> Option<Self> {
match s { match s {
"0_upcoming" => Some(Stage::Upcoming),
"1_backlog" => Some(Stage::Backlog), "1_backlog" => Some(Stage::Backlog),
"2_current" => Some(Stage::Coding), "2_current" => Some(Stage::Coding),
"3_qa" => Some(Stage::Qa), "3_qa" => Some(Stage::Qa),
@@ -219,6 +249,7 @@ impl std::error::Error for TransitionError {}
/// Human-readable label for a `Stage` variant. /// Human-readable label for a `Stage` variant.
pub fn stage_label(s: &Stage) -> &'static str { pub fn stage_label(s: &Stage) -> &'static str {
match s { match s {
Stage::Upcoming => "Upcoming",
Stage::Backlog => "Backlog", Stage::Backlog => "Backlog",
Stage::Coding => "Coding", Stage::Coding => "Coding",
Stage::Qa => "Qa", Stage::Qa => "Qa",
@@ -231,6 +262,7 @@ pub fn stage_label(s: &Stage) -> &'static str {
/// Map a Stage to the filesystem directory name used by the work pipeline. /// Map a Stage to the filesystem directory name used by the work pipeline.
pub fn stage_dir_name(s: &Stage) -> &'static str { pub fn stage_dir_name(s: &Stage) -> &'static str {
match s { match s {
Stage::Upcoming => "0_upcoming",
Stage::Backlog => "1_backlog", Stage::Backlog => "1_backlog",
Stage::Coding => "2_current", Stage::Coding => "2_current",
Stage::Qa => "3_qa", Stage::Qa => "3_qa",
+1
View File
@@ -202,6 +202,7 @@ pub fn get_work_item_content(
let stage = item let stage = item
.as_ref() .as_ref()
.map(|i| match &i.stage { .map(|i| match &i.stage {
crate::pipeline_state::Stage::Upcoming => "upcoming",
crate::pipeline_state::Stage::Backlog => "backlog", crate::pipeline_state::Stage::Backlog => "backlog",
crate::pipeline_state::Stage::Coding => "current", crate::pipeline_state::Stage::Coding => "current",
crate::pipeline_state::Stage::Qa => "qa", crate::pipeline_state::Stage::Qa => "qa",
@@ -10,6 +10,7 @@ use crate::service::common::item_id::extract_item_number;
pub fn stage_display_name(stage: &str) -> &'static str { pub fn stage_display_name(stage: &str) -> &'static str {
use crate::pipeline_state::Stage; use crate::pipeline_state::Stage;
match Stage::from_dir(stage) { match Stage::from_dir(stage) {
Some(Stage::Upcoming) => "Upcoming",
Some(Stage::Backlog) => "Backlog", Some(Stage::Backlog) => "Backlog",
Some(Stage::Coding) => "Current", Some(Stage::Coding) => "Current",
Some(Stage::Qa) => "QA", Some(Stage::Qa) => "QA",