use super::{PrInfo, Provider, RepoInfo, RepoMeta}; use anyhow::{Context, Result}; use async_trait::async_trait; use reqwest::{header, Client, Response}; use serde_json::{json, Value}; pub struct GitLabProvider { client: Client, namespace: String, base_url: String, } impl GitLabProvider { pub fn new(base_url: &str, namespace: &str, token: &str) -> Result { let mut headers = header::HeaderMap::new(); headers.insert( "PRIVATE-TOKEN", header::HeaderValue::from_str(token)?, ); headers.insert( header::USER_AGENT, header::HeaderValue::from_static("fxg/0.1"), ); let client = Client::builder().default_headers(headers).build()?; Ok(Self { client, namespace: namespace.to_string(), base_url: base_url.trim_end_matches('/').to_string(), }) } fn url(&self, path: &str) -> String { format!("{}/{}", self.base_url, path.trim_start_matches('/')) } fn repo_id(&self, repo: &str) -> String { // Percent-encode "namespace/repo" for use in URL path segments let raw = format!("{}/{}", self.namespace, repo); raw.replace('/', "%2F") } async fn check(resp: Response) -> Result { let status = resp.status(); let text = resp.text().await?; if status.is_success() { Ok(serde_json::from_str(&text).unwrap_or(Value::Null)) } else { anyhow::bail!("GitLab API {} — {}", status, text) } } } #[async_trait] impl Provider for GitLabProvider { async fn create_repo(&self, name: &str, private: bool, description: &str) -> Result { let visibility = if private { "private" } else { "public" }; let body = json!({ "name": name, "visibility": visibility, "description": description, "namespace_path": self.namespace, }); let resp = self.client.post(self.url("projects")).json(&body).send().await?; let v = Self::check(resp).await.context("create_repo")?; Ok(RepoInfo { name: v["name"].as_str().unwrap_or(name).to_string(), url: v["web_url"].as_str().unwrap_or("").to_string(), ssh_url: v["ssh_url_to_repo"].as_str().unwrap_or("").to_string(), }) } async fn delete_repo(&self, name: &str) -> Result<()> { let resp = self.client .delete(self.url(&format!("projects/{}", self.repo_id(name)))) .send().await?; Self::check(resp).await.context("delete_repo")?; Ok(()) } async fn set_topics(&self, repo: &str, topics: &[String]) -> Result<()> { let body = json!({ "topics": topics }); let resp = self.client .put(self.url(&format!("projects/{}", self.repo_id(repo)))) .json(&body).send().await?; Self::check(resp).await.context("set_topics")?; Ok(()) } async fn create_branch(&self, repo: &str, branch: &str, from: &str) -> Result<()> { let body = json!({ "branch": branch, "ref": from }); let resp = self.client .post(self.url(&format!("projects/{}/repository/branches", self.repo_id(repo)))) .json(&body).send().await?; Self::check(resp).await.context("create_branch")?; Ok(()) } async fn create_pull_request( &self, repo: &str, title: &str, body: &str, head: &str, base: &str, ) -> Result { let payload = json!({ "title": title, "description": body, "source_branch": head, "target_branch": base, }); let resp = self.client .post(self.url(&format!("projects/{}/merge_requests", self.repo_id(repo)))) .json(&payload).send().await?; let v = Self::check(resp).await.context("create_pull_request")?; Ok(PrInfo { number: v["iid"].as_u64().unwrap_or(0), url: v["web_url"].as_str().unwrap_or("").to_string(), }) } async fn request_reviewers( &self, repo: &str, pr_number: u64, reviewers: &[String], ) -> Result<()> { // Resolve usernames to GitLab user IDs let mut reviewer_ids: Vec = Vec::new(); for username in reviewers { let resp = self.client .get(self.url(&format!("users?username={}", username))) .send().await?; if let Ok(v) = Self::check(resp).await { if let Some(id) = v.as_array() .and_then(|a| a.first()) .and_then(|u| u["id"].as_u64()) { reviewer_ids.push(id); } } } if !reviewer_ids.is_empty() { let body = json!({ "reviewer_ids": reviewer_ids }); let resp = self.client .put(self.url(&format!("projects/{}/merge_requests/{}", self.repo_id(repo), pr_number))) .json(&body).send().await?; Self::check(resp).await.context("request_reviewers")?; } Ok(()) } async fn add_labels( &self, repo: &str, pr_number: u64, labels: &[String], ) -> Result<()> { let body = json!({ "add_labels": labels.join(",") }); let resp = self.client .put(self.url(&format!("projects/{}/merge_requests/{}", self.repo_id(repo), pr_number))) .json(&body).send().await?; Self::check(resp).await.context("add_labels")?; Ok(()) } async fn add_collaborator(&self, repo: &str, user: &str, role: &str) -> Result<()> { // GitLab access levels: 30=developer, 40=maintainer, 50=owner let level: u8 = match role { "read" => 20, "write" => 30, _ => 40 }; let body = json!({ "user_id": user, "access_level": level }); let resp = self.client .post(self.url(&format!("projects/{}/members", self.repo_id(repo)))) .json(&body).send().await?; Self::check(resp).await.context("add_collaborator")?; Ok(()) } async fn create_release( &self, repo: &str, tag: &str, title: &str, body: &str, _draft: bool, ) -> Result { let payload = json!({ "tag_name": tag, "name": title, "description": body }); let resp = self.client .post(self.url(&format!("projects/{}/releases", self.repo_id(repo)))) .json(&payload).send().await?; let v = Self::check(resp).await.context("create_release")?; Ok(v["_links"]["self"].as_str().unwrap_or("").to_string()) } async fn repo_meta(&self, repo: &str) -> Result { let resp = self.client .get(self.url(&format!("projects/{}", self.repo_id(repo)))) .send().await?; let v = Self::check(resp).await.context("repo_meta")?; Ok(RepoMeta { open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32, open_prs: v["merge_requests_count"].as_u64().unwrap_or(0) as u32, stars: v["star_count"].as_u64().unwrap_or(0) as u32, }) } async fn whoami(&self) -> Result { let resp = self.client.get(self.url("user")).send().await?; let v = Self::check(resp).await.context("whoami")?; Ok(v["username"].as_str().unwrap_or("unknown").to_string()) } }