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