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