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