1use crate::{
7 database::RecorderDatabase,
8 models::RecordedRequest,
9 sync::{DetectedChange, GitOpsConfig},
10 Result,
11};
12use mockforge_core::pr_generation::{
13 PRFileChange, PRFileChangeType, PRGenerator, PRProvider, PRRequest,
14};
15use std::path::PathBuf;
16use tracing::{debug, info, warn};
17
18pub struct GitOpsSyncHandler {
20 config: GitOpsConfig,
21 pr_generator: Option<PRGenerator>,
22 fixtures_dir: PathBuf,
23}
24
25impl GitOpsSyncHandler {
26 pub fn new(config: GitOpsConfig, fixtures_dir: PathBuf) -> Result<Self> {
28 let pr_generator = if config.enabled && config.token.is_some() {
29 let provider = match config.pr_provider.to_lowercase().as_str() {
30 "gitlab" => PRProvider::GitLab,
31 _ => PRProvider::GitHub,
32 };
33
34 let token = config.token.as_ref().ok_or_else(|| {
35 crate::RecorderError::InvalidFilter("GitOps token not provided".to_string())
36 })?;
37
38 Some(match provider {
39 PRProvider::GitHub => PRGenerator::new_github(
40 config.repo_owner.clone(),
41 config.repo_name.clone(),
42 token.clone(),
43 config.base_branch.clone(),
44 ),
45 PRProvider::GitLab => PRGenerator::new_gitlab(
46 config.repo_owner.clone(),
47 config.repo_name.clone(),
48 token.clone(),
49 config.base_branch.clone(),
50 ),
51 })
52 } else {
53 None
54 };
55
56 Ok(Self {
57 config,
58 pr_generator,
59 fixtures_dir,
60 })
61 }
62
63 pub async fn process_sync_changes(
65 &self,
66 database: &RecorderDatabase,
67 changes: &[DetectedChange],
68 sync_cycle_id: &str,
69 ) -> Result<Option<mockforge_core::pr_generation::PRResult>> {
70 if !self.config.enabled {
71 return Ok(None);
72 }
73
74 if changes.is_empty() {
75 debug!("No changes detected, skipping GitOps PR creation");
76 return Ok(None);
77 }
78
79 let pr_generator = self.pr_generator.as_ref().ok_or_else(|| {
80 crate::RecorderError::InvalidFilter("PR generator not configured".to_string())
81 })?;
82
83 info!("Processing {} changes for GitOps PR creation", changes.len());
84
85 let mut file_changes = Vec::new();
87
88 for change in changes {
89 if let Ok(Some(request)) = database.get_request(&change.request_id).await {
91 if self.config.update_fixtures {
92 if let Some(fixture_change) =
93 self.create_fixture_file_change(database, &request, change).await?
94 {
95 file_changes.push(fixture_change);
96 }
97 }
98 }
99 }
100
101 if file_changes.is_empty() {
102 warn!("No file changes to commit, skipping PR creation");
103 return Ok(None);
104 }
105
106 let branch = format!(
108 "{}/sync-{}",
109 self.config.base_branch,
110 sync_cycle_id.split('_').last().unwrap_or(sync_cycle_id)
111 );
112
113 let title =
114 format!("Auto-sync: Update fixtures from upstream API changes ({})", sync_cycle_id);
115
116 let body = self.generate_pr_body(changes);
117
118 let pr_request = PRRequest {
119 title,
120 body,
121 branch,
122 files: file_changes,
123 labels: vec!["automated".to_string(), "contract-update".to_string()],
124 reviewers: vec![],
125 };
126
127 match pr_generator.create_pr(pr_request).await {
128 Ok(result) => {
129 info!("Created GitOps PR: {} - {}", result.number, result.url);
130 Ok(Some(result))
131 }
132 Err(e) => {
133 warn!("Failed to create GitOps PR: {}", e);
134 Err(crate::RecorderError::InvalidFilter(format!("Failed to create PR: {}", e)))
135 }
136 }
137 }
138
139 async fn create_fixture_file_change(
141 &self,
142 database: &RecorderDatabase,
143 request: &RecordedRequest,
144 change: &DetectedChange,
145 ) -> Result<Option<PRFileChange>> {
146 let fixture_path = self.get_fixture_path(request);
148
149 let response = database.get_response(&change.request_id).await?.ok_or_else(|| {
151 crate::RecorderError::NotFound(format!(
152 "Response not found for request {}",
153 change.request_id
154 ))
155 })?;
156
157 let fixture_content = serde_json::to_string_pretty(&serde_json::json!({
159 "id": request.id,
160 "method": request.method,
161 "path": request.path,
162 "headers": request.headers,
163 "body": request.body,
164 "response": {
165 "status_code": response.status_code,
166 "headers": response.headers,
167 "body": response.body,
168 "body_encoding": response.body_encoding,
169 },
170 "timestamp": request.timestamp,
171 }))?;
172
173 let change_type = if std::path::Path::new(&fixture_path).exists() {
175 PRFileChangeType::Update
176 } else {
177 PRFileChangeType::Create
178 };
179
180 Ok(Some(PRFileChange {
181 path: fixture_path,
182 content: fixture_content,
183 change_type,
184 }))
185 }
186
187 fn get_fixture_path(&self, request: &RecordedRequest) -> String {
189 let method = request.method.to_lowercase();
190 let path_hash = request.path.replace(['/', ':'], "_");
191
192 use std::collections::hash_map::DefaultHasher;
194 use std::hash::{Hash, Hasher};
195 let mut hasher = DefaultHasher::new();
196 request.path.hash(&mut hasher);
197 let hash = format!("{:x}", hasher.finish());
198
199 format!("fixtures/http/{}/{}/{}.json", method, path_hash, hash)
201 }
202
203 fn generate_pr_body(&self, changes: &[DetectedChange]) -> String {
205 let mut body = String::from("## Auto-sync: Upstream API Changes\n\n");
206 body.push_str("This PR was automatically generated by MockForge sync to update fixtures based on detected upstream API changes.\n\n");
207
208 body.push_str("### Summary\n\n");
209 body.push_str(&format!("- **Total changes**: {}\n", changes.len()));
210 body.push_str(&format!(
211 "- **Endpoints affected**: {}\n",
212 self.count_unique_endpoints(changes)
213 ));
214
215 body.push_str("\n### Changes\n\n");
216 for change in changes {
217 body.push_str(&format!(
218 "- `{} {}`: {} differences detected\n",
219 change.method,
220 change.path,
221 change.comparison.differences.len()
222 ));
223 }
224
225 body.push_str("\n### What Changed\n\n");
226 body.push_str("- Updated fixture files with new response data\n");
227 if self.config.update_docs {
228 body.push_str("- Updated OpenAPI specifications\n");
229 }
230 if self.config.regenerate_sdks {
231 body.push_str("- Regenerated SDKs\n");
232 }
233
234 body.push_str("\n---\n");
235 body.push_str("*This PR was automatically created by MockForge sync. Please review the changes before merging.*\n");
236
237 body
238 }
239
240 fn count_unique_endpoints(&self, changes: &[DetectedChange]) -> usize {
242 let mut endpoints = std::collections::HashSet::new();
243 for change in changes {
244 endpoints.insert(format!("{} {}", change.method, change.path));
245 }
246 endpoints.len()
247 }
248}