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