1use crate::error::{Error, Result};
4use crate::platform::PlatformService;
5use crate::types::{Platform, PlatformConfig, PrComment, PullRequest};
6use async_trait::async_trait;
7use reqwest::Client;
8use serde::{Deserialize, Serialize};
9use tracing::debug;
10
11pub struct GitLabService {
13 client: Client,
14 token: String,
15 host: String,
16 config: PlatformConfig,
17 project_path: String,
18}
19
20#[derive(Deserialize)]
21struct MergeRequest {
22 iid: u64,
23 web_url: String,
24 source_branch: String,
25 target_branch: String,
26 title: String,
27 #[serde(default)]
28 draft: bool,
29}
30
31#[derive(Deserialize)]
32struct MrNote {
33 id: u64,
34 body: String,
35 system: bool,
36}
37
38impl From<MergeRequest> for PullRequest {
39 fn from(mr: MergeRequest) -> Self {
40 Self {
41 number: mr.iid,
42 html_url: mr.web_url,
43 base_ref: mr.target_branch,
44 head_ref: mr.source_branch,
45 title: mr.title,
46 node_id: None, is_draft: mr.draft,
48 }
49 }
50}
51
52#[derive(Serialize)]
53struct CreateMrPayload {
54 source_branch: String,
55 target_branch: String,
56 title: String,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 draft: Option<bool>,
59}
60
61const DEFAULT_TIMEOUT_SECS: u64 = 30;
63
64impl GitLabService {
65 pub fn new(token: String, owner: String, repo: String, host: Option<String>) -> Result<Self> {
67 let host = host.unwrap_or_else(|| "gitlab.com".to_string());
68 let project_path = format!("{owner}/{repo}");
69
70 let client = Client::builder()
71 .timeout(std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS))
72 .build()
73 .map_err(|e| Error::GitLabApi(format!("failed to create HTTP client: {e}")))?;
74
75 let config_host = if host == "gitlab.com" {
76 None
77 } else {
78 Some(host.clone())
79 };
80
81 Ok(Self {
82 client,
83 token,
84 host,
85 config: PlatformConfig {
86 platform: Platform::GitLab,
87 owner,
88 repo,
89 host: config_host,
90 },
91 project_path,
92 })
93 }
94
95 fn api_url(&self, path: &str) -> String {
96 format!("https://{}/api/v4{}", self.host, path)
97 }
98
99 fn encoded_project(&self) -> String {
100 urlencoding::encode(&self.project_path).into_owned()
101 }
102}
103
104#[async_trait]
105impl PlatformService for GitLabService {
106 async fn find_existing_pr(&self, head_branch: &str) -> Result<Option<PullRequest>> {
107 debug!(head_branch, "finding existing MR");
108 let url = self.api_url(&format!(
109 "/projects/{}/merge_requests",
110 self.encoded_project()
111 ));
112
113 let mrs: Vec<MergeRequest> = self
114 .client
115 .get(&url)
116 .header("PRIVATE-TOKEN", &self.token)
117 .query(&[("source_branch", head_branch), ("state", "opened")])
118 .send()
119 .await?
120 .error_for_status()
121 .map_err(|e| Error::GitLabApi(e.to_string()))?
122 .json()
123 .await?;
124
125 let result: Option<PullRequest> = mrs.into_iter().next().map(Into::into);
126 if let Some(ref pr) = result {
127 debug!(mr_iid = pr.number, "found existing MR");
128 } else {
129 debug!("no existing MR found");
130 }
131 Ok(result)
132 }
133
134 async fn create_pr_with_options(
135 &self,
136 head: &str,
137 base: &str,
138 title: &str,
139 draft: bool,
140 ) -> Result<PullRequest> {
141 debug!(head, base, draft, "creating MR");
142 let url = self.api_url(&format!(
143 "/projects/{}/merge_requests",
144 self.encoded_project()
145 ));
146
147 let payload = CreateMrPayload {
148 source_branch: head.to_string(),
149 target_branch: base.to_string(),
150 title: title.to_string(),
151 draft: if draft { Some(true) } else { None },
152 };
153
154 let mr: MergeRequest = self
155 .client
156 .post(&url)
157 .header("PRIVATE-TOKEN", &self.token)
158 .json(&payload)
159 .send()
160 .await?
161 .error_for_status()
162 .map_err(|e| Error::GitLabApi(e.to_string()))?
163 .json()
164 .await?;
165
166 let pr: PullRequest = mr.into();
167 debug!(mr_iid = pr.number, "created MR");
168 Ok(pr)
169 }
170
171 async fn update_pr_base(&self, pr_number: u64, new_base: &str) -> Result<PullRequest> {
172 debug!(mr_iid = pr_number, new_base, "updating MR base");
173 let url = self.api_url(&format!(
174 "/projects/{}/merge_requests/{}",
175 self.encoded_project(),
176 pr_number
177 ));
178
179 let mr: MergeRequest = self
180 .client
181 .put(&url)
182 .header("PRIVATE-TOKEN", &self.token)
183 .json(&serde_json::json!({ "target_branch": new_base }))
184 .send()
185 .await?
186 .error_for_status()
187 .map_err(|e| Error::GitLabApi(e.to_string()))?
188 .json()
189 .await?;
190
191 debug!(mr_iid = pr_number, "updated MR base");
192 Ok(mr.into())
193 }
194
195 async fn publish_pr(&self, pr_number: u64) -> Result<PullRequest> {
196 debug!(mr_iid = pr_number, "publishing MR");
197 let url = self.api_url(&format!(
200 "/projects/{}/merge_requests/{}",
201 self.encoded_project(),
202 pr_number
203 ));
204
205 let mr: MergeRequest = self
207 .client
208 .put(&url)
209 .header("PRIVATE-TOKEN", &self.token)
210 .json(&serde_json::json!({ "state_event": "ready" }))
211 .send()
212 .await?
213 .error_for_status()
214 .map_err(|e| Error::GitLabApi(e.to_string()))?
215 .json()
216 .await?;
217
218 debug!(mr_iid = pr_number, "published MR");
219 Ok(mr.into())
220 }
221
222 async fn list_pr_comments(&self, pr_number: u64) -> Result<Vec<PrComment>> {
223 debug!(mr_iid = pr_number, "listing MR comments");
224 let url = self.api_url(&format!(
225 "/projects/{}/merge_requests/{}/notes",
226 self.encoded_project(),
227 pr_number
228 ));
229
230 let notes: Vec<MrNote> = self
231 .client
232 .get(&url)
233 .header("PRIVATE-TOKEN", &self.token)
234 .send()
235 .await?
236 .error_for_status()
237 .map_err(|e| Error::GitLabApi(e.to_string()))?
238 .json()
239 .await?;
240
241 let comments: Vec<PrComment> = notes
242 .into_iter()
243 .filter(|n| !n.system)
244 .map(|n| PrComment {
245 id: n.id,
246 body: n.body,
247 })
248 .collect();
249 debug!(
250 mr_iid = pr_number,
251 count = comments.len(),
252 "listed MR comments"
253 );
254 Ok(comments)
255 }
256
257 async fn create_pr_comment(&self, pr_number: u64, body: &str) -> Result<()> {
258 debug!(mr_iid = pr_number, "creating MR comment");
259 let url = self.api_url(&format!(
260 "/projects/{}/merge_requests/{}/notes",
261 self.encoded_project(),
262 pr_number
263 ));
264
265 self.client
266 .post(&url)
267 .header("PRIVATE-TOKEN", &self.token)
268 .json(&serde_json::json!({ "body": body }))
269 .send()
270 .await?
271 .error_for_status()
272 .map_err(|e| Error::GitLabApi(e.to_string()))?;
273
274 debug!(mr_iid = pr_number, "created MR comment");
275 Ok(())
276 }
277
278 async fn update_pr_comment(&self, pr_number: u64, comment_id: u64, body: &str) -> Result<()> {
279 debug!(mr_iid = pr_number, comment_id, "updating MR comment");
280 let url = self.api_url(&format!(
281 "/projects/{}/merge_requests/{}/notes/{}",
282 self.encoded_project(),
283 pr_number,
284 comment_id
285 ));
286
287 self.client
288 .put(&url)
289 .header("PRIVATE-TOKEN", &self.token)
290 .json(&serde_json::json!({ "body": body }))
291 .send()
292 .await?
293 .error_for_status()
294 .map_err(|e| Error::GitLabApi(e.to_string()))?;
295
296 debug!(mr_iid = pr_number, comment_id, "updated MR comment");
297 Ok(())
298 }
299
300 fn config(&self) -> &PlatformConfig {
301 &self.config
302 }
303}