init
This commit is contained in:
commit
4cf726709f
36 files changed
+8617
No files matched your search
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user