Files
vulp/src/providers/gitlab.rs
T
2026-06-10 12:41:14 +02:00

194 lines
7.3 KiB
Rust

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<Self> {
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<Value> {
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<RepoInfo> {
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<PrInfo> {
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<u64> = 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<String> {
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<RepoMeta> {
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<String> {
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())
}
}