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}