git-stk 0.7.5

Git-native stacked branch workflow helper
Documentation
use anyhow::{Context, Result};

use super::json::{
    first_json_item, optional_bool, optional_string, parse_body_field, parse_state, required_string,
};
use super::{ReviewProvider, ReviewRequest, command_output};

pub(super) struct GitLabProvider;

impl ReviewProvider for GitLabProvider {
    fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>> {
        // glab mr list only returns open merge requests by default; check
        // merged ones too so cleanup can see landed reviews.
        if let Some(review) = list_review(branch, None)? {
            return Ok(Some(review));
        }
        list_review(branch, Some("--merged"))
    }

    fn review_for_branch_including_closed(&self, branch: &str) -> Result<Option<ReviewRequest>> {
        // Open and merged take precedence: a branch resubmitted after its
        // review was closed should resolve to the fresh review.
        if let Some(review) = self.review_for_branch(branch)? {
            return Ok(Some(review));
        }
        list_review(branch, Some("--closed"))
    }

    fn create_review(&self, branch: &str, base: &str, draft: bool) -> Result<String> {
        let mut args = vec![
            "mr",
            "create",
            "--source-branch",
            branch,
            "--target-branch",
            base,
            "--fill",
        ];
        if draft {
            args.push("--draft");
        }
        command_output("glab", &args)
    }

    fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String> {
        command_output(
            "glab",
            &["mr", "update", review.id_value(), "--target-branch", base],
        )
    }

    fn review_body(&self, review: &ReviewRequest) -> Result<String> {
        let output = command_output(
            "glab",
            &["mr", "view", review.id_value(), "--output", "json"],
        )?;
        parse_body_field(&output, "description")
    }

    fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String> {
        command_output(
            "glab",
            &["mr", "update", review.id_value(), "--description", body],
        )
    }

    fn merge_review(&self, review: &ReviewRequest, strategy: &str, auto: bool) -> Result<String> {
        let mut args = vec!["mr", "merge", review.id_value()];
        match strategy {
            "rebase" => args.push("--rebase"),
            "merge" => {}
            _ => args.push("--squash"),
        }
        // glab schedules on pending pipelines by default; --auto just makes
        // the intent explicit. Either way the caller checks what happened.
        if auto {
            args.push("--auto-merge");
        }
        command_output("glab", &args)
    }

    fn wait_for_checks(&self, review: &ReviewRequest) -> Result<bool> {
        loop {
            let output = command_output(
                "glab",
                &["mr", "view", review.id_value(), "--output", "json"],
            )?;
            let value: serde_json::Value =
                serde_json::from_str(&output).context("failed to parse glab MR JSON")?;
            let status = value
                .get("head_pipeline")
                .or_else(|| value.get("pipeline"))
                .and_then(|pipeline| pipeline.get("status"))
                .and_then(serde_json::Value::as_str);

            match status {
                // No pipeline configured: nothing to wait for.
                None => return Ok(true),
                Some("success") | Some("skipped") | Some("manual") => return Ok(true),
                Some("failed") | Some("canceled") => return Ok(false),
                _ => std::thread::sleep(std::time::Duration::from_secs(10)),
            }
        }
    }

    fn mark_ready(&self, review: &ReviewRequest) -> Result<String> {
        command_output("glab", &["mr", "update", review.id_value(), "--ready"])
    }
}

fn list_review(branch: &str, state_flag: Option<&str>) -> Result<Option<ReviewRequest>> {
    let mut args = vec!["mr", "list", "--source-branch", branch];
    if let Some(flag) = state_flag {
        args.push(flag);
    }
    args.extend(["--output", "json"]);

    let output = command_output("glab", &args)?;
    parse_gitlab_review(&output)
}

fn parse_gitlab_review(output: &str) -> Result<Option<ReviewRequest>> {
    let Some(review) = first_json_item(output)? else {
        return Ok(None);
    };

    Ok(Some(ReviewRequest {
        id: format!("!{}", required_string(&review, &["iid", "id"])?),
        branch: required_string(&review, &["source_branch", "sourceBranch"])?,
        base: required_string(&review, &["target_branch", "targetBranch"])?,
        state: parse_state(&required_string(&review, &["state"])?),
        url: required_string(&review, &["web_url", "webUrl", "url"])?,
        title: optional_string(&review, "title"),
        draft: optional_bool(&review, "draft"),
    }))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::providers::{ReviewRequest, ReviewState};

    #[test]
    fn parse_gitlab_review_reads_snake_case_fields() {
        let review = parse_gitlab_review(
            r#"[{"iid":34,"state":"merged","target_branch":"feature/a","source_branch":"feature/b","web_url":"https://gitlab.com/owner/repo/-/merge_requests/34"}]"#,
        )
        .expect("parse review")
        .expect("review exists");

        assert_eq!(
            review,
            ReviewRequest {
                id: "!34".to_owned(),
                branch: "feature/b".to_owned(),
                base: "feature/a".to_owned(),
                state: ReviewState::Merged,
                url: "https://gitlab.com/owner/repo/-/merge_requests/34".to_owned(),
                title: String::new(),
                draft: false,
            }
        );
    }

    #[test]
    fn parse_gitlab_review_reads_camel_case_fields() {
        let review = parse_gitlab_review(
            r#"[{"id":34,"state":"closed","targetBranch":"feature/a","sourceBranch":"feature/b","webUrl":"https://gitlab.com/owner/repo/-/merge_requests/34"}]"#,
        )
        .expect("parse review")
        .expect("review exists");

        assert_eq!(review.id, "!34");
        assert_eq!(review.branch, "feature/b");
        assert_eq!(review.base, "feature/a");
        assert_eq!(review.state, ReviewState::Closed);
        assert_eq!(
            review.url,
            "https://gitlab.com/owner/repo/-/merge_requests/34"
        );
    }

    #[test]
    fn parse_gitlab_review_empty_array_returns_none() {
        assert_eq!(parse_gitlab_review("[]").expect("parse review"), None);
    }
}