This commit is contained in:
dualfroz committed 2026-06-10 12:41:14 +02:00
commit 4cf726709f
36 files changed
+8617

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
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};
/// Gitea / Forgejo REST API v1 provider.
pub struct GiteaProvider {
client: Client,
owner: String,
base_url: String, // e.g. "https://gitea.example.com/api/v1"
}
impl GiteaProvider {
pub fn new(base_url: &str, owner: &str, token: &str) -> Result<Self> {
let mut headers = header::HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_str(&format!("token {}", token))?,
);
headers.insert(header::USER_AGENT, header::HeaderValue::from_static("fxg/0.1"));
let client = Client::builder().default_headers(headers).build()?;
Ok(Self {
client,
owner: owner.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('/'))
}
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!("Gitea API {} — {}", status, text)
}
}
}
#[async_trait]
impl Provider for GiteaProvider {
async fn create_repo(&self, name: &str, private: bool, description: &str) -> Result<RepoInfo> {
let body = json!({ "name": name, "private": private, "description": description, "auto_init": false });
let resp = self.client.post(self.url("user/repos")).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["html_url"].as_str().unwrap_or("").to_string(),
ssh_url: v["ssh_url"].as_str().unwrap_or("").to_string(),
})
}
async fn delete_repo(&self, name: &str) -> Result<()> {
let resp = self.client
.delete(self.url(&format!("repos/{}/{}", self.owner, 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!("repos/{}/{}/topics", self.owner, 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!({ "new_branch_name": branch, "old_branch_name": from });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/branches", self.owner, 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, "body": body, "head": head, "base": base });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/pulls", self.owner, repo)))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_pull_request")?;
Ok(PrInfo {
number: v["number"].as_u64().unwrap_or(0),
url: v["html_url"].as_str().unwrap_or("").to_string(),
})
}
async fn request_reviewers(
&self, repo: &str, pr_number: u64, reviewers: &[String],
) -> Result<()> {
let body = json!({ "reviewers": reviewers });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/pulls/{}/requested_reviewers", self.owner, repo, pr_number)))
.json(&body).send().await?;
Self::check(resp).await.context("request_reviewers")?;
Ok(())
}
async fn add_labels(
&self, repo: &str, issue_number: u64, labels: &[String],
) -> Result<()> {
// Fetch existing labels to find IDs; create any that are missing
let list_resp = self.client
.get(self.url(&format!("repos/{}/{}/labels", self.owner, repo)))
.send().await?;
let existing: Vec<(u64, String)> = if list_resp.status().is_success() {
list_resp.json::<serde_json::Value>().await
.ok()
.and_then(|v| v.as_array().cloned())
.unwrap_or_default()
.iter()
.filter_map(|l| l["id"].as_u64().zip(l["name"].as_str().map(|s| s.to_string())))
.collect()
} else {
vec![]
};
let mut ids: Vec<u64> = Vec::new();
for label in labels {
if let Some((id, _)) = existing.iter().find(|(_, n)| n == label) {
ids.push(*id);
} else {
let body = json!({ "name": label, "color": "#0075ca" });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/labels", self.owner, repo)))
.json(&body).send().await?;
if let Ok(v) = Self::check(resp).await {
if let Some(id) = v["id"].as_u64() { ids.push(id); }
}
}
}
if !ids.is_empty() {
let body = json!({ "labels": ids });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/issues/{}/labels", self.owner, repo, issue_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<()> {
let permission = match role { "read" => "read", "admin" => "admin", _ => "write" };
let body = json!({ "permission": permission });
let resp = self.client
.put(self.url(&format!("repos/{}/{}/collaborators/{}", self.owner, repo, user)))
.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, "body": body, "draft": draft });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/releases", self.owner, repo)))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_release")?;
Ok(v["html_url"].as_str().unwrap_or("").to_string())
}
async fn repo_meta(&self, repo: &str) -> Result<RepoMeta> {
let resp = self.client
.get(self.url(&format!("repos/{}/{}", self.owner, 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["open_pr_counter"].as_u64().unwrap_or(0) as u32,
stars: v["stars_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["login"].as_str().unwrap_or("unknown").to_string())
}
}
+182
View File
@@ -0,0 +1,182 @@
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 GitHubProvider {
client: Client,
owner: String,
base_url: String,
}
impl GitHubProvider {
pub fn new(base_url: &str, owner: &str, token: &str) -> Result<Self> {
let mut headers = header::HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_str(&format!("Bearer {}", token))?,
);
headers.insert(
header::ACCEPT,
header::HeaderValue::from_static("application/vnd.github+json"),
);
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static("fxg/0.1"),
);
headers.insert(
"X-GitHub-Api-Version",
header::HeaderValue::from_static("2022-11-28"),
);
let client = Client::builder().default_headers(headers).build()?;
Ok(Self { client, owner: owner.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('/'))
}
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!("GitHub API {} — {}", status, text)
}
}
}
#[async_trait]
impl Provider for GitHubProvider {
async fn create_repo(&self, name: &str, private: bool, description: &str) -> Result<RepoInfo> {
let body = json!({ "name": name, "private": private, "description": description });
let resp = self.client.post(self.url("user/repos")).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["html_url"].as_str().unwrap_or("").to_string(),
ssh_url: v["ssh_url"].as_str().unwrap_or("").to_string(),
})
}
async fn delete_repo(&self, name: &str) -> Result<()> {
let resp = self.client
.delete(self.url(&format!("repos/{}/{}", self.owner, name)))
.send().await?;
Self::check(resp).await.context("delete_repo")?;
Ok(())
}
async fn set_topics(&self, repo: &str, topics: &[String]) -> Result<()> {
let body = json!({ "names": topics });
let resp = self.client
.put(self.url(&format!("repos/{}/{}/topics", self.owner, 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<()> {
// Resolve 'from' to a SHA first
let ref_url = self.url(&format!("repos/{}/{}/git/ref/heads/{}", self.owner, repo, from));
let resp = self.client.get(&ref_url).send().await?;
let v = Self::check(resp).await.context("resolve ref")?;
let sha = v["object"]["sha"].as_str()
.ok_or_else(|| anyhow::anyhow!("could not resolve SHA for {}", from))?
.to_string();
let body = json!({ "ref": format!("refs/heads/{}", branch), "sha": sha });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/git/refs", self.owner, 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, "body": body, "head": head, "base": base });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/pulls", self.owner, repo)))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_pull_request")?;
Ok(PrInfo {
number: v["number"].as_u64().unwrap_or(0),
url: v["html_url"].as_str().unwrap_or("").to_string(),
})
}
async fn add_collaborator(&self, repo: &str, user: &str, role: &str) -> Result<()> {
let body = json!({ "permission": role });
let resp = self.client
.put(self.url(&format!("repos/{}/{}/collaborators/{}", self.owner, repo, user)))
.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, "body": body, "draft": draft });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/releases", self.owner, repo)))
.json(&payload).send().await?;
let v = Self::check(resp).await.context("create_release")?;
Ok(v["html_url"].as_str().unwrap_or("").to_string())
}
async fn repo_meta(&self, repo: &str) -> Result<RepoMeta> {
let resp = self.client
.get(self.url(&format!("repos/{}/{}", self.owner, repo)))
.send().await?;
let v = Self::check(resp).await.context("repo_meta")?;
let pr_resp = self.client
.get(self.url(&format!("repos/{}/{}/pulls?state=open&per_page=100", self.owner, repo)))
.send().await?;
let open_prs = if pr_resp.status().is_success() {
let pr_list: serde_json::Value = pr_resp.json().await.unwrap_or(serde_json::Value::Array(vec![]));
pr_list.as_array().map(|a| a.len() as u32).unwrap_or(0)
} else {
0
};
Ok(RepoMeta {
open_issues: v["open_issues_count"].as_u64().unwrap_or(0) as u32,
open_prs,
stars: v["stargazers_count"].as_u64().unwrap_or(0) as u32,
})
}
async fn request_reviewers(
&self, repo: &str, pr_number: u64, reviewers: &[String],
) -> Result<()> {
let body = json!({ "reviewers": reviewers });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/pulls/{}/requested_reviewers", self.owner, repo, pr_number)))
.json(&body).send().await?;
Self::check(resp).await.context("request_reviewers")?;
Ok(())
}
async fn add_labels(
&self, repo: &str, issue_number: u64, labels: &[String],
) -> Result<()> {
let body = json!({ "labels": labels });
let resp = self.client
.post(self.url(&format!("repos/{}/{}/issues/{}/labels", self.owner, repo, issue_number)))
.json(&body).send().await?;
Self::check(resp).await.context("add_labels")?;
Ok(())
}
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["login"].as_str().unwrap_or("unknown").to_string())
}
}
+193
View File
@@ -0,0 +1,193 @@
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())
}
}
+123
View File
@@ -0,0 +1,123 @@
mod gitea;
mod github;
mod gitlab;
pub use gitea::GiteaProvider;
pub use github::GitHubProvider;
pub use gitlab::GitLabProvider;
use crate::auth::Account;
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RepoInfo {
pub name: String,
pub url: String,
pub ssh_url: String,
}
#[derive(Debug, Clone)]
pub struct PrInfo {
pub number: u64,
pub url: String,
}
#[derive(Debug, Clone, Default)]
#[allow(dead_code)]
pub struct RepoMeta {
pub open_issues: u32,
pub open_prs: u32,
pub stars: u32,
}
#[async_trait]
#[allow(dead_code)]
pub trait Provider: Send + Sync {
/// Create a new remote repository.
async fn create_repo(
&self, name: &str, private: bool, description: &str,
) -> Result<RepoInfo>;
/// Delete a remote repository.
async fn delete_repo(&self, name: &str) -> Result<()>;
/// Replace the topic list on a repository.
async fn set_topics(&self, repo: &str, topics: &[String]) -> Result<()>;
/// Create a branch from an existing ref.
async fn create_branch(&self, repo: &str, branch: &str, from: &str) -> Result<()>;
/// Open a pull-request / merge-request.
async fn create_pull_request(
&self, repo: &str, title: &str, body: &str, head: &str, base: &str,
) -> Result<PrInfo>;
/// Request reviewers for an open pull request / merge request.
async fn request_reviewers(
&self, _repo: &str, _pr_number: u64, _reviewers: &[String],
) -> Result<()> {
Ok(())
}
/// Add labels to an open pull request / issue.
async fn add_labels(
&self, _repo: &str, _issue_number: u64, _labels: &[String],
) -> Result<()> {
Ok(())
}
/// Add a collaborator to a repository.
async fn add_collaborator(&self, repo: &str, user: &str, role: &str) -> Result<()>;
/// Create a release tag with release notes.
async fn create_release(
&self, repo: &str, tag: &str, title: &str, body: &str, draft: bool,
) -> Result<String>;
/// Fetch basic metadata (issues, PRs, stars).
async fn repo_meta(&self, repo: &str) -> Result<RepoMeta>;
/// Verify the token works, return the authenticated username.
async fn whoami(&self) -> Result<String>;
}
/// Build a boxed `Provider` from an `Account`.
/// The token must already be present in the keyring.
pub fn make_provider(account: &Account) -> Result<Box<dyn Provider>> {
let token = crate::auth::get_token(&account.id())?;
match account.provider.as_str() {
"github" => {
let base = account.base_url.as_deref()
.unwrap_or("https://api.github.com");
Ok(Box::new(GitHubProvider::new(base, &account.username, &token)?))
}
"gitlab" => {
let base = account.base_url.as_deref()
.unwrap_or("https://gitlab.com/api/v4");
Ok(Box::new(GitLabProvider::new(base, &account.username, &token)?))
}
"gitea" | "forgejo" => {
let base = account.base_url.as_deref()
.ok_or_else(|| anyhow::anyhow!("gitea/forgejo requires base_url in account config"))?;
let api_base = if base.ends_with("/api/v1") { base.to_string() } else { format!("{}/api/v1", base.trim_end_matches('/')) };
Ok(Box::new(GiteaProvider::new(&api_base, &account.username, &token)?))
}
other => anyhow::bail!("unsupported provider: {}", other),
}
}
/// Resolve the default account for a given provider name (or the global default).
pub fn default_account<'a>(
accounts: &'a [crate::auth::Account],
provider_hint: Option<&str>,
) -> Option<&'a crate::auth::Account> {
if let Some(p) = provider_hint {
accounts.iter().find(|a| a.provider == p && a.is_default)
.or_else(|| accounts.iter().find(|a| a.provider == p))
} else {
accounts.iter().find(|a| a.is_default)
.or_else(|| accounts.first())
}
}