huskies: merge 951

This commit is contained in:
dave
2026-05-13 04:28:30 +00:00
parent c5abc44a63
commit 2f50e2198b
12 changed files with 178 additions and 218 deletions
+1 -1
View File
@@ -53,7 +53,7 @@ pub use transition::{
pub use events::{EventBus, TransitionFired, TransitionSubscriber};
#[allow(unused_imports)]
pub use projection::{ProjectionError, project_stage};
pub use projection::ProjectionError;
pub use projection::{read_all_typed, read_typed};
#[allow(unused_imports)]
+39 -99
View File
@@ -1,12 +1,16 @@
//! Projection layer — converts loose CRDT views into typed `PipelineItem` enums.
//!
//! Story 944: the view layer (`PipelineItemView`) now carries a typed
//! [`Stage`] directly, so this projection is mechanical — no more stage-string
//! parsing or payload synthesis happens here. [`TryFrom`] is kept for
//! backwards-compatible callers (apply.rs threads `ProjectionError` through
//! `ApplyError`), but the impl is infallible in practice.
use chrono::{DateTime, Utc};
use std::fmt;
use std::num::NonZeroU32;
use crate::crdt_state::PipelineItemView;
use super::{ArchiveReason, BranchName, GitSha, PipelineItem, Stage, StoryId, stage_dir_name};
use super::{ArchiveReason, PipelineItem, Stage, StoryId, stage_dir_name};
/// Errors from projecting loose CRDT data into typed enums.
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -50,12 +54,10 @@ impl TryFrom<&PipelineItemView> for PipelineItem {
let retry_count = view.retry_count();
let stage = project_stage(view)?;
Ok(PipelineItem {
story_id,
name,
stage,
stage: view.stage().clone(),
depends_on,
retry_count,
frozen: view.frozen(),
@@ -63,78 +65,6 @@ impl TryFrom<&PipelineItemView> for PipelineItem {
}
}
/// Project the typed low-level [`crdt_state::Stage`] plus the view's
/// associated fields into a rich [`Stage`] with payload defaults.
///
/// This is the one carefully-controlled boundary where the CRDT's
/// stringly-typed stage register gains payload fields (merge metadata,
/// archive reason, etc.) synthesised from sibling registers and sane
/// defaults. Unknown stage strings (forward-compat aliases) surface as
/// [`ProjectionError::UnknownStage`].
pub fn project_stage(view: &PipelineItemView) -> Result<Stage, ProjectionError> {
use crate::crdt_state::Stage as LowStage;
match view.stage() {
LowStage::Upcoming => Ok(Stage::Upcoming),
LowStage::Backlog => Ok(Stage::Backlog),
LowStage::Blocked => Ok(Stage::Blocked {
reason: String::new(),
}),
LowStage::Coding => Ok(Stage::Coding),
LowStage::Qa => Ok(Stage::Qa),
LowStage::Merge => {
// Merge stage in the current CRDT doesn't carry feature_branch or
// commits_ahead — those are computed at transition time. For
// projection from existing CRDT data, we synthesize defaults.
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).
Ok(Stage::Merge {
feature_branch: BranchName(branch),
commits_ahead: NonZeroU32::new(1).expect("1 is non-zero"),
})
}
LowStage::MergeFailure => {
// The reason is persisted in the content body but is not part of
// the raw CRDT view; the projection uses an empty string here.
// Consumers that need the reason should read content directly.
Ok(Stage::MergeFailure {
reason: String::new(),
})
}
LowStage::Done => {
// Use the stored merged_at timestamp if present. Legacy items
// that pre-date this field have merged_at = None, so we fall back
// to UNIX_EPOCH, which makes them older than any retention window
// and therefore eligible for immediate sweep to archived.
let merged_at = view
.merged_at()
.map(|ts| {
DateTime::from_timestamp(ts as i64, 0).unwrap_or(DateTime::<Utc>::UNIX_EPOCH)
})
.unwrap_or(DateTime::<Utc>::UNIX_EPOCH);
Ok(Stage::Done {
merged_at,
merge_commit: GitSha("legacy".to_string()),
})
}
LowStage::Archived => {
let reason = if view.blocked() {
ArchiveReason::Blocked {
reason: "migrated from legacy blocked field".to_string(),
}
} else {
ArchiveReason::Completed
};
Ok(Stage::Archived {
archived_at: Utc::now(),
reason,
})
}
LowStage::Unknown(s) => Err(ProjectionError::UnknownStage(s)),
}
}
// ── Reverse projection: PipelineItem → stage dir string ─────────────────────
impl PipelineItem {
@@ -190,6 +120,8 @@ pub fn read_typed(story_id: &str) -> Result<Option<PipelineItem>, ProjectionErro
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline_state::{BranchName, GitSha};
use chrono::Utc;
use std::num::NonZeroU32;
fn nz(n: u32) -> NonZeroU32 {
@@ -201,11 +133,8 @@ mod tests {
fn sha(s: &str) -> GitSha {
GitSha(s.to_string())
}
fn sid(s: &str) -> StoryId {
StoryId(s.to_string())
}
fn make_view(story_id: &str, stage: &str, name: Option<&str>) -> PipelineItemView {
fn make_view(story_id: &str, stage: Stage, name: Option<&str>) -> PipelineItemView {
PipelineItemView::for_test(
story_id,
stage,
@@ -228,7 +157,7 @@ mod tests {
#[test]
fn project_upcoming_item() {
let view = make_view("42_story_test", "upcoming", Some("Test Story"));
let view = make_view("42_story_test", Stage::Upcoming, Some("Test Story"));
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Upcoming));
}
@@ -237,7 +166,7 @@ mod tests {
fn project_backlog_item() {
let view = PipelineItemView::for_test(
"42_story_test",
"backlog",
Stage::Backlog,
Some("Test Story".to_string()),
None,
None,
@@ -265,7 +194,7 @@ mod tests {
fn project_current_item() {
let view = PipelineItemView::for_test(
"42_story_test",
"coding",
Stage::Coding,
Some("Test".to_string()),
Some("coder-1".to_string()),
Some(2),
@@ -288,7 +217,14 @@ mod tests {
#[test]
fn project_merge_item() {
let view = make_view("42_story_test", "merge", Some("Test"));
let view = make_view(
"42_story_test",
Stage::Merge {
feature_branch: fb("feature/story-42_story_test"),
commits_ahead: nz(1),
},
Some("Test"),
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Merge { .. }));
if let Stage::Merge {
@@ -303,7 +239,13 @@ mod tests {
#[test]
fn project_blocked_item() {
let view = make_view("42_story_test", "blocked", Some("Test"));
let view = make_view(
"42_story_test",
Stage::Blocked {
reason: String::new(),
},
Some("Test"),
);
let item = PipelineItem::try_from(&view).unwrap();
assert!(matches!(item.stage, Stage::Blocked { .. }));
}
@@ -312,7 +254,12 @@ mod tests {
fn project_archived_blocked_item() {
let view = PipelineItemView::for_test(
"42_story_test",
"archived",
Stage::Archived {
archived_at: Utc::now(),
reason: ArchiveReason::Blocked {
reason: "migrated from legacy blocked field".to_string(),
},
},
Some("Test".to_string()),
None,
None,
@@ -342,7 +289,10 @@ mod tests {
fn project_archived_completed_item() {
let view = PipelineItemView::for_test(
"42_story_test",
"archived",
Stage::Archived {
archived_at: Utc::now(),
reason: ArchiveReason::Completed,
},
Some("Test".to_string()),
None,
None,
@@ -368,16 +318,6 @@ mod tests {
));
}
#[test]
fn project_unknown_stage_returns_error() {
let view = make_view("42_story_test", "9_invalid", Some("Test"));
let result = PipelineItem::try_from(&view);
assert!(matches!(
result,
Err(ProjectionError::UnknownStage(s)) if s == "9_invalid"
));
}
// ── Reverse projection tests ────────────────────────────────────────
#[test]