Skip to main content

jj_ryu/platform/
gitlab.rs

1//! GitLab platform service implementation
2
3use 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
11/// GitLab service using reqwest
12pub 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, // GitLab doesn't use GraphQL node IDs
47            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
61/// Default request timeout in seconds
62const DEFAULT_TIMEOUT_SECS: u64 = 30;
63
64impl GitLabService {
65    /// Create a new GitLab service
66    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        // GitLab: Use state_event to mark MR as ready
198        // We need to remove the draft/WIP status
199        let url = self.api_url(&format!(
200            "/projects/{}/merge_requests/{}",
201            self.encoded_project(),
202            pr_number
203        ));
204
205        // GitLab uses state_event: "ready" to mark as ready for review
206        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}