huskies: merge 951
This commit is contained in:
@@ -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)]
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user