huskies: merge 857
This commit is contained in:
+226
-156
@@ -2,14 +2,21 @@
|
||||
//!
|
||||
//! All pipeline state lives in the CRDT. These functions never consult the
|
||||
//! 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::process::Command;
|
||||
|
||||
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;
|
||||
|
||||
type ContentTransform = Option<Box<dyn Fn(&str) -> String>>;
|
||||
|
||||
/// 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.
|
||||
@@ -40,78 +47,21 @@ pub(crate) fn item_type_from_id(item_id: &str) -> &'static str {
|
||||
"story"
|
||||
}
|
||||
|
||||
/// Move a work item to a new pipeline stage via the database.
|
||||
///
|
||||
/// Looks up the item in the CRDT to verify it exists in one of the expected
|
||||
/// `sources` stages, then updates the stage. Optionally clears front-matter
|
||||
/// fields from the stored content. Returns the source stage on success.
|
||||
fn move_item<'a>(
|
||||
story_id: &str,
|
||||
sources: &'a [&'a str],
|
||||
target_dir: &str,
|
||||
extra_done_dirs: &[&str],
|
||||
missing_ok: bool,
|
||||
fields_to_clear: &[&str],
|
||||
) -> 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(¤t_dir) {
|
||||
return Ok(None); // Idempotent: already there.
|
||||
type ContentTransform = Box<dyn Fn(&str) -> String>;
|
||||
|
||||
/// Build a content-transform closure that clears the given front-matter fields.
|
||||
fn fields_to_clear_transform(fields: &[&str]) -> Option<ContentTransform> {
|
||||
if fields.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let fields: Vec<String> = fields.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);
|
||||
}
|
||||
|
||||
// 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}."))
|
||||
result
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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> {
|
||||
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
|
||||
@@ -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.
|
||||
/// 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> {
|
||||
move_item(
|
||||
story_id,
|
||||
&["2_current", "3_qa", "4_merge"],
|
||||
"5_done",
|
||||
&["6_archived"],
|
||||
false,
|
||||
&["merge_failure", "blocked"],
|
||||
)
|
||||
.map(|_| ())
|
||||
let item = read_typed_or_err(story_id)?;
|
||||
let dir = item.stage.dir_name();
|
||||
|
||||
// Idempotent: already at or past done.
|
||||
if dir >= "5_done" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
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/`.
|
||||
///
|
||||
/// 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> {
|
||||
move_item(
|
||||
story_id,
|
||||
&["2_current", "3_qa"],
|
||||
"4_merge",
|
||||
&["5_done", "6_archived"],
|
||||
false,
|
||||
&["blocked"],
|
||||
)
|
||||
.map(|_| ())
|
||||
let item = read_typed_or_err(story_id)?;
|
||||
let dir = item.stage.dir_name();
|
||||
|
||||
// Idempotent: already at or past merge.
|
||||
if dir >= "4_merge" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
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/`.
|
||||
///
|
||||
/// Idempotent if already in `3_qa/`. Errors if not found in `2_current/`.
|
||||
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,
|
||||
&["2_current"],
|
||||
"3_qa",
|
||||
&["5_done", "6_archived"],
|
||||
false,
|
||||
&["blocked"],
|
||||
PipelineEvent::GatesStarted,
|
||||
transform.as_ref().map(|f| f.as_ref()),
|
||||
)
|
||||
.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.
|
||||
pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> {
|
||||
let moved = move_item(
|
||||
story_id,
|
||||
&["3_qa"],
|
||||
"2_current",
|
||||
&[],
|
||||
false,
|
||||
&["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);
|
||||
let notes_owned = notes.to_string();
|
||||
let transform: Box<dyn Fn(&str) -> String> = Box::new(move |content: &str| {
|
||||
let mut result = clear_front_matter_field_in_content(content, "review_hold");
|
||||
if !notes_owned.is_empty() {
|
||||
result =
|
||||
crate::io::story_metadata::write_rejection_notes_to_content(&result, ¬es_owned);
|
||||
}
|
||||
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.
|
||||
@@ -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.
|
||||
/// Returns `(from_stage, to_stage)` on success.
|
||||
pub fn move_story_to_stage(story_id: &str, target_stage: &str) -> Result<(String, String), String> {
|
||||
const STAGES: &[(&str, &str)] = &[
|
||||
("backlog", "1_backlog"),
|
||||
("current", "2_current"),
|
||||
("qa", "3_qa"),
|
||||
("merge", "4_merge"),
|
||||
("done", "5_done"),
|
||||
("archived", "6_archived"),
|
||||
];
|
||||
|
||||
let target_dir = STAGES
|
||||
.iter()
|
||||
.filter(|(name, _)| *name != "archived")
|
||||
.find(|(name, _)| *name == target_stage)
|
||||
.map(|(_, dir)| *dir)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
// Validate target.
|
||||
let target_dir = match target_stage {
|
||||
"backlog" => "1_backlog",
|
||||
"current" => "2_current",
|
||||
"qa" => "3_qa",
|
||||
"merge" => "4_merge",
|
||||
"done" => "5_done",
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"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/`.
|
||||
///
|
||||
/// 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> {
|
||||
move_item(
|
||||
bug_id,
|
||||
&["2_current", "1_backlog"],
|
||||
"5_done",
|
||||
&[],
|
||||
false,
|
||||
&[],
|
||||
)
|
||||
.map(|_| ())
|
||||
let item = read_typed_or_err(bug_id)?;
|
||||
|
||||
if item.stage.dir_name() >= "5_done" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
apply_transition(bug_id, PipelineEvent::Close, None)
|
||||
.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)]
|
||||
|
||||
Reference in New Issue
Block a user