gitlab_butler_lib/
merge_requests.rs

1//! ## Manage ready to merge GitLab MRs
2//!
3//! If in your project you prefer a semi-linear history, and you have a queue of MRs that are ready to be
4//! merged, you may and up fighting for the merge and rebasing more than once each branch (the problem is more
5//! evident if the project has a lengthy pipeline to wait).
6//!
7//! This bot automate a workflow that emerged in our team:
8//!
9//! 1. Developer A work on some feature
10//! 2. Once done, assign the MR to developer B for the review
11//! 3. Instead of merging (or rebasing) developer B adds a ~"Merge Ready" label
12//! 4. The TL manages the merge queue (doing a second review on the code)
13//!
14//! Waiting for this bot to exist, we ended up finding the second review valuable, so we modified the bot to
15//! wait for both the ~"Merge Ready" label and the TL emoji of choice.
16//!
17//! The new workflow is very like the one above, with a different ending:
18//!
19//! 1. Developer A work on some feature
20//! 2. Once done, assign the MR to developer B for the review
21//! 3. Instead of merging (or rebasing) developer B adds a ~"Merge Ready" label
22//! 4. The TL does a second review on the code, and adds an `:octopus:` emoji
23//! 5. `gitlab-butler` loops and picks the first rebased MR or triggers a rebase
24
25use std::collections::HashSet;
26
27use anyhow::Result;
28use log::{debug, error, info};
29use url::percent_encoding::{utf8_percent_encode, PATH_SEGMENT_ENCODE_SET};
30use url::Url;
31
32use crate::client::Client;
33use crate::entities::*;
34
35type ReqwestClient = reqwest::blocking::Client;
36
37impl MergeRequest {
38    pub fn list_ready_to_merge(
39        client: &Client,
40        project: &Option<String>,
41        merge_labels: &str,
42        merge_emoji: &str,
43    ) -> Result<Vec<MergeRequest>> {
44        let prefix = if let Some(aproject) = project {
45            let project_name_or_id =
46                utf8_percent_encode(aproject, PATH_SEGMENT_ENCODE_SET).to_string();
47            format!("api/v4/projects/{}/merge_requests?", project_name_or_id)
48        } else {
49            "api/v4/merge_requests?scope=assigned_to_me&".to_string()
50        };
51        let api_path = format!(
52            "{}state=opened&order_by=created_at&labels={}&my_reaction_emoji={}",
53            prefix, merge_labels, merge_emoji
54        );
55        debug!("Ready to query {:?}", api_path);
56        let mut merge_requests: Vec<MergeRequest> =
57            client.get(&api_path)?.error_for_status()?.json()?;
58        debug!("{} merge requests found", merge_requests.len());
59        // TODO: We should reassign back all WIP or conflicting MRs
60        merge_requests.retain(|mr| {
61            debug!("{:?}", mr);
62            !mr.work_in_progress
63                && mr.merge_status == "can_be_merged"
64                && mr.target_branch == "master"
65        });
66        Ok(merge_requests)
67    }
68    pub fn infos(&self, client: &ReqwestClient, api_server: &Url) -> Result<MergeRequestDetails> {
69        let mr_uri = format!(
70            "api/v4/projects/{}/merge_requests/{}",
71            self.project_id, self.iid
72        );
73        let info_mr_uri = api_server.join(&mr_uri)?;
74        let message = GetMergeRequestDetails::from(self);
75        let resp = client
76            .get(info_mr_uri.as_str())
77            .json(&message)
78            .send()?
79            .json()?;
80        Ok(resp)
81    }
82    fn auto_remove_branch(&self, client: &ReqwestClient, api_server: &Url) -> Result<MergeRequest> {
83        let mr_uri = format!(
84            "api/v4/projects/{}/merge_requests/{}",
85            self.project_id, self.iid
86        );
87        let update_mr_url = api_server.join(&mr_uri)?;
88        let force_merge = RemoveSourceBranch::from(self);
89        debug!("  Update url: {:?} <- {:?}", update_mr_url, force_merge);
90        // We may set the remove after merge option in the merge command too
91        let resp = client
92            .put(update_mr_url.as_str())
93            .json(&force_merge)
94            .send()?
95            .json()?;
96        Ok(resp)
97    }
98    pub fn pipelines(&self, client: &Client) -> Result<Vec<Pipeline>> {
99        let pipelines_uri = format!(
100            "api/v4/projects/{}/merge_requests/{}/pipelines",
101            self.project_id, self.iid
102        );
103        let message = ListPipelines::from(self);
104        debug!("querying pipelines: {:?} {:?}", pipelines_uri, message);
105        let resp = client.get_json(&pipelines_uri, &message)?.json()?;
106        Ok(resp)
107    }
108    fn accept(&self, client: &ReqwestClient, api_server: &Url) -> Result<MergeRequest> {
109        let accept_uri = format!(
110            "api/v4/projects/{}/merge_requests/{}/merge",
111            self.project_id, self.iid
112        );
113        let accept_mr_uri = api_server.join(&accept_uri)?;
114        let message = AcceptMergeRequest::from(self);
115        let resp = client.put(accept_mr_uri.as_str()).json(&message).send()?;
116        debug!("accept response: {:?}", resp);
117        let resp = resp.json()?;
118        Ok(resp)
119    }
120    fn rebase(&self, client: &ReqwestClient, api_server: &Url) -> Result<()> {
121        let rebase_uri = format!(
122            "api/v4/projects/{}/merge_requests/{}/rebase",
123            self.project_id, self.iid
124        );
125        let rebase_mr_url = api_server.join(&rebase_uri)?;
126        let message = RebaseBranch::from(self);
127        let _resp = client.put(rebase_mr_url.as_str()).json(&message).send()?;
128        Ok(())
129    }
130    pub fn create(client: &Client, mr: &CreateMergeRequest) -> Result<MergeRequest> {
131        //! POST /projects/:id/merge_requests
132        let api_path = {
133            let project_name_or_id =
134                utf8_percent_encode(&mr.project_id, PATH_SEGMENT_ENCODE_SET).to_string();
135            format!("api/v4/projects/{}/merge_requests", project_name_or_id)
136        };
137        let response = client.post_json(&api_path, &mr)?;
138        let de = &mut serde_json::Deserializer::from_reader(response);
139        let result: Result<MergeRequest, _> = serde_path_to_error::deserialize(de);
140        match result {
141            Ok(mr) => Ok(mr),
142            Err(err) => {
143                error!("Path: {}", err.path().to_string());
144                error!("Body: {:?}", client.post_json(&api_path, &mr)?.bytes()?);
145                Err(err.into_inner().into())
146            }
147        }
148    }
149    pub fn single(client: &Client, project: &str, mr_iid: usize) -> Result<MergeRequest> {
150        //! `GET /projects/:id/merge_requests/:mr_iid`
151        let api_path = {
152            let project_name_or_id =
153                utf8_percent_encode(project, PATH_SEGMENT_ENCODE_SET).to_string();
154            format!(
155                "api/v4/projects/{}/merge_requests/{}",
156                project_name_or_id, mr_iid
157            )
158        };
159        let response = client.get(&api_path)?;
160        let de = &mut serde_json::Deserializer::from_reader(response);
161        let result: Result<MergeRequest, _> = serde_path_to_error::deserialize(de);
162        match result {
163            Ok(mr) => Ok(mr),
164            Err(err) => {
165                dbg!(&api_path);
166                dbg!(err.path().to_string());
167                dbg!(client.get(&api_path)?.text()?);
168                Err(err.into_inner().into())
169            }
170        }
171    }
172    pub fn list(
173        client: &Client,
174        project: &Option<String>,
175        state: &MergeRequestState,
176        labels: &Option<String>,
177        milestone: &Option<String>,
178        source_branch: &Option<String>,
179        limit: usize,
180    ) -> Result<Vec<MergeRequest>> {
181        let prefix = if let Some(aproject) = project {
182            let project_name_or_id =
183                utf8_percent_encode(aproject, PATH_SEGMENT_ENCODE_SET).to_string();
184            format!("api/v4/projects/{}", project_name_or_id)
185        } else {
186            "api/v4".to_string()
187        };
188        let scope = {
189            if project.is_some() {
190                "all"
191            } else {
192                "assigned_to_me" // avoid 500 error for too broad queries
193            }
194        };
195        let mut api_path = format!(
196            "{}/merge_requests?state={}&scope={}",
197            prefix,
198            state.name(),
199            scope
200        );
201        if let Some(labels) = labels {
202            api_path = format!("{}&labels={}", api_path, labels);
203        }
204        if let Some(milestone) = milestone {
205            api_path = format!("{}&milestone={}", api_path, milestone);
206        }
207        if let Some(source_branch) = source_branch {
208            api_path = format!("{}&source_branch={}", api_path, source_branch);
209        }
210        debug!("Ready to query {:?}", api_path);
211        let mut all_merge_requests: Vec<MergeRequest> = vec![];
212        for merge_requests in client.get_paginated(&api_path) {
213            let merge_requests = merge_requests?;
214            let de = &mut serde_json::Deserializer::from_reader(merge_requests);
215            let result: Result<Vec<MergeRequest>, _> = serde_path_to_error::deserialize(de);
216            let merge_requests = match result {
217                Ok(merge_requests) => merge_requests,
218                Err(err) => {
219                    dbg!(&api_path);
220                    dbg!(err.path().to_string());
221                    dbg!(client.get(&api_path)?.text()?);
222                    return Err(err.into_inner().into());
223                }
224            };
225            all_merge_requests.extend(merge_requests.into_iter().map(|mut mr| {
226                let main_issue =
227                    Issue::issue_from_branch(client, &mr.project_id.to_string(), &mr.source_branch)
228                        .transpose()
229                        .ok()
230                        .flatten();
231                mr.main_issue = main_issue;
232                mr
233            }));
234            if all_merge_requests.len() >= limit {
235                break;
236            }
237        }
238        Ok(all_merge_requests)
239    }
240}
241
242#[derive(Debug)]
243pub enum MergeResult {
244    RebaseInProgress(MergeRequestDetails),
245    Merged(MergeRequest),
246    Rebasing(MergeRequest),
247    NothingToDo,
248}
249
250/// Try to merge the given MR, optionally rebasing and waiting for pipeline completition.
251pub fn try_to_merge(
252    client: &ReqwestClient,
253    api_server: &Url,
254    mr: MergeRequest,
255) -> Result<MergeResult> {
256    let details = mr.infos(client, api_server)?;
257    debug!("Trying to merge {:?}", details);
258    // The first rebase_in_progress is a signal this is not the right time to do anything
259    if Some(true) == details.rebase_in_progress {
260        info!("Rebasing MR {:?}, need to wait", details);
261        return Ok(MergeResult::RebaseInProgress(details));
262    }
263    if let Some(pipeline) = details.pipeline {
264        // The first successful pipeline is the first candidate to be accepted
265        if pipeline.status == "success" {
266            mr.auto_remove_branch(client, api_server)?;
267            // We may need to rebase, but we have no info about it from the API
268            // so we try to accept
269            debug!("----- Try to accept");
270            if let Ok(amr) = mr.accept(client, api_server) {
271                info!("Going to merge MR {:?}", amr);
272                // After the merge all the queue will need to be rebased
273                return Ok(MergeResult::Merged(amr));
274            } else {
275                // We may need to rebase
276                debug!("----- About to rebase");
277                mr.rebase(client, api_server)?;
278                return Ok(MergeResult::Rebasing(mr));
279            }
280        }
281    }
282    return Ok(MergeResult::NothingToDo);
283}
284
285pub fn mergebot(
286    client: &Client,
287    project: &Option<String>,
288    merge_labels: &str,
289    merge_emoji: &str,
290) -> Result<()> {
291    let resp = MergeRequest::list_ready_to_merge(&client, project, &merge_labels, &merge_emoji)?;
292    // List all the MRs with the requested label and reaction, those are the candidates to be merged.
293    // Candidates MRs are those than can be merged without conflicts. The TL is supposed to manually review
294    // them and then to add a marker emoji. Once an MR has both markers, the label from the reviewer and the
295    // emoji from the TL, we can rebase the branch, push and accept the MR if the pipeline passes. Till there
296    // is an accepted MR with a running pipeline, we need to wait the branch to be merged in the master to
297    // rebase the next one.
298    if resp.is_empty() {
299        info!("No MR marked for merge");
300        return Ok(());
301    }
302    let mut already_rebased = HashSet::new();
303    for mr in resp.iter() {
304        let details = mr.infos(&client.client, &client.api_server)?;
305        if let Some(pipeline) = details.pipeline {
306            if pipeline.status == "pending" || pipeline.status == "running" {
307                info!("Some pipeline is running, not a good time to do anything");
308                return Ok(());
309            }
310        }
311        // ???: is missing like diverged_commits_count = 0 ?
312        if details.diverged_commits_count.unwrap_or(0) == 0 {
313            already_rebased.insert(details.iid);
314        }
315    }
316    for mr in resp.into_iter().rev() {
317        // We prefer any already rebased MR in the list
318        // TODO: allow some priority label to merge hot-fixes
319        if already_rebased.is_empty() || already_rebased.contains(&mr.iid) {
320            let result = try_to_merge(&client.client, &client.api_server, mr)?;
321            match result {
322                MergeResult::NothingToDo => {
323                    debug!("Check next MR...");
324                }
325                _ => {
326                    info!("Some action ongoing, exiting {:?}", result);
327                    break;
328                }
329            }
330        }
331    }
332    Ok(())
333}