mockforge_core/pr_generation/
gitlab.rs

1//! GitLab PR (Merge Request) client
2//!
3//! This module provides functionality for creating merge requests on GitLab.
4
5use crate::pr_generation::types::{PRFileChange, PRFileChangeType, PRRequest, PRResult};
6use crate::Error;
7use reqwest::Client;
8
9/// GitLab PR client
10#[derive(Debug, Clone)]
11pub struct GitLabPRClient {
12    owner: String,
13    repo: String,
14    token: String,
15    base_branch: String,
16    client: Client,
17    api_url: String,
18}
19
20impl GitLabPRClient {
21    /// Create a new GitLab PR client
22    pub fn new(owner: String, repo: String, token: String, base_branch: String) -> Self {
23        Self {
24            owner,
25            repo,
26            token,
27            base_branch,
28            client: Client::new(),
29            api_url: "https://gitlab.com/api/v4".to_string(),
30        }
31    }
32
33    /// Create a merge request (PR)
34    pub async fn create_pr(&self, request: PRRequest) -> crate::Result<PRResult> {
35        // GitLab API uses project ID or path
36        let project_path = format!("{}/{}", self.owner, self.repo);
37
38        // Step 1: Create branch
39        self.create_branch(&request.branch).await?;
40
41        // Step 2: Commit file changes
42        for file_change in &request.files {
43            match file_change.change_type {
44                PRFileChangeType::Create | PRFileChangeType::Update => {
45                    self.commit_file(&request.branch, file_change).await?;
46                }
47                PRFileChangeType::Delete => {
48                    self.delete_file(&request.branch, file_change).await?;
49                }
50            }
51        }
52
53        // Step 3: Create merge request
54        let mr = self.create_merge_request(&request, &project_path).await?;
55
56        // Step 4: Add labels if any
57        if !request.labels.is_empty() {
58            self.add_labels(mr.number, &request.labels, &project_path).await?;
59        }
60
61        Ok(mr)
62    }
63
64    async fn create_branch(&self, branch: &str) -> crate::Result<()> {
65        let project_path = format!("{}/{}", self.owner, self.repo);
66        let url = format!(
67            "{}/projects/{}/repository/branches",
68            self.api_url,
69            urlencoding::encode(&project_path)
70        );
71
72        let body = serde_json::json!({
73            "branch": branch,
74            "ref": self.base_branch
75        });
76
77        let response = self
78            .client
79            .post(&url)
80            .header("PRIVATE-TOKEN", &self.token)
81            .json(&body)
82            .send()
83            .await
84            .map_err(|e| Error::generic(format!("Failed to create branch: {}", e)))?;
85
86        let status = response.status();
87        if !status.is_success() {
88            let error_text = response.text().await.unwrap_or_default();
89            // Branch might already exist, which is okay
90            if !error_text.contains("already exists") {
91                return Err(Error::generic(format!(
92                    "Failed to create branch: {} - {}",
93                    status, error_text
94                )));
95            }
96        }
97
98        Ok(())
99    }
100
101    async fn commit_file(&self, branch: &str, file_change: &PRFileChange) -> crate::Result<()> {
102        let project_path = format!("{}/{}", self.owner, self.repo);
103        let url = format!(
104            "{}/projects/{}/repository/files/{}",
105            self.api_url,
106            urlencoding::encode(&project_path),
107            urlencoding::encode(&file_change.path)
108        );
109
110        let content = base64::encode(&file_change.content);
111
112        let body = serde_json::json!({
113            "branch": branch,
114            "content": content,
115            "encoding": "base64",
116            "commit_message": format!("Update {}", file_change.path)
117        });
118
119        let response = self
120            .client
121            .put(&url)
122            .header("PRIVATE-TOKEN", &self.token)
123            .json(&body)
124            .send()
125            .await
126            .map_err(|e| Error::generic(format!("Failed to commit file: {}", e)))?;
127
128        let status = response.status();
129        if !status.is_success() {
130            // Try creating the file if it doesn't exist
131            if status == 404 {
132                return self.create_file(branch, file_change).await;
133            }
134
135            let error_text = response.text().await.unwrap_or_default();
136            return Err(Error::generic(format!(
137                "Failed to commit file: {} - {}",
138                status, error_text
139            )));
140        }
141
142        Ok(())
143    }
144
145    async fn create_file(&self, branch: &str, file_change: &PRFileChange) -> crate::Result<()> {
146        let project_path = format!("{}/{}", self.owner, self.repo);
147        let url = format!(
148            "{}/projects/{}/repository/files/{}",
149            self.api_url,
150            urlencoding::encode(&project_path),
151            urlencoding::encode(&file_change.path)
152        );
153
154        let content = base64::encode(&file_change.content);
155
156        let body = serde_json::json!({
157            "branch": branch,
158            "content": content,
159            "encoding": "base64",
160            "commit_message": format!("Create {}", file_change.path)
161        });
162
163        let response = self
164            .client
165            .post(&url)
166            .header("PRIVATE-TOKEN", &self.token)
167            .json(&body)
168            .send()
169            .await
170            .map_err(|e| Error::generic(format!("Failed to create file: {}", e)))?;
171
172        let status = response.status();
173        if !status.is_success() {
174            let error_text = response.text().await.unwrap_or_default();
175            return Err(Error::generic(format!(
176                "Failed to create file: {} - {}",
177                status, error_text
178            )));
179        }
180
181        Ok(())
182    }
183
184    async fn delete_file(&self, branch: &str, file_change: &PRFileChange) -> crate::Result<()> {
185        let project_path = format!("{}/{}", self.owner, self.repo);
186        let url = format!(
187            "{}/projects/{}/repository/files/{}",
188            self.api_url,
189            urlencoding::encode(&project_path),
190            urlencoding::encode(&file_change.path)
191        );
192
193        let body = serde_json::json!({
194            "branch": branch,
195            "commit_message": format!("Delete {}", file_change.path)
196        });
197
198        let response = self
199            .client
200            .delete(&url)
201            .header("PRIVATE-TOKEN", &self.token)
202            .json(&body)
203            .send()
204            .await
205            .map_err(|e| Error::generic(format!("Failed to delete file: {}", e)))?;
206
207        let status = response.status();
208        if !status.is_success() {
209            let error_text = response.text().await.unwrap_or_default();
210            return Err(Error::generic(format!(
211                "Failed to delete file: {} - {}",
212                status, error_text
213            )));
214        }
215
216        Ok(())
217    }
218
219    async fn create_merge_request(
220        &self,
221        request: &PRRequest,
222        project_path: &str,
223    ) -> crate::Result<PRResult> {
224        let url = format!(
225            "{}/projects/{}/merge_requests",
226            self.api_url,
227            urlencoding::encode(project_path)
228        );
229
230        let body = serde_json::json!({
231            "source_branch": request.branch,
232            "target_branch": self.base_branch,
233            "title": request.title,
234            "description": request.body
235        });
236
237        let response = self
238            .client
239            .post(&url)
240            .header("PRIVATE-TOKEN", &self.token)
241            .json(&body)
242            .send()
243            .await
244            .map_err(|e| Error::generic(format!("Failed to create MR: {}", e)))?;
245
246        let status = response.status();
247        if !status.is_success() {
248            let error_text = response.text().await.unwrap_or_default();
249            return Err(Error::generic(format!(
250                "Failed to create MR: {} - {}",
251                status, error_text
252            )));
253        }
254
255        let json: serde_json::Value = response
256            .json()
257            .await
258            .map_err(|e| Error::generic(format!("Failed to parse response: {}", e)))?;
259
260        Ok(PRResult {
261            number: json["iid"].as_u64().ok_or_else(|| Error::generic("Missing MR number"))?,
262            url: json["web_url"]
263                .as_str()
264                .ok_or_else(|| Error::generic("Missing MR URL"))?
265                .to_string(),
266            branch: request.branch.clone(),
267            title: request.title.clone(),
268        })
269    }
270
271    async fn add_labels(
272        &self,
273        mr_number: u64,
274        labels: &[String],
275        project_path: &str,
276    ) -> crate::Result<()> {
277        let url = format!(
278            "{}/projects/{}/merge_requests/{}",
279            self.api_url,
280            urlencoding::encode(project_path),
281            mr_number
282        );
283
284        let body = serde_json::json!({
285            "add_labels": labels.join(",")
286        });
287
288        let response = self
289            .client
290            .put(&url)
291            .header("PRIVATE-TOKEN", &self.token)
292            .json(&body)
293            .send()
294            .await
295            .map_err(|e| Error::generic(format!("Failed to add labels: {}", e)))?;
296
297        let status = response.status();
298        if !status.is_success() {
299            return Err(Error::generic(format!("Failed to add labels: {}", status)));
300        }
301
302        Ok(())
303    }
304}