huskies: merge 891

This commit is contained in:
dave
2026-05-12 17:03:41 +00:00
parent b76633b79b
commit 148ce37beb
20 changed files with 418 additions and 262 deletions
+92 -127
View File
@@ -36,22 +36,22 @@ impl fmt::Display for ProjectionError {
impl std::error::Error for ProjectionError {}
// ── Projection: PipelineItemView → PipelineItem ─────────────────────────────
// ── Projection: WorkItem → PipelineItem ─────────────────────────────────────
impl TryFrom<&PipelineItemView> for PipelineItem {
type Error = ProjectionError;
fn try_from(view: &PipelineItemView) -> Result<Self, ProjectionError> {
let story_id = StoryId(view.story_id.clone());
let name = view.name.clone().unwrap_or_default();
let story_id = StoryId(view.story_id().to_string());
let name = view.name().unwrap_or("").to_string();
let depends_on: Vec<StoryId> = view
.depends_on
.as_ref()
.map(|deps| deps.iter().map(|d| StoryId(d.to_string())).collect())
.unwrap_or_default();
.depends_on()
.iter()
.map(|d| StoryId(d.to_string()))
.collect();
let retry_count = view.retry_count.unwrap_or(0).max(0) as u32;
let retry_count = view.retry_count();
let stage = project_stage(view)?;
@@ -65,11 +65,11 @@ impl TryFrom<&PipelineItemView> for PipelineItem {
}
}
/// Project the stage string + associated fields from a PipelineItemView into
/// Project the stage string + associated fields from a WorkItem into
/// a typed Stage enum. This is the one carefully-controlled boundary where
/// loose CRDT data becomes typed.
pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError> {
match view.stage.as_str() {
match view.stage_str() {
"0_upcoming" => Ok(Stage::Upcoming),
"1_backlog" => Ok(Stage::Backlog),
"2_blocked" => Ok(Stage::Blocked {
@@ -82,7 +82,7 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
// commits_ahead — those are computed at transition time. For
// projection from existing CRDT data, we synthesize defaults.
// The feature branch follows the naming convention.
let branch = format!("feature/story-{}", view.story_id);
let branch = format!("feature/story-{}", view.story_id());
// Existing CRDT data doesn't track commits_ahead, so we use 1 as
// a safe non-zero default (the item is in merge, so there must be
// at least one commit).
@@ -105,7 +105,7 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
// to UNIX_EPOCH, which makes them older than any retention window
// and therefore eligible for immediate sweep to 6_archived.
let merged_at = view
.merged_at
.merged_at()
.map(|ts| {
DateTime::from_timestamp(ts as i64, 0).unwrap_or(DateTime::<Utc>::UNIX_EPOCH)
})
@@ -117,7 +117,7 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
}
"6_archived" => {
// Determine the archive reason from the CRDT fields.
let reason = if view.blocked == Some(true) {
let reason = if view.blocked() {
ArchiveReason::Blocked {
reason: "migrated from legacy blocked field".to_string(),
}
@@ -133,7 +133,7 @@ pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError>
"7_frozen" => {
// The stage to resume to is stored in front matter as `resume_to_stage`.
// Fall back to Coding if the field is absent (e.g. legacy frozen items).
let resume_to = crate::db::read_content(&view.story_id)
let resume_to = crate::db::read_content(view.story_id())
.and_then(|content| {
crate::db::yaml_legacy::parse_front_matter(&content)
.ok()
@@ -186,7 +186,7 @@ pub fn read_all_typed() -> Vec<PipelineItem> {
Err(e) => {
crate::slog!(
"[pipeline_state] projection error for '{}': {e}",
v.story_id
v.story_id()
);
None
}
@@ -221,42 +221,46 @@ mod tests {
StoryId(s.to_string())
}
fn make_view(story_id: &str, stage: &str, name: Option<&str>) -> PipelineItemView {
PipelineItemView::for_test(
story_id,
stage,
name.map(str::to_string),
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
}
#[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 view = make_view("42_story_test", "0_upcoming", Some("Test Story"));
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Upcoming));
}
#[test]
fn project_backlog_item() {
let view = PipelineItemView {
story_id: "42_story_test".to_string(),
stage: "1_backlog".to_string(),
name: Some("Test Story".to_string()),
agent: None,
retry_count: None,
blocked: None,
depends_on: Some(vec![10, 20]),
claimed_by: None,
claimed_at: None,
merged_at: None,
qa_mode: None,
mergemaster_attempted: None,
};
let view = PipelineItemView::for_test(
"42_story_test",
"1_backlog",
Some("Test Story".to_string()),
None,
None,
None,
Some(vec![10, 20]),
None,
None,
None,
None,
None,
);
let item = PipelineItem::try_from(&view).unwrap();
assert_eq!(item.story_id, StoryId("42_story_test".to_string()));
assert_eq!(item.name, "Test Story");
@@ -267,20 +271,20 @@ mod tests {
#[test]
fn project_current_item() {
let view = PipelineItemView {
story_id: "42_story_test".to_string(),
stage: "2_current".to_string(),
name: Some("Test".to_string()),
agent: Some("coder-1".to_string()),
retry_count: Some(2),
blocked: None,
depends_on: None,
claimed_by: None,
claimed_at: None,
merged_at: None,
qa_mode: None,
mergemaster_attempted: None,
};
let view = PipelineItemView::for_test(
"42_story_test",
"2_current",
Some("Test".to_string()),
Some("coder-1".to_string()),
Some(2),
None,
None,
None,
None,
None,
None,
None,
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Coding));
assert_eq!(item.retry_count, 2);
@@ -288,20 +292,7 @@ mod tests {
#[test]
fn project_merge_item() {
let view = PipelineItemView {
story_id: "42_story_test".to_string(),
stage: "4_merge".to_string(),
name: Some("Test".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 view = make_view("42_story_test", "4_merge", Some("Test"));
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Merge { .. }));
if let Stage::Merge {
@@ -316,40 +307,27 @@ mod tests {
#[test]
fn project_blocked_item() {
let view = PipelineItemView {
story_id: "42_story_test".to_string(),
stage: "2_blocked".to_string(),
name: Some("Test".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 view = make_view("42_story_test", "2_blocked", Some("Test"));
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Blocked { .. }));
}
#[test]
fn project_archived_blocked_item() {
let view = PipelineItemView {
story_id: "42_story_test".to_string(),
stage: "6_archived".to_string(),
name: Some("Test".to_string()),
agent: None,
retry_count: None,
blocked: Some(true),
depends_on: None,
claimed_by: None,
claimed_at: None,
merged_at: None,
qa_mode: None,
mergemaster_attempted: None,
};
let view = PipelineItemView::for_test(
"42_story_test",
"6_archived",
Some("Test".to_string()),
None,
None,
Some(true),
None,
None,
None,
None,
None,
None,
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(
item.stage,
@@ -362,20 +340,20 @@ mod tests {
#[test]
fn project_archived_completed_item() {
let view = PipelineItemView {
story_id: "42_story_test".to_string(),
stage: "6_archived".to_string(),
name: Some("Test".to_string()),
agent: None,
retry_count: None,
blocked: Some(false),
depends_on: None,
claimed_by: None,
claimed_at: None,
merged_at: None,
qa_mode: None,
mergemaster_attempted: None,
};
let view = PipelineItemView::for_test(
"42_story_test",
"6_archived",
Some("Test".to_string()),
None,
None,
Some(false),
None,
None,
None,
None,
None,
None,
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(
item.stage,
@@ -388,20 +366,7 @@ mod tests {
#[test]
fn project_unknown_stage_returns_error() {
let view = PipelineItemView {
story_id: "42_story_test".to_string(),
stage: "9_invalid".to_string(),
name: Some("Test".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 view = make_view("42_story_test", "9_invalid", Some("Test"));
let result = PipelineItem::try_from(&view);
assert!(matches!(
result,