gitlab-butler-lib 0.9.1

Support library for [`gitlab-butler`](https://crates.io/crates/gitlab-butler) Gitlab cli to automate the boring stuff.
Documentation
//! ## Manage ready to merge GitLab MRs
//!
//! If in your project you prefer a semi-linear history, and you have a queue of MRs that are ready to be
//! merged, you may and up fighting for the merge and rebasing more than once each branch (the problem is more
//! evident if the project has a lengthy pipeline to wait).
//!
//! This bot automate a workflow that emerged in our team:
//!
//! 1. Developer A work on some feature
//! 2. Once done, assign the MR to developer B for the review
//! 3. Instead of merging (or rebasing) developer B adds a ~"Merge Ready" label
//! 4. The TL manages the merge queue (doing a second review on the code)
//!
//! Waiting for this bot to exist, we ended up finding the second review valuable, so we modified the bot to
//! wait for both the ~"Merge Ready" label and the TL emoji of choice.
//!
//! The new workflow is very like the one above, with a different ending:
//!
//! 1. Developer A work on some feature
//! 2. Once done, assign the MR to developer B for the review
//! 3. Instead of merging (or rebasing) developer B adds a ~"Merge Ready" label
//! 4. The TL does a second review on the code, and adds an `:octopus:` emoji
//! 5. `gitlab-butler` loops and picks the first rebased MR or triggers a rebase

use std::collections::HashSet;

use anyhow::Result;
use log::{debug, error, info};
use url::percent_encoding::{utf8_percent_encode, PATH_SEGMENT_ENCODE_SET};
use url::Url;

use crate::client::Client;
use crate::entities::*;

type ReqwestClient = reqwest::blocking::Client;

impl MergeRequest {
    pub fn list_ready_to_merge(
        client: &Client,
        project: &Option<String>,
        merge_labels: &str,
        merge_emoji: &str,
    ) -> Result<Vec<MergeRequest>> {
        let prefix = if let Some(aproject) = project {
            let project_name_or_id =
                utf8_percent_encode(aproject, PATH_SEGMENT_ENCODE_SET).to_string();
            format!("api/v4/projects/{}/merge_requests?", project_name_or_id)
        } else {
            "api/v4/merge_requests?scope=assigned_to_me&".to_string()
        };
        let api_path = format!(
            "{}state=opened&order_by=created_at&labels={}&my_reaction_emoji={}",
            prefix, merge_labels, merge_emoji
        );
        debug!("Ready to query {:?}", api_path);
        let mut merge_requests: Vec<MergeRequest> =
            client.get(&api_path)?.error_for_status()?.json()?;
        debug!("{} merge requests found", merge_requests.len());
        // TODO: We should reassign back all WIP or conflicting MRs
        merge_requests.retain(|mr| {
            debug!("{:?}", mr);
            !mr.work_in_progress
                && mr.merge_status == "can_be_merged"
                && mr.target_branch == "master"
        });
        Ok(merge_requests)
    }
    pub fn infos(&self, client: &ReqwestClient, api_server: &Url) -> Result<MergeRequestDetails> {
        let mr_uri = format!(
            "api/v4/projects/{}/merge_requests/{}",
            self.project_id, self.iid
        );
        let info_mr_uri = api_server.join(&mr_uri)?;
        let message = GetMergeRequestDetails::from(self);
        let resp = client
            .get(info_mr_uri.as_str())
            .json(&message)
            .send()?
            .json()?;
        Ok(resp)
    }
    fn auto_remove_branch(&self, client: &ReqwestClient, api_server: &Url) -> Result<MergeRequest> {
        let mr_uri = format!(
            "api/v4/projects/{}/merge_requests/{}",
            self.project_id, self.iid
        );
        let update_mr_url = api_server.join(&mr_uri)?;
        let force_merge = RemoveSourceBranch::from(self);
        debug!("  Update url: {:?} <- {:?}", update_mr_url, force_merge);
        // We may set the remove after merge option in the merge command too
        let resp = client
            .put(update_mr_url.as_str())
            .json(&force_merge)
            .send()?
            .json()?;
        Ok(resp)
    }
    pub fn pipelines(&self, client: &Client) -> Result<Vec<Pipeline>> {
        let pipelines_uri = format!(
            "api/v4/projects/{}/merge_requests/{}/pipelines",
            self.project_id, self.iid
        );
        let message = ListPipelines::from(self);
        debug!("querying pipelines: {:?} {:?}", pipelines_uri, message);
        let resp = client.get_json(&pipelines_uri, &message)?.json()?;
        Ok(resp)
    }
    fn accept(&self, client: &ReqwestClient, api_server: &Url) -> Result<MergeRequest> {
        let accept_uri = format!(
            "api/v4/projects/{}/merge_requests/{}/merge",
            self.project_id, self.iid
        );
        let accept_mr_uri = api_server.join(&accept_uri)?;
        let message = AcceptMergeRequest::from(self);
        let resp = client.put(accept_mr_uri.as_str()).json(&message).send()?;
        debug!("accept response: {:?}", resp);
        let resp = resp.json()?;
        Ok(resp)
    }
    fn rebase(&self, client: &ReqwestClient, api_server: &Url) -> Result<()> {
        let rebase_uri = format!(
            "api/v4/projects/{}/merge_requests/{}/rebase",
            self.project_id, self.iid
        );
        let rebase_mr_url = api_server.join(&rebase_uri)?;
        let message = RebaseBranch::from(self);
        let _resp = client.put(rebase_mr_url.as_str()).json(&message).send()?;
        Ok(())
    }
    pub fn create(client: &Client, mr: &CreateMergeRequest) -> Result<MergeRequest> {
        //! POST /projects/:id/merge_requests
        let api_path = {
            let project_name_or_id =
                utf8_percent_encode(&mr.project_id, PATH_SEGMENT_ENCODE_SET).to_string();
            format!("api/v4/projects/{}/merge_requests", project_name_or_id)
        };
        let response = client.post_json(&api_path, &mr)?;
        let de = &mut serde_json::Deserializer::from_reader(response);
        let result: Result<MergeRequest, _> = serde_path_to_error::deserialize(de);
        match result {
            Ok(mr) => Ok(mr),
            Err(err) => {
                error!("Path: {}", err.path().to_string());
                error!("Body: {:?}", client.post_json(&api_path, &mr)?.bytes()?);
                Err(err.into_inner().into())
            }
        }
    }
    pub fn single(client: &Client, project: &str, mr_iid: usize) -> Result<MergeRequest> {
        //! `GET /projects/:id/merge_requests/:mr_iid`
        let api_path = {
            let project_name_or_id =
                utf8_percent_encode(project, PATH_SEGMENT_ENCODE_SET).to_string();
            format!(
                "api/v4/projects/{}/merge_requests/{}",
                project_name_or_id, mr_iid
            )
        };
        let response = client.get(&api_path)?;
        let de = &mut serde_json::Deserializer::from_reader(response);
        let result: Result<MergeRequest, _> = serde_path_to_error::deserialize(de);
        match result {
            Ok(mr) => Ok(mr),
            Err(err) => {
                dbg!(&api_path);
                dbg!(err.path().to_string());
                dbg!(client.get(&api_path)?.text()?);
                Err(err.into_inner().into())
            }
        }
    }
    pub fn list(
        client: &Client,
        project: &Option<String>,
        state: &MergeRequestState,
        labels: &Option<String>,
        milestone: &Option<String>,
        source_branch: &Option<String>,
        limit: usize,
    ) -> Result<Vec<MergeRequest>> {
        let prefix = if let Some(aproject) = project {
            let project_name_or_id =
                utf8_percent_encode(aproject, PATH_SEGMENT_ENCODE_SET).to_string();
            format!("api/v4/projects/{}", project_name_or_id)
        } else {
            "api/v4".to_string()
        };
        let scope = {
            if project.is_some() {
                "all"
            } else {
                "assigned_to_me" // avoid 500 error for too broad queries
            }
        };
        let mut api_path = format!(
            "{}/merge_requests?state={}&scope={}",
            prefix,
            state.name(),
            scope
        );
        if let Some(labels) = labels {
            api_path = format!("{}&labels={}", api_path, labels);
        }
        if let Some(milestone) = milestone {
            api_path = format!("{}&milestone={}", api_path, milestone);
        }
        if let Some(source_branch) = source_branch {
            api_path = format!("{}&source_branch={}", api_path, source_branch);
        }
        debug!("Ready to query {:?}", api_path);
        let mut all_merge_requests: Vec<MergeRequest> = vec![];
        for merge_requests in client.get_paginated(&api_path) {
            let merge_requests = merge_requests?;
            let de = &mut serde_json::Deserializer::from_reader(merge_requests);
            let result: Result<Vec<MergeRequest>, _> = serde_path_to_error::deserialize(de);
            let merge_requests = match result {
                Ok(merge_requests) => merge_requests,
                Err(err) => {
                    dbg!(&api_path);
                    dbg!(err.path().to_string());
                    dbg!(client.get(&api_path)?.text()?);
                    return Err(err.into_inner().into());
                }
            };
            all_merge_requests.extend(merge_requests.into_iter().map(|mut mr| {
                let main_issue =
                    Issue::issue_from_branch(client, &mr.project_id.to_string(), &mr.source_branch)
                        .transpose()
                        .ok()
                        .flatten();
                mr.main_issue = main_issue;
                mr
            }));
            if all_merge_requests.len() >= limit {
                break;
            }
        }
        Ok(all_merge_requests)
    }
}

#[derive(Debug)]
pub enum MergeResult {
    RebaseInProgress(MergeRequestDetails),
    Merged(MergeRequest),
    Rebasing(MergeRequest),
    NothingToDo,
}

/// Try to merge the given MR, optionally rebasing and waiting for pipeline completition.
pub fn try_to_merge(
    client: &ReqwestClient,
    api_server: &Url,
    mr: MergeRequest,
) -> Result<MergeResult> {
    let details = mr.infos(client, api_server)?;
    debug!("Trying to merge {:?}", details);
    // The first rebase_in_progress is a signal this is not the right time to do anything
    if Some(true) == details.rebase_in_progress {
        info!("Rebasing MR {:?}, need to wait", details);
        return Ok(MergeResult::RebaseInProgress(details));
    }
    if let Some(pipeline) = details.pipeline {
        // The first successful pipeline is the first candidate to be accepted
        if pipeline.status == "success" {
            mr.auto_remove_branch(client, api_server)?;
            // We may need to rebase, but we have no info about it from the API
            // so we try to accept
            debug!("----- Try to accept");
            if let Ok(amr) = mr.accept(client, api_server) {
                info!("Going to merge MR {:?}", amr);
                // After the merge all the queue will need to be rebased
                return Ok(MergeResult::Merged(amr));
            } else {
                // We may need to rebase
                debug!("----- About to rebase");
                mr.rebase(client, api_server)?;
                return Ok(MergeResult::Rebasing(mr));
            }
        }
    }
    return Ok(MergeResult::NothingToDo);
}

pub fn mergebot(
    client: &Client,
    project: &Option<String>,
    merge_labels: &str,
    merge_emoji: &str,
) -> Result<()> {
    let resp = MergeRequest::list_ready_to_merge(&client, project, &merge_labels, &merge_emoji)?;
    // List all the MRs with the requested label and reaction, those are the candidates to be merged.
    // Candidates MRs are those than can be merged without conflicts. The TL is supposed to manually review
    // them and then to add a marker emoji. Once an MR has both markers, the label from the reviewer and the
    // emoji from the TL, we can rebase the branch, push and accept the MR if the pipeline passes. Till there
    // is an accepted MR with a running pipeline, we need to wait the branch to be merged in the master to
    // rebase the next one.
    if resp.is_empty() {
        info!("No MR marked for merge");
        return Ok(());
    }
    let mut already_rebased = HashSet::new();
    for mr in resp.iter() {
        let details = mr.infos(&client.client, &client.api_server)?;
        if let Some(pipeline) = details.pipeline {
            if pipeline.status == "pending" || pipeline.status == "running" {
                info!("Some pipeline is running, not a good time to do anything");
                return Ok(());
            }
        }
        // ???: is missing like diverged_commits_count = 0 ?
        if details.diverged_commits_count.unwrap_or(0) == 0 {
            already_rebased.insert(details.iid);
        }
    }
    for mr in resp.into_iter().rev() {
        // We prefer any already rebased MR in the list
        // TODO: allow some priority label to merge hot-fixes
        if already_rebased.is_empty() || already_rebased.contains(&mr.iid) {
            let result = try_to_merge(&client.client, &client.api_server, mr)?;
            match result {
                MergeResult::NothingToDo => {
                    debug!("Check next MR...");
                }
                _ => {
                    info!("Some action ongoing, exiting {:?}", result);
                    break;
                }
            }
        }
    }
    Ok(())
}