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