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