huskies: merge 984

This commit is contained in:
dave
2026-05-13 16:43:19 +00:00
parent c3c9db3d8b
commit 580480094e
25 changed files with 501 additions and 97 deletions
+139 -3
View File
@@ -11,7 +11,7 @@ use std::path::Path;
use std::process::Command;
use crate::pipeline_state::{
ApplyError, ArchiveReason, BranchName, GitSha, MergeFailureKind, PipelineEvent, Stage,
ApplyError, ArchiveReason, BranchName, GitSha, MergeFailureKind, PipelineEvent, Stage, StoryId,
TransitionFired, apply_transition, stage_label,
};
use crate::slog;
@@ -106,7 +106,14 @@ pub fn move_story_to_done(story_id: &str) -> Result<(), String> {
let item = read_typed_or_err(story_id)?;
// Idempotent: already at or past done.
if matches!(item.stage, Stage::Done { .. } | Stage::Archived { .. }) {
if matches!(
item.stage,
Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. }
) {
return Ok(());
}
@@ -142,6 +149,9 @@ pub fn move_story_to_merge(story_id: &str) -> Result<(), String> {
| Stage::MergeFailure { .. }
| Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. }
) {
return Ok(());
}
@@ -184,6 +194,9 @@ pub fn move_story_to_qa(story_id: &str) -> Result<(), String> {
| Stage::MergeFailure { .. }
| Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. }
) {
return Ok(());
}
@@ -313,6 +326,51 @@ pub fn transition_to_unblocked(story_id: &str) -> Result<(), String> {
Ok(())
}
/// Abandon a work item, transitioning it to `Stage::Abandoned`.
///
/// Valid from any active or done stage. Returns `Err` when the item is not
/// found or the transition is invalid for the current stage.
#[allow(dead_code)]
pub fn abandon_story(story_id: &str) -> Result<(), String> {
apply_transition(story_id, PipelineEvent::Abandon, None)
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Mark a work item as superseded by another, transitioning to `Stage::Superseded`.
///
/// `superseded_by` is the story ID of the replacement work item. Valid from
/// any active or done stage. Returns `Err` on unknown item or invalid transition.
#[allow(dead_code)]
pub fn supersede_story(story_id: &str, superseded_by: &str) -> Result<(), String> {
apply_transition(
story_id,
PipelineEvent::Supersede {
by: StoryId(superseded_by.to_string()),
},
None,
)
.map(|_| ())
.map_err(|e| e.to_string())
}
/// Permanently reject a work item, transitioning it to `Stage::Rejected`.
///
/// `reason` must be non-empty. Valid from any active stage (backlog, coding,
/// qa, or merge). Returns `Err` on unknown item or invalid transition.
#[allow(dead_code)]
pub fn reject_story(story_id: &str, reason: &str) -> Result<(), String> {
apply_transition(
story_id,
PipelineEvent::Reject {
reason: reason.to_string(),
},
None,
)
.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,
@@ -441,7 +499,14 @@ pub fn move_story_to_stage(story_id: &str, target_stage: &str) -> Result<(String
pub fn close_bug_to_archive(bug_id: &str) -> Result<(), String> {
let item = read_typed_or_err(bug_id)?;
if matches!(item.stage, Stage::Done { .. } | Stage::Archived { .. }) {
if matches!(
item.stage,
Stage::Done { .. }
| Stage::Archived { .. }
| Stage::Abandoned { .. }
| Stage::Superseded { .. }
| Stage::Rejected { .. }
) {
return Ok(());
}
@@ -472,6 +537,9 @@ fn stage_to_name(s: &Stage) -> &'static str {
Stage::ReviewHold { .. } => "review_hold",
Stage::Done { .. } => "done",
Stage::Archived { .. } => "archived",
Stage::Abandoned { .. } => "abandoned",
Stage::Superseded { .. } => "superseded",
Stage::Rejected { .. } => "rejected",
}
}
@@ -877,6 +945,74 @@ mod tests {
);
}
// ── Story 984: abandon_story / supersede_story / reject_story ───────────────
#[test]
fn abandon_story_transitions_to_abandoned() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "99984_story_abandon";
crate::db::write_item_with_content(
story_id,
"2_current",
"---\nname: Abandon Test\n---\n",
crate::db::ItemMeta::named("Abandon Test"),
);
abandon_story(story_id).expect("abandon_story must succeed");
let item = crate::pipeline_state::read_typed(story_id)
.expect("read must succeed")
.expect("item must exist");
assert!(
matches!(item.stage, Stage::Abandoned { .. }),
"stage must be Abandoned after abandon_story: {:?}",
item.stage
);
}
#[test]
fn supersede_story_transitions_to_superseded() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "99985_story_supersede";
crate::db::write_item_with_content(
story_id,
"1_backlog",
"---\nname: Supersede Test\n---\n",
crate::db::ItemMeta::named("Supersede Test"),
);
supersede_story(story_id, "999_story_replacement").expect("supersede_story must succeed");
let item = crate::pipeline_state::read_typed(story_id)
.expect("read must succeed")
.expect("item must exist");
assert!(
matches!(item.stage, Stage::Superseded { ref superseded_by, .. } if superseded_by.0 == "999_story_replacement"),
"stage must be Superseded with correct ID: {:?}",
item.stage
);
}
#[test]
fn reject_story_transitions_to_rejected() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
let story_id = "99986_story_reject";
crate::db::write_item_with_content(
story_id,
"3_qa",
"---\nname: Reject Test\n---\n",
crate::db::ItemMeta::named("Reject Test"),
);
reject_story(story_id, "not aligned with roadmap").expect("reject_story must succeed");
let item = crate::pipeline_state::read_typed(story_id)
.expect("read must succeed")
.expect("item must exist");
assert!(
matches!(item.stage, Stage::Rejected { ref reason, .. } if reason == "not aligned with roadmap"),
"stage must be Rejected with correct reason: {:?}",
item.stage
);
}
/// Bug 226: feature_branch_has_unmerged_changes returns false when no
/// feature branch exists.
#[test]
@@ -506,6 +506,9 @@ impl AgentPool {
typed_item.stage,
crate::pipeline_state::Stage::Done { .. }
| crate::pipeline_state::Stage::Archived { .. }
| crate::pipeline_state::Stage::Abandoned { .. }
| crate::pipeline_state::Stage::Superseded { .. }
| crate::pipeline_state::Stage::Rejected { .. }
)
{
let current_dir = typed_item.stage.dir_name();