ferrflow 7.13.2

Universal semantic versioning for monorepos and classic repos
Documentation
use anyhow::{Context, Result};

use super::{Forge, MergeRequestResult, ReleaseResult};
use crate::error_code::{self, ErrorCodeExt};

const PER_PAGE: u32 = 50;

const MAX_PAGES: u32 = 100;

pub struct GiteaForge {
    pub token: String,
    pub slug: String,
    pub api_base: String,
    pub agent: ureq::Agent,
}

impl GiteaForge {
    fn paginated_json_array(&self, base_url: &str, what: &str) -> Result<Vec<serde_json::Value>> {
        let mut all = Vec::new();
        for page in 1..=MAX_PAGES {
            let url = format!("{base_url}?limit={PER_PAGE}&page={page}");
            let body: serde_json::Value = self
                .agent
                .get(&url)
                .header("Authorization", &format!("token {}", self.token))
                .header("User-Agent", "ferrflow")
                .call()
                .with_context(|| format!("Failed to list {what}"))?
                .body_mut()
                .read_json()
                .with_context(|| format!("Failed to parse {what} response"))?;
            let page_items = match body.as_array() {
                Some(arr) if !arr.is_empty() => arr.clone(),
                _ => return Ok(all),
            };
            let len = page_items.len();
            all.extend(page_items);
            if (len as u32) < PER_PAGE {
                return Ok(all);
            }
        }
        Ok(all)
    }
}

impl Forge for GiteaForge {
    fn create_release(
        &self,
        tag: &str,
        body: &str,
        prerelease: bool,
        draft: bool,
    ) -> Result<ReleaseResult> {
        let url = format!("{}/repos/{}/releases", self.api_base, self.slug);

        let payload = serde_json::json!({
            "tag_name": tag,
            "name": tag,
            "body": body,
            "draft": draft,
            "prerelease": prerelease,
        });
        let response: serde_json::Value = self
            .agent
            .post(&url)
            .header("Authorization", &format!("token {}", self.token))
            .header("User-Agent", "ferrflow")
            .send_json(payload)
            .with_context(|| format!("Failed to create Gitea release for {tag}"))
            .error_code(error_code::GITEA_CREATE_RELEASE)?
            .body_mut()
            .read_json()
            .unwrap_or(serde_json::Value::Null);

        Ok(ReleaseResult {
            id: response["id"].as_u64(),
            url: response["html_url"].as_str().map(str::to_string),
        })
    }

    fn find_draft_release(&self, tag: &str) -> Result<Option<u64>> {
        let base_url = format!("{}/repos/{}/releases", self.api_base, self.slug);
        let releases = self
            .paginated_json_array(&base_url, "Gitea releases")
            .error_code(error_code::GITEA_LIST_RELEASES)?;
        for release in releases {
            if release["draft"].as_bool() == Some(true)
                && release["tag_name"].as_str() == Some(tag)
                && let Some(id) = release["id"].as_u64()
            {
                return Ok(Some(id));
            }
        }
        Ok(None)
    }

    fn publish_release(&self, release_id: u64) -> Result<()> {
        let url = format!(
            "{}/repos/{}/releases/{release_id}",
            self.api_base, self.slug
        );

        self.agent
            .patch(&url)
            .header("Authorization", &format!("token {}", self.token))
            .header("User-Agent", "ferrflow")
            .send_json(serde_json::json!({ "draft": false }))
            .with_context(|| format!("Failed to publish Gitea release {release_id}"))
            .error_code(error_code::GITEA_PUBLISH_RELEASE)?;

        Ok(())
    }

    fn create_merge_request(
        &self,
        _head: &str,
        _base: &str,
        _title: &str,
        _body: &str,
    ) -> Result<MergeRequestResult> {
        anyhow::bail!(
            "Pull-request mode is not yet supported on Gitea/Forgejo. \
             FerrFlow can create releases; PR-based release flow is tracked in FerrLabs/FerrFlow#499."
        )
    }

    fn enable_auto_merge(&self, _mr: &MergeRequestResult) -> Result<()> {
        anyhow::bail!(
            "Auto-merge is not yet supported on Gitea/Forgejo (PR mode). See FerrLabs/FerrFlow#499."
        )
    }

    fn mr_noun(&self) -> &'static str {
        "PR"
    }

    fn release_noun(&self) -> &'static str {
        "Gitea Release"
    }

    fn find_comment(&self, pr_id: u64, marker: &str) -> Result<Option<u64>> {
        let base_url = format!(
            "{}/repos/{}/issues/{}/comments",
            self.api_base, self.slug, pr_id
        );
        let comments = self.paginated_json_array(&base_url, "issue comments")?;
        for comment in comments {
            if let Some(body) = comment["body"].as_str()
                && body.contains(marker)
                && let Some(id) = comment["id"].as_u64()
            {
                return Ok(Some(id));
            }
        }
        Ok(None)
    }

    fn create_comment(&self, pr_id: u64, body: &str) -> Result<()> {
        let url = format!(
            "{}/repos/{}/issues/{}/comments",
            self.api_base, self.slug, pr_id
        );
        self.agent
            .post(&url)
            .header("Authorization", &format!("token {}", self.token))
            .header("User-Agent", "ferrflow")
            .send_json(serde_json::json!({ "body": body }))
            .with_context(|| "Failed to create issue comment")?;
        Ok(())
    }

    fn update_comment(&self, _pr_id: u64, comment_id: u64, body: &str) -> Result<()> {
        let url = format!(
            "{}/repos/{}/issues/comments/{}",
            self.api_base, self.slug, comment_id
        );
        self.agent
            .patch(&url)
            .header("Authorization", &format!("token {}", self.token))
            .header("User-Agent", "ferrflow")
            .send_json(serde_json::json!({ "body": body }))
            .with_context(|| "Failed to update issue comment")?;
        Ok(())
    }

    fn find_open_pr(&self, _head: &str, _base: &str) -> Result<Option<u64>> {
        Ok(None)
    }

    fn update_merge_request(
        &self,
        _id: u64,
        _title: &str,
        _body: &str,
    ) -> Result<MergeRequestResult> {
        anyhow::bail!(
            "Pull-request mode is not yet supported on Gitea/Forgejo. \
             FerrFlow can create releases; PR-based release flow is tracked in FerrLabs/FerrFlow#499."
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_forge() -> GiteaForge {
        GiteaForge {
            token: "test-token".to_string(),
            slug: "owner/repo".to_string(),
            api_base: "https://codeberg.org/api/v1".to_string(),
            agent: ureq::Agent::new_with_defaults(),
        }
    }

    #[test]
    fn release_noun_is_gitea_release() {
        assert_eq!(make_forge().release_noun(), "Gitea Release");
    }

    #[test]
    fn api_base_uses_v1_prefix() {
        assert_eq!(make_forge().api_base, "https://codeberg.org/api/v1");
    }

    #[test]
    fn create_release_payload_structure() {
        let payload = serde_json::json!({
            "tag_name": "v1.0.0",
            "name": "v1.0.0",
            "body": "Release notes",
            "draft": true,
            "prerelease": false,
        });
        assert_eq!(payload["tag_name"], "v1.0.0");
        assert_eq!(payload["draft"], true);
        assert_eq!(payload["prerelease"], false);
    }

    #[test]
    fn find_draft_release_matches_exact_tag() {
        let releases: serde_json::Value = serde_json::json!([
            {"id": 10, "tag_name": "v2.0.0", "draft": true},
            {"id": 20, "tag_name": "v2.0.0-beta.1", "draft": true},
        ]);
        let found = releases
            .as_array()
            .unwrap()
            .iter()
            .find(|r| {
                r["draft"].as_bool() == Some(true) && r["tag_name"].as_str() == Some("v2.0.0")
            })
            .and_then(|r| r["id"].as_u64());
        assert_eq!(found, Some(10));
    }

    #[test]
    fn find_draft_release_ignores_published() {
        let releases: serde_json::Value = serde_json::json!([
            {"id": 1, "tag_name": "v1.0.0", "draft": false},
        ]);
        let found = releases.as_array().unwrap().iter().find(|r| {
            r["draft"].as_bool() == Some(true) && r["tag_name"].as_str() == Some("v1.0.0")
        });
        assert!(found.is_none());
    }

    #[test]
    fn pr_mode_is_not_supported() {
        let forge = make_forge();
        assert!(forge.create_merge_request("h", "b", "t", "body").is_err());
        assert!(
            forge
                .enable_auto_merge(&MergeRequestResult {
                    id: 1,
                    auto_merge_key: String::new(),
                })
                .is_err()
        );
    }
}