Skip to main content

mockforge_collab/
promotion.rs

1//! Promotion workflow management
2//!
3//! Handles promotion of scenarios, personas, and configs between environments
4//! with history tracking and `GitOps` integration.
5
6use crate::error::{CollabError, Result};
7use chrono::{DateTime, Utc};
8use mockforge_core::pr_generation::{
9    PRFileChange, PRFileChangeType, PRGenerator, PRProvider, PRRequest,
10};
11use mockforge_core::workspace::mock_environment::MockEnvironmentName;
12use mockforge_core::workspace::scenario_promotion::{
13    PromotionEntityType, PromotionHistory, PromotionHistoryEntry, PromotionRequest, PromotionStatus,
14};
15use mockforge_core::PromotionService as PromotionServiceTrait;
16use serde_json;
17use sqlx::{Pool, Sqlite};
18use std::fmt::Write;
19use std::sync::Arc;
20use uuid::Uuid;
21
22/// `GitOps` configuration for promotions
23#[derive(Debug, Clone)]
24pub struct PromotionGitOpsConfig {
25    /// Whether `GitOps` is enabled
26    pub enabled: bool,
27    /// PR generator (if enabled)
28    pub pr_generator: Option<PRGenerator>,
29    /// Repository path for workspace config (relative to repo root)
30    pub config_path: Option<String>,
31}
32
33impl PromotionGitOpsConfig {
34    /// Create a new `GitOps` config
35    #[must_use]
36    pub fn new(
37        enabled: bool,
38        provider: PRProvider,
39        owner: String,
40        repo: String,
41        token: Option<String>,
42        base_branch: String,
43        config_path: Option<String>,
44    ) -> Self {
45        let pr_generator = if let (true, Some(token)) = (enabled, token) {
46            Some(match provider {
47                PRProvider::GitHub => PRGenerator::new_github(owner, repo, token, base_branch),
48                PRProvider::GitLab => PRGenerator::new_gitlab(owner, repo, token, base_branch),
49            })
50        } else {
51            None
52        };
53
54        Self {
55            enabled,
56            pr_generator,
57            config_path,
58        }
59    }
60
61    /// Create disabled `GitOps` config
62    #[must_use]
63    pub const fn disabled() -> Self {
64        Self {
65            enabled: false,
66            pr_generator: None,
67            config_path: None,
68        }
69    }
70}
71
72/// Promotion service for managing promotions between environments
73pub struct PromotionService {
74    db: Pool<Sqlite>,
75    gitops: Arc<PromotionGitOpsConfig>,
76}
77
78impl PromotionService {
79    /// Create a new promotion service
80    #[must_use]
81    pub fn new(db: Pool<Sqlite>) -> Self {
82        Self {
83            db,
84            gitops: Arc::new(PromotionGitOpsConfig::disabled()),
85        }
86    }
87
88    /// Create a new promotion service with `GitOps` support
89    #[must_use]
90    pub fn with_gitops(db: Pool<Sqlite>, gitops: PromotionGitOpsConfig) -> Self {
91        Self {
92            db,
93            gitops: Arc::new(gitops),
94        }
95    }
96
97    /// Run database migrations for promotion tables
98    ///
99    /// This ensures the `promotion_history` and `environment_permission_policies` tables exist.
100    /// Should be called during service initialization.
101    ///
102    /// # Errors
103    ///
104    /// Returns an error if migrations fail.
105    ///
106    /// # Panics
107    ///
108    /// This method does not currently panic.
109    #[allow(clippy::unused_async)]
110    pub async fn run_migrations(&self) -> Result<()> {
111        // Promotion-specific tables (promotion_history, environment_permission_policies)
112        // are created by the collab server's main SQLx migration system.
113        // No additional promotion-specific migrations needed at this time.
114        Ok(())
115    }
116
117    /// Record a promotion in the history and optionally create a `GitOps` PR
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if the database insert fails.
122    pub async fn record_promotion(
123        &self,
124        request: &PromotionRequest,
125        promoted_by: Uuid,
126        status: PromotionStatus,
127        workspace_config: Option<serde_json::Value>,
128    ) -> Result<Uuid> {
129        let promotion_id = Uuid::new_v4();
130        let now = Utc::now();
131
132        let metadata_json = if request.metadata.is_empty() {
133            None
134        } else {
135            Some(serde_json::to_string(&request.metadata)?)
136        };
137
138        // Record promotion in database
139        // Note: promotion_history table uses TEXT columns, so convert UUIDs to strings
140        let promotion_id_str = promotion_id.to_string();
141        let promoted_by_str = promoted_by.to_string();
142        let entity_type_str = request.entity_type.to_string();
143        let from_env_str = request.from_environment.as_str().to_string();
144        let to_env_str = request.to_environment.as_str().to_string();
145        let status_str = status.to_string();
146        let now_str = now.to_rfc3339();
147
148        sqlx::query!(
149            r#"
150            INSERT INTO promotion_history (
151                id, workspace_id, entity_type, entity_id, entity_version,
152                from_environment, to_environment, promoted_by, status,
153                comments, metadata, created_at, updated_at
154            )
155            VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
156            "#,
157            promotion_id_str,
158            request.workspace_id,
159            entity_type_str,
160            request.entity_id,
161            request.entity_version,
162            from_env_str,
163            to_env_str,
164            promoted_by_str,
165            status_str,
166            request.comments,
167            metadata_json,
168            now_str,
169            now_str,
170        )
171        .execute(&self.db)
172        .await
173        .map_err(|e| CollabError::DatabaseError(format!("Failed to record promotion: {e}")))?;
174
175        // Create GitOps PR if enabled and workspace config is provided
176        if let (true, Some(config)) = (self.gitops.enabled, workspace_config) {
177            if let Err(e) = self.create_promotion_pr(&promotion_id, request, config).await {
178                tracing::warn!("Failed to create GitOps PR for promotion {}: {}", promotion_id, e);
179                // Don't fail the promotion if PR creation fails
180            }
181        }
182
183        Ok(promotion_id)
184    }
185
186    /// Create a `GitOps` PR for a promotion
187    async fn create_promotion_pr(
188        &self,
189        promotion_id: &Uuid,
190        request: &PromotionRequest,
191        workspace_config: serde_json::Value,
192    ) -> Result<()> {
193        let pr_generator = self
194            .gitops
195            .pr_generator
196            .as_ref()
197            .ok_or_else(|| CollabError::Internal("PR generator not configured".to_string()))?;
198
199        // Generate PR title and body
200        let title = format!(
201            "Promote {} '{}' from {} to {}",
202            request.entity_type,
203            request.entity_id,
204            request.from_environment.as_str(),
205            request.to_environment.as_str(),
206        );
207
208        let mut body = format!(
209            "## Promotion: {} → {}\n\n",
210            request.from_environment.as_str(),
211            request.to_environment.as_str(),
212        );
213        let _ = writeln!(body, "**Entity Type:** {}", request.entity_type);
214        let _ = writeln!(body, "**Entity ID:** {}", request.entity_id);
215        if let Some(version) = &request.entity_version {
216            let _ = writeln!(body, "**Version:** {version}");
217        }
218        if let Some(comments) = &request.comments {
219            let _ = write!(body, "\n**Comments:**\n{comments}\n");
220        }
221        body.push_str("\n---\n\n");
222        body.push_str("*This PR was automatically generated by MockForge promotion workflow.*");
223
224        // Determine config file path
225        let default_path = format!("workspaces/{}/config.yaml", request.workspace_id);
226        let config_path = self.gitops.config_path.as_deref().unwrap_or(&default_path);
227
228        // Serialize workspace config to JSON (YAML can be converted later if needed)
229        let config_json = serde_json::to_string_pretty(&workspace_config)
230            .map_err(|e| CollabError::Internal(format!("Failed to serialize config: {e}")))?;
231
232        // Create file change (use .json extension or keep .yaml if path specifies it)
233        let file_path = if std::path::Path::new(config_path)
234            .extension()
235            .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml") || ext.eq_ignore_ascii_case("yml"))
236        {
237            config_path.to_string()
238        } else {
239            format!("{config_path}.json")
240        };
241
242        let file_change = PRFileChange {
243            path: file_path,
244            content: config_json,
245            change_type: PRFileChangeType::Update,
246        };
247
248        // Create PR request
249        let pr_request = PRRequest {
250            title,
251            body,
252            branch: format!(
253                "mockforge/promotion-{}-{}-{}",
254                request.entity_type,
255                request.entity_id,
256                &promotion_id.to_string()[..8]
257            ),
258            files: vec![file_change],
259            labels: vec![
260                "automated".to_string(),
261                "promotion".to_string(),
262                format!("env-{}", request.to_environment.as_str()),
263            ],
264            reviewers: vec![],
265        };
266
267        // Create PR
268        match pr_generator.create_pr(pr_request).await {
269            Ok(pr_result) => {
270                // Update promotion with PR URL
271                self.update_promotion_pr_url(*promotion_id, pr_result.url.clone()).await?;
272                tracing::info!(
273                    "Created GitOps PR {} for promotion {}",
274                    pr_result.url,
275                    promotion_id
276                );
277                Ok(())
278            }
279            Err(e) => {
280                tracing::error!("Failed to create PR for promotion {}: {}", promotion_id, e);
281                Err(CollabError::Internal(format!("Failed to create PR: {e}")))
282            }
283        }
284    }
285
286    /// Update promotion status (e.g., when approved or rejected)
287    ///
288    /// # Errors
289    ///
290    /// Returns an error if the database update fails.
291    pub async fn update_promotion_status(
292        &self,
293        promotion_id: Uuid,
294        status: PromotionStatus,
295        approved_by: Option<Uuid>,
296    ) -> Result<()> {
297        let now = Utc::now();
298        let status_str = status.to_string();
299        let approved_by_str = approved_by.map(|u| u.to_string());
300        let now_str = now.to_rfc3339();
301        let promotion_id_str = promotion_id.to_string();
302
303        sqlx::query!(
304            r#"
305            UPDATE promotion_history
306            SET status = ?, approved_by = ?, updated_at = ?
307            WHERE id = ?
308            "#,
309            status_str,
310            approved_by_str,
311            now_str,
312            promotion_id_str,
313        )
314        .execute(&self.db)
315        .await
316        .map_err(|e| {
317            CollabError::DatabaseError(format!("Failed to update promotion status: {e}"))
318        })?;
319
320        // Emit pipeline event when promotion is completed
321        if status == PromotionStatus::Completed {
322            #[cfg(feature = "pipelines")]
323            {
324                use mockforge_pipelines::events::{publish_event, PipelineEvent};
325                use sqlx::Row;
326
327                // Get workspace_id from database (stored as TEXT)
328                let workspace_id_row =
329                    sqlx::query("SELECT workspace_id FROM promotion_history WHERE id = ?")
330                        .bind(&promotion_id_str)
331                        .fetch_optional(&self.db)
332                        .await
333                        .ok()
334                        .flatten();
335
336                if let Some(row) = workspace_id_row {
337                    if let Ok(workspace_id_str) = row.try_get::<String, _>("workspace_id") {
338                        if let Ok(ws_id) = Uuid::parse_str(&workspace_id_str) {
339                            // Get promotion details for event
340                            if let Some(promotion) = self.get_promotion_by_id(promotion_id).await? {
341                                let event = PipelineEvent::promotion_completed(
342                                    ws_id,
343                                    promotion_id,
344                                    promotion.entity_type.to_string(),
345                                    promotion.from_environment.as_str().to_string(),
346                                    promotion.to_environment.as_str().to_string(),
347                                );
348
349                                if let Err(e) = publish_event(event) {
350                                    tracing::warn!(
351                                        "Failed to publish promotion completed event: {}",
352                                        e
353                                    );
354                                }
355                            }
356                        }
357                    }
358                }
359            }
360        }
361
362        Ok(())
363    }
364
365    /// Update promotion with `GitOps` PR URL
366    ///
367    /// # Errors
368    ///
369    /// Returns an error if the database update fails.
370    pub async fn update_promotion_pr_url(&self, promotion_id: Uuid, pr_url: String) -> Result<()> {
371        let now = Utc::now();
372        let now_str = now.to_rfc3339();
373        let promotion_id_str = promotion_id.to_string();
374
375        sqlx::query!(
376            r#"
377            UPDATE promotion_history
378            SET pr_url = ?, updated_at = ?
379            WHERE id = ?
380            "#,
381            pr_url,
382            now_str,
383            promotion_id_str,
384        )
385        .execute(&self.db)
386        .await
387        .map_err(|e| {
388            CollabError::DatabaseError(format!("Failed to update promotion PR URL: {e}"))
389        })?;
390
391        Ok(())
392    }
393
394    /// Get a promotion by ID
395    ///
396    /// # Errors
397    ///
398    /// Returns an error if the database query fails or data is invalid.
399    #[allow(clippy::items_after_statements)]
400    pub async fn get_promotion_by_id(
401        &self,
402        promotion_id: Uuid,
403    ) -> Result<Option<PromotionHistoryEntry>> {
404        let promotion_id_str = promotion_id.to_string();
405
406        use sqlx::Row;
407        let row = sqlx::query(
408            r"
409            SELECT
410                id, entity_type, entity_id, entity_version, workspace_id,
411                from_environment, to_environment, promoted_by, approved_by,
412                status, comments, pr_url, metadata, created_at, updated_at
413            FROM promotion_history
414            WHERE id = ?
415            ",
416        )
417        .bind(&promotion_id_str)
418        .fetch_optional(&self.db)
419        .await
420        .map_err(|e| CollabError::DatabaseError(format!("Failed to get promotion: {e}")))?;
421
422        if let Some(row) = row {
423            let id: String = row.get("id");
424            let entity_type_str: String = row.get("entity_type");
425            let entity_id: String = row.get("entity_id");
426            let entity_version: Option<String> = row.get("entity_version");
427            let _workspace_id: String = row.get("workspace_id");
428            let from_environment: String = row.get("from_environment");
429            let to_environment: String = row.get("to_environment");
430            let promoted_by: String = row.get("promoted_by");
431            let approved_by: Option<String> = row.get("approved_by");
432            let status_str: String = row.get("status");
433            let comments: Option<String> = row.get("comments");
434            let pr_url: Option<String> = row.get("pr_url");
435            let metadata: Option<String> = row.get("metadata");
436            let created_at: String = row.get("created_at");
437
438            let from_env = MockEnvironmentName::from_str(&from_environment).ok_or_else(|| {
439                CollabError::Internal(format!("Invalid from_environment: {from_environment}"))
440            })?;
441            let to_env = MockEnvironmentName::from_str(&to_environment).ok_or_else(|| {
442                CollabError::Internal(format!("Invalid to_environment: {to_environment}"))
443            })?;
444            let status = match status_str.as_str() {
445                "pending" => PromotionStatus::Pending,
446                "approved" => PromotionStatus::Approved,
447                "rejected" => PromotionStatus::Rejected,
448                "completed" => PromotionStatus::Completed,
449                "failed" => PromotionStatus::Failed,
450                _ => return Err(CollabError::Internal(format!("Invalid status: {status_str}"))),
451            };
452            let entity_type = match entity_type_str.as_str() {
453                "scenario" => PromotionEntityType::Scenario,
454                "persona" => PromotionEntityType::Persona,
455                "config" => PromotionEntityType::Config,
456                _ => {
457                    return Err(CollabError::Internal(format!(
458                        "Invalid entity_type: {entity_type_str}"
459                    )))
460                }
461            };
462
463            let metadata_map = metadata.map_or_else(std::collections::HashMap::new, |meta_str| {
464                serde_json::from_str(&meta_str).unwrap_or_default()
465            });
466
467            let timestamp = DateTime::parse_from_rfc3339(&created_at)
468                .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {e}")))?
469                .with_timezone(&Utc);
470
471            Ok(Some(PromotionHistoryEntry {
472                promotion_id: id,
473                entity_type,
474                entity_id,
475                entity_version,
476                from_environment: from_env,
477                to_environment: to_env,
478                promoted_by,
479                approved_by,
480                status,
481                timestamp,
482                comments,
483                pr_url,
484                metadata: metadata_map,
485            }))
486        } else {
487            Ok(None)
488        }
489    }
490
491    /// Get promotion history for an entity
492    ///
493    /// # Errors
494    ///
495    /// Returns an error if the database query fails or data is invalid.
496    pub async fn get_promotion_history(
497        &self,
498        workspace_id: &str,
499        entity_type: PromotionEntityType,
500        entity_id: &str,
501    ) -> Result<PromotionHistory> {
502        let entity_type_str = entity_type.to_string();
503        let rows = sqlx::query!(
504            r#"
505            SELECT
506                id, entity_type, entity_id, entity_version,
507                from_environment, to_environment, promoted_by, approved_by,
508                status, comments, pr_url, metadata, created_at, updated_at
509            FROM promotion_history
510            WHERE workspace_id = ? AND entity_type = ? AND entity_id = ?
511            ORDER BY created_at ASC
512            "#,
513            workspace_id,
514            entity_type_str,
515            entity_id,
516        )
517        .fetch_all(&self.db)
518        .await
519        .map_err(|e| CollabError::DatabaseError(format!("Failed to get promotion history: {e}")))?;
520
521        let promotions: Result<Vec<PromotionHistoryEntry>> = rows
522            .into_iter()
523            .map(|row| {
524                let from_env =
525                    MockEnvironmentName::from_str(&row.from_environment).ok_or_else(|| {
526                        CollabError::Internal(format!(
527                            "Invalid from_environment: {}",
528                            row.from_environment
529                        ))
530                    })?;
531                let to_env =
532                    MockEnvironmentName::from_str(&row.to_environment).ok_or_else(|| {
533                        CollabError::Internal(format!(
534                            "Invalid to_environment: {}",
535                            row.to_environment
536                        ))
537                    })?;
538                let status = match row.status.as_str() {
539                    "pending" => PromotionStatus::Pending,
540                    "approved" => PromotionStatus::Approved,
541                    "rejected" => PromotionStatus::Rejected,
542                    "completed" => PromotionStatus::Completed,
543                    "failed" => PromotionStatus::Failed,
544                    _ => {
545                        return Err(CollabError::Internal(format!(
546                            "Invalid status: {}",
547                            row.status
548                        )))
549                    }
550                };
551                let entity_type = match row.entity_type.as_str() {
552                    "scenario" => PromotionEntityType::Scenario,
553                    "persona" => PromotionEntityType::Persona,
554                    "config" => PromotionEntityType::Config,
555                    _ => {
556                        return Err(CollabError::Internal(format!(
557                            "Invalid entity_type: {}",
558                            row.entity_type
559                        )))
560                    }
561                };
562
563                let metadata =
564                    row.metadata.map_or_else(std::collections::HashMap::new, |meta_str| {
565                        serde_json::from_str(&meta_str).unwrap_or_default()
566                    });
567
568                let timestamp = DateTime::parse_from_rfc3339(&row.created_at)
569                    .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {e}")))?
570                    .with_timezone(&Utc);
571
572                Ok(PromotionHistoryEntry {
573                    promotion_id: row.id,
574                    entity_type,
575                    entity_id: row.entity_id,
576                    entity_version: row.entity_version,
577                    from_environment: from_env,
578                    to_environment: to_env,
579                    promoted_by: row.promoted_by,
580                    approved_by: row.approved_by,
581                    status,
582                    timestamp,
583                    comments: row.comments,
584                    pr_url: row.pr_url,
585                    metadata,
586                })
587            })
588            .collect();
589
590        Ok(PromotionHistory {
591            entity_type,
592            entity_id: entity_id.to_string(),
593            workspace_id: workspace_id.to_string(),
594            promotions: promotions?,
595        })
596    }
597
598    /// Get all promotions for a workspace
599    ///
600    /// # Errors
601    ///
602    /// Returns an error if the database query fails or data is invalid.
603    pub async fn get_workspace_promotions(
604        &self,
605        workspace_id: &str,
606        limit: Option<i64>,
607    ) -> Result<Vec<PromotionHistoryEntry>> {
608        let limit = limit.unwrap_or(100);
609
610        let rows = sqlx::query!(
611            r#"
612            SELECT
613                id, entity_type, entity_id, entity_version,
614                from_environment, to_environment, promoted_by, approved_by,
615                status, comments, pr_url, metadata, created_at, updated_at
616            FROM promotion_history
617            WHERE workspace_id = ?
618            ORDER BY created_at DESC
619            LIMIT ?
620            "#,
621            workspace_id,
622            limit,
623        )
624        .fetch_all(&self.db)
625        .await
626        .map_err(|e| {
627            CollabError::DatabaseError(format!("Failed to get workspace promotions: {e}"))
628        })?;
629
630        let promotions: Result<Vec<PromotionHistoryEntry>> = rows
631            .into_iter()
632            .map(|row| {
633                let from_env =
634                    MockEnvironmentName::from_str(&row.from_environment).ok_or_else(|| {
635                        CollabError::Internal(format!(
636                            "Invalid from_environment: {}",
637                            row.from_environment
638                        ))
639                    })?;
640                let to_env =
641                    MockEnvironmentName::from_str(&row.to_environment).ok_or_else(|| {
642                        CollabError::Internal(format!(
643                            "Invalid to_environment: {}",
644                            row.to_environment
645                        ))
646                    })?;
647                let status = match row.status.as_str() {
648                    "pending" => PromotionStatus::Pending,
649                    "approved" => PromotionStatus::Approved,
650                    "rejected" => PromotionStatus::Rejected,
651                    "completed" => PromotionStatus::Completed,
652                    "failed" => PromotionStatus::Failed,
653                    _ => {
654                        return Err(CollabError::Internal(format!(
655                            "Invalid status: {}",
656                            row.status
657                        )))
658                    }
659                };
660                let entity_type = match row.entity_type.as_str() {
661                    "scenario" => PromotionEntityType::Scenario,
662                    "persona" => PromotionEntityType::Persona,
663                    "config" => PromotionEntityType::Config,
664                    _ => {
665                        return Err(CollabError::Internal(format!(
666                            "Invalid entity_type: {}",
667                            row.entity_type
668                        )))
669                    }
670                };
671
672                let metadata =
673                    row.metadata.map_or_else(std::collections::HashMap::new, |meta_str| {
674                        serde_json::from_str(&meta_str).unwrap_or_default()
675                    });
676
677                let timestamp = DateTime::parse_from_rfc3339(&row.created_at)
678                    .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {e}")))?
679                    .with_timezone(&Utc);
680
681                Ok(PromotionHistoryEntry {
682                    promotion_id: row.id,
683                    entity_type,
684                    entity_id: row.entity_id,
685                    entity_version: row.entity_version,
686                    from_environment: from_env,
687                    to_environment: to_env,
688                    promoted_by: row.promoted_by,
689                    approved_by: row.approved_by,
690                    status,
691                    timestamp,
692                    comments: row.comments,
693                    pr_url: row.pr_url,
694                    metadata,
695                })
696            })
697            .collect();
698
699        promotions
700    }
701
702    /// Get pending promotions requiring approval
703    ///
704    /// # Errors
705    ///
706    /// Returns an error if the database query fails or data is invalid.
707    #[allow(clippy::items_after_statements)]
708    pub async fn get_pending_promotions(
709        &self,
710        workspace_id: Option<&str>,
711    ) -> Result<Vec<PromotionHistoryEntry>> {
712        // Use runtime query to handle conditional workspace_id
713        let rows = if let Some(ws_id) = workspace_id {
714            sqlx::query(
715                r"
716                SELECT
717                    id, entity_type, entity_id, entity_version,
718                    from_environment, to_environment, promoted_by, approved_by,
719                    status, comments, pr_url, metadata, created_at, updated_at
720                FROM promotion_history
721                WHERE workspace_id = ? AND status = 'pending'
722                ORDER BY created_at ASC
723                ",
724            )
725            .bind(ws_id)
726            .fetch_all(&self.db)
727            .await
728            .map_err(|e| {
729                CollabError::DatabaseError(format!("Failed to get pending promotions: {e}"))
730            })?
731        } else {
732            sqlx::query(
733                r"
734                SELECT
735                    id, entity_type, entity_id, entity_version,
736                    from_environment, to_environment, promoted_by, approved_by,
737                    status, comments, pr_url, metadata, created_at, updated_at
738                FROM promotion_history
739                WHERE status = 'pending'
740                ORDER BY created_at ASC
741                ",
742            )
743            .fetch_all(&self.db)
744            .await
745            .map_err(|e| {
746                CollabError::DatabaseError(format!("Failed to get pending promotions: {e}"))
747            })?
748        };
749
750        use sqlx::Row;
751        let promotions: Result<Vec<PromotionHistoryEntry>> = rows
752            .into_iter()
753            .map(|row: sqlx::sqlite::SqliteRow| {
754                let id: String = row.get("id");
755                let entity_type_str: String = row.get("entity_type");
756                let entity_id: String = row.get("entity_id");
757                let entity_version: Option<String> = row.get("entity_version");
758                let from_environment: String = row.get("from_environment");
759                let to_environment: String = row.get("to_environment");
760                let promoted_by: String = row.get("promoted_by");
761                let approved_by: Option<String> = row.get("approved_by");
762                let comments: Option<String> = row.get("comments");
763                let pr_url: Option<String> = row.get("pr_url");
764                let metadata: Option<String> = row.get("metadata");
765                let created_at: String = row.get("created_at");
766
767                let from_env =
768                    MockEnvironmentName::from_str(&from_environment).ok_or_else(|| {
769                        CollabError::Internal(format!(
770                            "Invalid from_environment: {from_environment}"
771                        ))
772                    })?;
773                let to_env = MockEnvironmentName::from_str(&to_environment).ok_or_else(|| {
774                    CollabError::Internal(format!("Invalid to_environment: {to_environment}"))
775                })?;
776                let status = PromotionStatus::Pending;
777                let entity_type = match entity_type_str.as_str() {
778                    "scenario" => PromotionEntityType::Scenario,
779                    "persona" => PromotionEntityType::Persona,
780                    "config" => PromotionEntityType::Config,
781                    _ => {
782                        return Err(CollabError::Internal(format!(
783                            "Invalid entity_type: {entity_type_str}"
784                        )))
785                    }
786                };
787
788                let metadata_map = metadata
789                    .map_or_else(std::collections::HashMap::new, |meta_str| {
790                        serde_json::from_str(&meta_str).unwrap_or_default()
791                    });
792
793                let timestamp = DateTime::parse_from_rfc3339(&created_at)
794                    .map_err(|e| CollabError::Internal(format!("Invalid timestamp: {e}")))?
795                    .with_timezone(&Utc);
796
797                Ok(PromotionHistoryEntry {
798                    promotion_id: id,
799                    entity_type,
800                    entity_id,
801                    entity_version,
802                    from_environment: from_env,
803                    to_environment: to_env,
804                    promoted_by,
805                    approved_by,
806                    status,
807                    timestamp,
808                    comments,
809                    pr_url,
810                    metadata: metadata_map,
811                })
812            })
813            .collect();
814
815        promotions
816    }
817}
818
819// Implement PromotionService trait for PromotionService
820#[async_trait::async_trait]
821impl PromotionServiceTrait for PromotionService {
822    async fn promote_entity(
823        &self,
824        workspace_id: Uuid,
825        entity_type: PromotionEntityType,
826        entity_id: String,
827        entity_version: Option<String>,
828        from_environment: MockEnvironmentName,
829        to_environment: MockEnvironmentName,
830        promoted_by: Uuid,
831        comments: Option<String>,
832    ) -> mockforge_core::Result<Uuid> {
833        let request = PromotionRequest {
834            entity_type,
835            entity_id: entity_id.clone(),
836            entity_version,
837            workspace_id: workspace_id.to_string(),
838            from_environment,
839            to_environment,
840            requires_approval: false, // Auto-promotions don't require approval
841            approval_required_reason: None,
842            comments,
843            metadata: std::collections::HashMap::new(),
844        };
845
846        // Auto-complete the promotion (no approval needed for auto-promotions)
847        self.record_promotion(&request, promoted_by, PromotionStatus::Completed, None)
848            .await
849            .map_err(|e| mockforge_core::Error::generic(format!("Promotion failed: {e}")))
850    }
851}
852
853#[cfg(test)]
854mod tests {
855    use super::*;
856    use mockforge_core::pr_generation::PRProvider;
857    use mockforge_core::workspace::mock_environment::MockEnvironmentName;
858    use mockforge_core::workspace::scenario_promotion::{
859        PromotionEntityType, PromotionRequest, PromotionStatus,
860    };
861    use sqlx::SqlitePool;
862
863    async fn setup_test_db() -> Pool<Sqlite> {
864        let pool = SqlitePool::connect(":memory:").await.unwrap();
865
866        // Create tables
867        sqlx::query(
868            r"
869            CREATE TABLE IF NOT EXISTS promotion_history (
870                id TEXT PRIMARY KEY,
871                workspace_id TEXT NOT NULL,
872                entity_type TEXT NOT NULL,
873                entity_id TEXT NOT NULL,
874                entity_version TEXT,
875                from_environment TEXT NOT NULL,
876                to_environment TEXT NOT NULL,
877                promoted_by TEXT NOT NULL,
878                approved_by TEXT,
879                status TEXT NOT NULL,
880                comments TEXT,
881                pr_url TEXT,
882                metadata TEXT,
883                created_at TEXT NOT NULL,
884                updated_at TEXT NOT NULL
885            )
886            ",
887        )
888        .execute(&pool)
889        .await
890        .unwrap();
891
892        pool
893    }
894
895    #[test]
896    fn test_promotion_gitops_config_new() {
897        let config = PromotionGitOpsConfig::new(
898            true,
899            PRProvider::GitHub,
900            "owner".to_string(),
901            "repo".to_string(),
902            Some("token".to_string()),
903            "main".to_string(),
904            Some("config.yaml".to_string()),
905        );
906
907        assert!(config.enabled);
908        assert!(config.pr_generator.is_some());
909        assert_eq!(config.config_path, Some("config.yaml".to_string()));
910    }
911
912    #[test]
913    fn test_promotion_gitops_config_new_without_token() {
914        let config = PromotionGitOpsConfig::new(
915            true,
916            PRProvider::GitHub,
917            "owner".to_string(),
918            "repo".to_string(),
919            None,
920            "main".to_string(),
921            None,
922        );
923
924        assert!(config.enabled);
925        assert!(config.pr_generator.is_none());
926        assert_eq!(config.config_path, None);
927    }
928
929    #[test]
930    fn test_promotion_gitops_config_disabled() {
931        let config = PromotionGitOpsConfig::disabled();
932
933        assert!(!config.enabled);
934        assert!(config.pr_generator.is_none());
935        assert_eq!(config.config_path, None);
936    }
937
938    #[test]
939    fn test_promotion_gitops_config_gitlab() {
940        let config = PromotionGitOpsConfig::new(
941            true,
942            PRProvider::GitLab,
943            "owner".to_string(),
944            "repo".to_string(),
945            Some("token".to_string()),
946            "main".to_string(),
947            None,
948        );
949
950        assert!(config.enabled);
951        assert!(config.pr_generator.is_some());
952    }
953
954    #[tokio::test]
955    async fn test_promotion_service_new() {
956        let pool = setup_test_db().await;
957        let service = PromotionService::new(pool);
958
959        // Verify service is created with disabled gitops
960        assert!(!service.gitops.enabled);
961    }
962
963    #[tokio::test]
964    async fn test_promotion_service_with_gitops() {
965        let pool = setup_test_db().await;
966        let gitops = PromotionGitOpsConfig::disabled();
967        let service = PromotionService::with_gitops(pool, gitops);
968
969        assert!(!service.gitops.enabled);
970    }
971
972    #[tokio::test]
973    async fn test_run_migrations() {
974        let pool = setup_test_db().await;
975        let service = PromotionService::new(pool);
976
977        let result = service.run_migrations().await;
978        assert!(result.is_ok());
979    }
980
981    #[tokio::test]
982    async fn test_record_promotion_success() {
983        let pool = setup_test_db().await;
984        let service = PromotionService::new(pool);
985
986        let request = PromotionRequest {
987            entity_type: PromotionEntityType::Scenario,
988            entity_id: "test-scenario".to_string(),
989            entity_version: Some("v1".to_string()),
990            workspace_id: Uuid::new_v4().to_string(),
991            from_environment: MockEnvironmentName::Dev,
992            to_environment: MockEnvironmentName::Test,
993            requires_approval: false,
994            approval_required_reason: None,
995            comments: Some("Test promotion".to_string()),
996            metadata: std::collections::HashMap::new(),
997        };
998
999        let user_id = Uuid::new_v4();
1000        let result = service
1001            .record_promotion(&request, user_id, PromotionStatus::Pending, None)
1002            .await;
1003
1004        assert!(result.is_ok());
1005        let promotion_id = result.unwrap();
1006
1007        // Verify the promotion was recorded
1008        let promotion = service.get_promotion_by_id(promotion_id).await.unwrap();
1009        assert!(promotion.is_some());
1010        let promotion = promotion.unwrap();
1011        assert_eq!(promotion.entity_id, "test-scenario");
1012        assert_eq!(promotion.status, PromotionStatus::Pending);
1013    }
1014
1015    #[tokio::test]
1016    async fn test_record_promotion_with_metadata() {
1017        let pool = setup_test_db().await;
1018        let service = PromotionService::new(pool);
1019
1020        let mut metadata = std::collections::HashMap::new();
1021        metadata.insert("key1".to_string(), serde_json::Value::String("value1".to_string()));
1022        metadata.insert("key2".to_string(), serde_json::Value::String("value2".to_string()));
1023
1024        let request = PromotionRequest {
1025            entity_type: PromotionEntityType::Persona,
1026            entity_id: "test-persona".to_string(),
1027            entity_version: None,
1028            workspace_id: Uuid::new_v4().to_string(),
1029            from_environment: MockEnvironmentName::Test,
1030            to_environment: MockEnvironmentName::Prod,
1031            requires_approval: true,
1032            approval_required_reason: Some("Production deployment".to_string()),
1033            comments: None,
1034            metadata,
1035        };
1036
1037        let user_id = Uuid::new_v4();
1038        let result = service
1039            .record_promotion(&request, user_id, PromotionStatus::Pending, None)
1040            .await;
1041
1042        assert!(result.is_ok());
1043        let promotion_id = result.unwrap();
1044
1045        // Verify metadata was stored
1046        let promotion = service.get_promotion_by_id(promotion_id).await.unwrap();
1047        assert!(promotion.is_some());
1048        let promotion = promotion.unwrap();
1049        assert_eq!(promotion.metadata.len(), 2);
1050        assert_eq!(promotion.metadata.get("key1").unwrap(), "value1");
1051    }
1052
1053    #[tokio::test]
1054    async fn test_update_promotion_status() {
1055        let pool = setup_test_db().await;
1056        let service = PromotionService::new(pool);
1057
1058        let request = PromotionRequest {
1059            entity_type: PromotionEntityType::Config,
1060            entity_id: "test-config".to_string(),
1061            entity_version: None,
1062            workspace_id: Uuid::new_v4().to_string(),
1063            from_environment: MockEnvironmentName::Dev,
1064            to_environment: MockEnvironmentName::Test,
1065            requires_approval: true,
1066            approval_required_reason: None,
1067            comments: None,
1068            metadata: std::collections::HashMap::new(),
1069        };
1070
1071        let user_id = Uuid::new_v4();
1072        let promotion_id = service
1073            .record_promotion(&request, user_id, PromotionStatus::Pending, None)
1074            .await
1075            .unwrap();
1076
1077        // Update status
1078        let approver_id = Uuid::new_v4();
1079        let result = service
1080            .update_promotion_status(promotion_id, PromotionStatus::Approved, Some(approver_id))
1081            .await;
1082
1083        assert!(result.is_ok());
1084
1085        // Verify update
1086        let promotion = service.get_promotion_by_id(promotion_id).await.unwrap();
1087        assert!(promotion.is_some());
1088        let promotion = promotion.unwrap();
1089        assert_eq!(promotion.status, PromotionStatus::Approved);
1090        assert_eq!(promotion.approved_by, Some(approver_id.to_string()));
1091    }
1092
1093    #[tokio::test]
1094    async fn test_update_promotion_pr_url() {
1095        let pool = setup_test_db().await;
1096        let service = PromotionService::new(pool);
1097
1098        let request = PromotionRequest {
1099            entity_type: PromotionEntityType::Scenario,
1100            entity_id: "test-scenario".to_string(),
1101            entity_version: None,
1102            workspace_id: Uuid::new_v4().to_string(),
1103            from_environment: MockEnvironmentName::Dev,
1104            to_environment: MockEnvironmentName::Test,
1105            requires_approval: false,
1106            approval_required_reason: None,
1107            comments: None,
1108            metadata: std::collections::HashMap::new(),
1109        };
1110
1111        let user_id = Uuid::new_v4();
1112        let promotion_id = service
1113            .record_promotion(&request, user_id, PromotionStatus::Pending, None)
1114            .await
1115            .unwrap();
1116
1117        // Update PR URL
1118        let pr_url = "https://github.com/owner/repo/pull/123".to_string();
1119        let result = service.update_promotion_pr_url(promotion_id, pr_url.clone()).await;
1120
1121        assert!(result.is_ok());
1122
1123        // Verify update
1124        let promotion = service.get_promotion_by_id(promotion_id).await.unwrap();
1125        assert!(promotion.is_some());
1126        let promotion = promotion.unwrap();
1127        assert_eq!(promotion.pr_url, Some(pr_url));
1128    }
1129
1130    #[tokio::test]
1131    async fn test_get_promotion_by_id_not_found() {
1132        let pool = setup_test_db().await;
1133        let service = PromotionService::new(pool);
1134
1135        let result = service.get_promotion_by_id(Uuid::new_v4()).await;
1136        assert!(result.is_ok());
1137        assert!(result.unwrap().is_none());
1138    }
1139
1140    #[tokio::test]
1141    async fn test_get_promotion_history() {
1142        let pool = setup_test_db().await;
1143        let service = PromotionService::new(pool);
1144
1145        let workspace_id = Uuid::new_v4();
1146        let entity_id = "test-scenario";
1147
1148        // Create multiple promotions for the same entity
1149        for i in 0..3 {
1150            let request = PromotionRequest {
1151                entity_type: PromotionEntityType::Scenario,
1152                entity_id: entity_id.to_string(),
1153                entity_version: Some(format!("v{i}")),
1154                workspace_id: workspace_id.to_string(),
1155                from_environment: MockEnvironmentName::Dev,
1156                to_environment: MockEnvironmentName::Test,
1157                requires_approval: false,
1158                approval_required_reason: None,
1159                comments: Some(format!("Promotion {i}")),
1160                metadata: std::collections::HashMap::new(),
1161            };
1162
1163            let user_id = Uuid::new_v4();
1164            service
1165                .record_promotion(&request, user_id, PromotionStatus::Completed, None)
1166                .await
1167                .unwrap();
1168        }
1169
1170        // Get history
1171        let history = service
1172            .get_promotion_history(
1173                &workspace_id.to_string(),
1174                PromotionEntityType::Scenario,
1175                entity_id,
1176            )
1177            .await
1178            .unwrap();
1179
1180        assert_eq!(history.promotions.len(), 3);
1181        assert_eq!(history.entity_id, entity_id);
1182        assert_eq!(history.workspace_id, workspace_id.to_string());
1183    }
1184
1185    #[tokio::test]
1186    async fn test_get_workspace_promotions() {
1187        let pool = setup_test_db().await;
1188        let service = PromotionService::new(pool);
1189
1190        let workspace_id = Uuid::new_v4();
1191
1192        // Create promotions for different entities
1193        for entity_type in &[
1194            PromotionEntityType::Scenario,
1195            PromotionEntityType::Persona,
1196            PromotionEntityType::Config,
1197        ] {
1198            let request = PromotionRequest {
1199                entity_type: *entity_type,
1200                entity_id: format!("test-{entity_type}"),
1201                entity_version: None,
1202                workspace_id: workspace_id.to_string(),
1203                from_environment: MockEnvironmentName::Dev,
1204                to_environment: MockEnvironmentName::Test,
1205                requires_approval: false,
1206                approval_required_reason: None,
1207                comments: None,
1208                metadata: std::collections::HashMap::new(),
1209            };
1210
1211            let user_id = Uuid::new_v4();
1212            service
1213                .record_promotion(&request, user_id, PromotionStatus::Completed, None)
1214                .await
1215                .unwrap();
1216        }
1217
1218        // Get all workspace promotions
1219        let promotions =
1220            service.get_workspace_promotions(&workspace_id.to_string(), None).await.unwrap();
1221
1222        assert_eq!(promotions.len(), 3);
1223    }
1224
1225    #[tokio::test]
1226    async fn test_get_workspace_promotions_with_limit() {
1227        let pool = setup_test_db().await;
1228        let service = PromotionService::new(pool);
1229
1230        let workspace_id = Uuid::new_v4();
1231
1232        // Create 5 promotions
1233        for i in 0..5 {
1234            let request = PromotionRequest {
1235                entity_type: PromotionEntityType::Scenario,
1236                entity_id: format!("test-{i}"),
1237                entity_version: None,
1238                workspace_id: workspace_id.to_string(),
1239                from_environment: MockEnvironmentName::Dev,
1240                to_environment: MockEnvironmentName::Test,
1241                requires_approval: false,
1242                approval_required_reason: None,
1243                comments: None,
1244                metadata: std::collections::HashMap::new(),
1245            };
1246
1247            let user_id = Uuid::new_v4();
1248            service
1249                .record_promotion(&request, user_id, PromotionStatus::Completed, None)
1250                .await
1251                .unwrap();
1252        }
1253
1254        // Get with limit
1255        let promotions = service
1256            .get_workspace_promotions(&workspace_id.to_string(), Some(3))
1257            .await
1258            .unwrap();
1259
1260        assert_eq!(promotions.len(), 3);
1261    }
1262
1263    #[tokio::test]
1264    async fn test_get_pending_promotions() {
1265        let pool = setup_test_db().await;
1266        let service = PromotionService::new(pool);
1267
1268        let workspace_id = Uuid::new_v4();
1269
1270        // Create promotions with different statuses
1271        for (i, status) in [
1272            PromotionStatus::Pending,
1273            PromotionStatus::Approved,
1274            PromotionStatus::Pending,
1275            PromotionStatus::Completed,
1276        ]
1277        .iter()
1278        .enumerate()
1279        {
1280            let request = PromotionRequest {
1281                entity_type: PromotionEntityType::Scenario,
1282                entity_id: format!("test-{i}"),
1283                entity_version: None,
1284                workspace_id: workspace_id.to_string(),
1285                from_environment: MockEnvironmentName::Dev,
1286                to_environment: MockEnvironmentName::Test,
1287                requires_approval: true,
1288                approval_required_reason: None,
1289                comments: None,
1290                metadata: std::collections::HashMap::new(),
1291            };
1292
1293            let user_id = Uuid::new_v4();
1294            service.record_promotion(&request, user_id, *status, None).await.unwrap();
1295        }
1296
1297        // Get pending promotions
1298        let pending =
1299            service.get_pending_promotions(Some(&workspace_id.to_string())).await.unwrap();
1300
1301        assert_eq!(pending.len(), 2);
1302        for promotion in &pending {
1303            assert_eq!(promotion.status, PromotionStatus::Pending);
1304        }
1305    }
1306
1307    #[tokio::test]
1308    async fn test_get_pending_promotions_all_workspaces() {
1309        let pool = setup_test_db().await;
1310        let service = PromotionService::new(pool);
1311
1312        // Create promotions in different workspaces
1313        for _ in 0..3 {
1314            let workspace_id = Uuid::new_v4();
1315            let request = PromotionRequest {
1316                entity_type: PromotionEntityType::Scenario,
1317                entity_id: "test-scenario".to_string(),
1318                entity_version: None,
1319                workspace_id: workspace_id.to_string(),
1320                from_environment: MockEnvironmentName::Dev,
1321                to_environment: MockEnvironmentName::Test,
1322                requires_approval: true,
1323                approval_required_reason: None,
1324                comments: None,
1325                metadata: std::collections::HashMap::new(),
1326            };
1327
1328            let user_id = Uuid::new_v4();
1329            service
1330                .record_promotion(&request, user_id, PromotionStatus::Pending, None)
1331                .await
1332                .unwrap();
1333        }
1334
1335        // Get all pending promotions
1336        let pending = service.get_pending_promotions(None).await.unwrap();
1337
1338        assert_eq!(pending.len(), 3);
1339    }
1340
1341    #[tokio::test]
1342    async fn test_promotion_service_trait_promote_entity() {
1343        let pool = setup_test_db().await;
1344        let service = PromotionService::new(pool);
1345
1346        let workspace_id = Uuid::new_v4();
1347        let user_id = Uuid::new_v4();
1348
1349        let result = service
1350            .promote_entity(
1351                workspace_id,
1352                PromotionEntityType::Scenario,
1353                "test-scenario".to_string(),
1354                Some("v1".to_string()),
1355                MockEnvironmentName::Dev,
1356                MockEnvironmentName::Test,
1357                user_id,
1358                Some("Auto promotion".to_string()),
1359            )
1360            .await;
1361
1362        assert!(result.is_ok());
1363        let promotion_id = result.unwrap();
1364
1365        // Verify the promotion was created with Completed status
1366        let promotion = service.get_promotion_by_id(promotion_id).await.unwrap();
1367        assert!(promotion.is_some());
1368        let promotion = promotion.unwrap();
1369        assert_eq!(promotion.status, PromotionStatus::Completed);
1370        assert_eq!(promotion.entity_id, "test-scenario");
1371    }
1372
1373    #[tokio::test]
1374    async fn test_all_promotion_statuses() {
1375        let pool = setup_test_db().await;
1376        let service = PromotionService::new(pool);
1377
1378        let statuses = vec![
1379            PromotionStatus::Pending,
1380            PromotionStatus::Approved,
1381            PromotionStatus::Rejected,
1382            PromotionStatus::Completed,
1383            PromotionStatus::Failed,
1384        ];
1385
1386        for status in statuses {
1387            let request = PromotionRequest {
1388                entity_type: PromotionEntityType::Scenario,
1389                entity_id: format!("test-{status}"),
1390                entity_version: None,
1391                workspace_id: Uuid::new_v4().to_string(),
1392                from_environment: MockEnvironmentName::Dev,
1393                to_environment: MockEnvironmentName::Test,
1394                requires_approval: false,
1395                approval_required_reason: None,
1396                comments: None,
1397                metadata: std::collections::HashMap::new(),
1398            };
1399
1400            let user_id = Uuid::new_v4();
1401            let promotion_id =
1402                service.record_promotion(&request, user_id, status, None).await.unwrap();
1403
1404            // Verify status was stored correctly
1405            let promotion = service.get_promotion_by_id(promotion_id).await.unwrap();
1406            assert!(promotion.is_some());
1407            assert_eq!(promotion.unwrap().status, status);
1408        }
1409    }
1410
1411    #[tokio::test]
1412    async fn test_all_entity_types() {
1413        let pool = setup_test_db().await;
1414        let service = PromotionService::new(pool);
1415
1416        let entity_types = vec![
1417            PromotionEntityType::Scenario,
1418            PromotionEntityType::Persona,
1419            PromotionEntityType::Config,
1420        ];
1421
1422        for entity_type in entity_types {
1423            let request = PromotionRequest {
1424                entity_type,
1425                entity_id: format!("test-{entity_type}"),
1426                entity_version: None,
1427                workspace_id: Uuid::new_v4().to_string(),
1428                from_environment: MockEnvironmentName::Dev,
1429                to_environment: MockEnvironmentName::Test,
1430                requires_approval: false,
1431                approval_required_reason: None,
1432                comments: None,
1433                metadata: std::collections::HashMap::new(),
1434            };
1435
1436            let user_id = Uuid::new_v4();
1437            let promotion_id = service
1438                .record_promotion(&request, user_id, PromotionStatus::Completed, None)
1439                .await
1440                .unwrap();
1441
1442            // Verify entity type was stored correctly
1443            let promotion = service.get_promotion_by_id(promotion_id).await.unwrap();
1444            assert!(promotion.is_some());
1445            assert_eq!(promotion.unwrap().entity_type, entity_type);
1446        }
1447    }
1448}