diff --git a/src/cli/run.rs b/src/cli/run.rs index 74a76f6..f757733 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -5,6 +5,7 @@ use crate::automation::{ }; use crate::config::{GlobalConfig, TuttiConfig}; use crate::error::{Result, TuttiError}; +use crate::state::{load_sdlc_run_ledger, sdlc_pr_comment_summary}; use crate::{budget, budget::BudgetGuardOutcome}; use comfy_table::{Table, presets::UTF8_BORDERS_ONLY}; use serde::Serialize; @@ -89,6 +90,21 @@ pub fn run( if !plan.is_empty() { print_resume_plan(&ctx.run_id, &plan); } + match load_sdlc_run_ledger(project_root, &ctx.run_id) { + Ok(Some(ledger)) => match sdlc_pr_comment_summary(&ledger) { + Ok(summary) => eprintln!("{summary}"), + Err(err) => { + eprintln!("warn: failed to format SDLC run ledger summary: {err}"); + } + }, + Ok(None) => {} + Err(err) => { + eprintln!( + "warn: failed to load SDLC run ledger for '{}': {err}", + ctx.run_id + ); + } + } } if dry_run { diff --git a/src/state/mod.rs b/src/state/mod.rs index 0cf5e1e..9e70f18 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -179,6 +179,7 @@ fn with_run_ledger_lock(project_root: &Path, op: impl FnOnce() -> Result) op() } +/// A single state transition recorded for an SDLC run. #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SdlcTransitionRecord { @@ -190,6 +191,7 @@ pub struct SdlcTransitionRecord { pub reason: Option, } +/// Persisted state for an SDLC-tracked run, including transition history. #[allow(dead_code)] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SdlcRunLedgerRecord { @@ -204,6 +206,43 @@ pub struct SdlcRunLedgerRecord { pub transitions: Vec, } +/// Render a reusable PR comment summary for the provided SDLC run ledger. +/// +/// The output includes the current run state and a chronological transition list +/// suitable for posting in PR status updates. +pub fn sdlc_pr_comment_summary(ledger: &SdlcRunLedgerRecord) -> Result { + let mut out = String::new(); + out.push_str(&format!( + "SDLC run `{}` for #{} is currently `{:?}` (updated {} by {}).\n", + ledger.run_id, + ledger.issue_number, + ledger.state, + ledger.updated_at.to_rfc3339(), + ledger.actor + )); + if ledger.transitions.is_empty() { + out.push_str("No transitions recorded yet."); + return Ok(out); + } + + out.push_str("\nTransitions:\n"); + for transition in &ledger.transitions { + out.push_str(&format!( + "- {:?} → {:?} @ {} by {}{}\n", + transition.from, + transition.to, + transition.timestamp.to_rfc3339(), + transition.actor, + transition + .reason + .as_ref() + .map(|r| format!(" ({r})")) + .unwrap_or_default() + )); + } + Ok(out.trim_end().to_string()) +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ActivityState { @@ -1191,4 +1230,30 @@ mod tests { let bad = validate_step_id("step*1").unwrap_err(); assert!(bad.to_string().contains("only [A-Za-z0-9_-] allowed")); } + + #[test] + fn sdlc_pr_comment_summary_renders_transitions() { + let now = Utc::now(); + let ledger = SdlcRunLedgerRecord { + run_id: "run-ledger-summary".to_string(), + issue_number: 30, + repository: "nutthouse/tutti".to_string(), + workflow_name: "readiness".to_string(), + state: SdlcRunState::Tested, + updated_at: now, + actor: "wren".to_string(), + transitions: vec![SdlcTransitionRecord { + from: SdlcRunState::Implemented, + to: SdlcRunState::Tested, + timestamp: now, + actor: "wren".to_string(), + reason: Some("tests passed".to_string()), + }], + }; + + let summary = sdlc_pr_comment_summary(&ledger).unwrap(); + assert!(summary.contains("run-ledger-summary")); + assert!(summary.contains("Transitions:")); + assert!(summary.contains("tests passed")); + } }