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