Phases 0-1 + Sprint A: module split, CLI layer, org-refresh fix, hygiene
- LICENSE (MIT) + README truth-pass; docs/cli.md CLI contract - CLI subcommands (src/cli_cmds/) per "TUI for users, CLI for users+AI" - Hub/detail refactor: keymap dispatch, modal split, action enum, BgMsg bus - Fix: org repos were never refreshed (owner==username match); refresh now parallel — 49 repos in ~11s, surfaced 70 previously invisible PRs - Fix: 3 date bugs in detail/git.rs (format-local, Local::now, 1y month math) - Stash indicator + [m] actions, session persistence, gh CLI token fallback - Legacy UI modules removed; plan/ docs (roadmap, audit, debate, handoff)
This commit is contained in:
1 parent
5787b14880
commit
0468c032c6
100 files changed
+11614
-9840
No files matched your search
+33
-16
@@ -1,3 +1,6 @@
|
||||
//! `Provider` implementation for Gitea and Forgejo (API-compatible forks),
|
||||
//! talking to the REST API v1 directly over `reqwest`.
|
||||
|
||||
use super::{PrInfo, Provider, RepoInfo, RepoMeta};
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
@@ -12,13 +15,17 @@ pub struct GiteaProvider {
|
||||
}
|
||||
|
||||
impl GiteaProvider {
|
||||
/// Builds a client authenticated with `token`, scoped to `owner`'s repos
|
||||
/// by default. Unlike GitHub/GitLab, Gitea has no fixed default
|
||||
/// `base_url` — every instance is self-hosted, so the caller always
|
||||
/// supplies one.
|
||||
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"));
|
||||
headers.insert(header::USER_AGENT, header::HeaderValue::from_static("vulp/0.1"));
|
||||
let client = Client::builder().default_headers(headers).build()?;
|
||||
Ok(Self {
|
||||
client,
|
||||
@@ -27,10 +34,20 @@ impl GiteaProvider {
|
||||
})
|
||||
}
|
||||
|
||||
/// Joins `path` onto `base_url`, normalizing the slash between them.
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
|
||||
}
|
||||
|
||||
/// Repo path segment: names may arrive as bare ("vulp" → account owner)
|
||||
/// or fully qualified ("VulCodeCom/vulp" → used verbatim), so org-owned
|
||||
/// repos work with a personal account's token.
|
||||
fn slug(&self, repo: &str) -> String {
|
||||
if repo.contains('/') { repo.to_string() } else { format!("{}/{}", self.owner, repo) }
|
||||
}
|
||||
|
||||
/// Parses a response as JSON if the status was successful, otherwise
|
||||
/// turns the non-2xx status and body into an error.
|
||||
async fn check(resp: Response) -> Result<Value> {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
@@ -44,7 +61,7 @@ impl GiteaProvider {
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for GiteaProvider {
|
||||
async fn create_repo(&self, name: &str, private: bool, description: &str) -> Result<RepoInfo> {
|
||||
async fn create_repo(&self, name: &str, private: bool, description: &str, _owner: Option<&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")?;
|
||||
@@ -57,7 +74,7 @@ impl Provider for GiteaProvider {
|
||||
|
||||
async fn delete_repo(&self, name: &str) -> Result<()> {
|
||||
let resp = self.client
|
||||
.delete(self.url(&format!("repos/{}/{}", self.owner, name)))
|
||||
.delete(self.url(&format!("repos/{}", self.slug(name))))
|
||||
.send().await?;
|
||||
Self::check(resp).await.context("delete_repo")?;
|
||||
Ok(())
|
||||
@@ -66,7 +83,7 @@ impl Provider for GiteaProvider {
|
||||
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)))
|
||||
.put(self.url(&format!("repos/{}/topics", self.slug(repo))))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("set_topics")?;
|
||||
Ok(())
|
||||
@@ -75,7 +92,7 @@ impl Provider for GiteaProvider {
|
||||
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)))
|
||||
.post(self.url(&format!("repos/{}/branches", self.slug(repo))))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("create_branch")?;
|
||||
Ok(())
|
||||
@@ -86,7 +103,7 @@ impl Provider for GiteaProvider {
|
||||
) -> 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)))
|
||||
.post(self.url(&format!("repos/{}/pulls", self.slug(repo))))
|
||||
.json(&payload).send().await?;
|
||||
let v = Self::check(resp).await.context("create_pull_request")?;
|
||||
Ok(PrInfo {
|
||||
@@ -100,7 +117,7 @@ impl Provider for GiteaProvider {
|
||||
) -> Result<()> {
|
||||
let body = json!({ "reviewers": reviewers });
|
||||
let resp = self.client
|
||||
.post(self.url(&format!("repos/{}/{}/pulls/{}/requested_reviewers", self.owner, repo, pr_number)))
|
||||
.post(self.url(&format!("repos/{}/pulls/{}/requested_reviewers", self.slug(repo), pr_number)))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("request_reviewers")?;
|
||||
Ok(())
|
||||
@@ -111,7 +128,7 @@ impl Provider for GiteaProvider {
|
||||
) -> 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)))
|
||||
.get(self.url(&format!("repos/{}/labels", self.slug(repo))))
|
||||
.send().await?;
|
||||
let existing: Vec<(u64, String)> = if list_resp.status().is_success() {
|
||||
list_resp.json::<serde_json::Value>().await
|
||||
@@ -132,7 +149,7 @@ impl Provider for GiteaProvider {
|
||||
} else {
|
||||
let body = json!({ "name": label, "color": "#0075ca" });
|
||||
let resp = self.client
|
||||
.post(self.url(&format!("repos/{}/{}/labels", self.owner, repo)))
|
||||
.post(self.url(&format!("repos/{}/labels", self.slug(repo))))
|
||||
.json(&body).send().await?;
|
||||
if let Ok(v) = Self::check(resp).await {
|
||||
if let Some(id) = v["id"].as_u64() { ids.push(id); }
|
||||
@@ -143,7 +160,7 @@ impl Provider for GiteaProvider {
|
||||
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)))
|
||||
.post(self.url(&format!("repos/{}/issues/{}/labels", self.slug(repo), issue_number)))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("add_labels")?;
|
||||
}
|
||||
@@ -154,7 +171,7 @@ impl Provider for GiteaProvider {
|
||||
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)))
|
||||
.put(self.url(&format!("repos/{}/collaborators/{}", self.slug(repo), user)))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("add_collaborator")?;
|
||||
Ok(())
|
||||
@@ -165,7 +182,7 @@ impl Provider for GiteaProvider {
|
||||
) -> 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)))
|
||||
.post(self.url(&format!("repos/{}/releases", self.slug(repo))))
|
||||
.json(&payload).send().await?;
|
||||
let v = Self::check(resp).await.context("create_release")?;
|
||||
Ok(v["html_url"].as_str().unwrap_or("").to_string())
|
||||
@@ -173,7 +190,7 @@ impl Provider for GiteaProvider {
|
||||
|
||||
async fn repo_meta(&self, repo: &str) -> Result<RepoMeta> {
|
||||
let resp = self.client
|
||||
.get(self.url(&format!("repos/{}/{}", self.owner, repo)))
|
||||
.get(self.url(&format!("repos/{}", self.slug(repo))))
|
||||
.send().await?;
|
||||
let v = Self::check(resp).await.context("repo_meta")?;
|
||||
Ok(RepoMeta {
|
||||
@@ -194,7 +211,7 @@ impl Provider for GiteaProvider {
|
||||
async fn update_visibility(&self, repo: &str, private: bool) -> Result<()> {
|
||||
let body = json!({ "private": private });
|
||||
let resp = self.client
|
||||
.patch(self.url(&format!("repos/{}/{}", self.owner, repo)))
|
||||
.patch(self.url(&format!("repos/{}", self.slug(repo))))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("update_visibility")?;
|
||||
Ok(())
|
||||
@@ -203,7 +220,7 @@ impl Provider for GiteaProvider {
|
||||
async fn rename_repo(&self, repo: &str, new_name: &str) -> Result<()> {
|
||||
let body = json!({ "name": new_name });
|
||||
let resp = self.client
|
||||
.patch(self.url(&format!("repos/{}/{}", self.owner, repo)))
|
||||
.patch(self.url(&format!("repos/{}", self.slug(repo))))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("rename_repo")?;
|
||||
Ok(())
|
||||
@@ -212,7 +229,7 @@ impl Provider for GiteaProvider {
|
||||
async fn archive_repo(&self, repo: &str) -> Result<()> {
|
||||
let body = json!({ "archived": true });
|
||||
let resp = self.client
|
||||
.patch(self.url(&format!("repos/{}/{}", self.owner, repo)))
|
||||
.patch(self.url(&format!("repos/{}", self.slug(repo))))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("archive_repo")?;
|
||||
Ok(())
|
||||
|
||||
+125
-21
@@ -1,4 +1,8 @@
|
||||
use super::{IssueDetail, PrDetail, PrInfo, Provider, RepoInfo, RepoMeta};
|
||||
//! `Provider` implementation for GitHub (and GitHub Enterprise, via a
|
||||
//! custom `base_url`). Talks to the REST API directly over `reqwest` rather
|
||||
//! than the `gh` CLI, so it works the same whether or not `gh` is installed.
|
||||
|
||||
use super::{IssueDetail, PrDetail, PrInfo, Provider, RepoAffiliation, RepoInfo, RepoMeta};
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
use reqwest::{header, Client, Response};
|
||||
@@ -11,6 +15,9 @@ pub struct GitHubProvider {
|
||||
}
|
||||
|
||||
impl GitHubProvider {
|
||||
/// Builds a client authenticated with `token`, scoped to `owner`'s repos
|
||||
/// by default. `base_url` lets this point at a GitHub Enterprise
|
||||
/// instance instead of `api.github.com`.
|
||||
pub fn new(base_url: &str, owner: &str, token: &str) -> Result<Self> {
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.insert(
|
||||
@@ -23,20 +30,37 @@ impl GitHubProvider {
|
||||
);
|
||||
headers.insert(
|
||||
header::USER_AGENT,
|
||||
header::HeaderValue::from_static("fxg/0.1"),
|
||||
header::HeaderValue::from_static("vulp/0.1"),
|
||||
);
|
||||
headers.insert(
|
||||
"X-GitHub-Api-Version",
|
||||
header::HeaderValue::from_static("2022-11-28"),
|
||||
);
|
||||
let client = Client::builder().default_headers(headers).build()?;
|
||||
// Without a timeout, a stalled connection (flaky network, secondary
|
||||
// rate-limiting) hangs the background worker forever with no way for
|
||||
// the UI to surface an error — better to fail loud than hang silent.
|
||||
let client = Client::builder()
|
||||
.default_headers(headers)
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()?;
|
||||
Ok(Self { client, owner: owner.to_string(), base_url: base_url.trim_end_matches('/').to_string() })
|
||||
}
|
||||
|
||||
/// Joins `path` onto `base_url`, normalizing the slash between them.
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
|
||||
}
|
||||
|
||||
/// Repo path segment: names may arrive as bare ("vulp" → account owner)
|
||||
/// or fully qualified ("VulCodeCom/vulp" → used verbatim), so org-owned
|
||||
/// repos work with a personal account's token.
|
||||
fn slug(&self, repo: &str) -> String {
|
||||
if repo.contains('/') { repo.to_string() } else { format!("{}/{}", self.owner, repo) }
|
||||
}
|
||||
|
||||
/// Parses a response as JSON if the status was successful, otherwise
|
||||
/// turns the non-2xx status and body into an error — the single choke
|
||||
/// point every API call routes through so error messages stay consistent.
|
||||
async fn check(resp: Response) -> Result<Value> {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
@@ -50,9 +74,13 @@ impl GitHubProvider {
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for GitHubProvider {
|
||||
async fn create_repo(&self, name: &str, private: bool, description: &str) -> Result<RepoInfo> {
|
||||
async fn create_repo(&self, name: &str, private: bool, description: &str, owner: Option<&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 path = match owner {
|
||||
Some(org) => format!("orgs/{}/repos", org),
|
||||
None => "user/repos".to_string(),
|
||||
};
|
||||
let resp = self.client.post(self.url(&path)).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(),
|
||||
@@ -61,9 +89,49 @@ impl Provider for GitHubProvider {
|
||||
})
|
||||
}
|
||||
|
||||
async fn list_orgs(&self) -> Result<Vec<String>> {
|
||||
let resp = self.client.get(self.url("user/orgs")).send().await?;
|
||||
let v = Self::check(resp).await.context("list_orgs")?;
|
||||
Ok(v.as_array().map(|arr| {
|
||||
arr.iter().filter_map(|o| o["login"].as_str().map(str::to_string)).collect()
|
||||
}).unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn list_repos(&self, affiliation: &RepoAffiliation) -> Result<Vec<RepoInfo>> {
|
||||
let mut affs = Vec::new();
|
||||
if affiliation.owner { affs.push("owner"); }
|
||||
if affiliation.collaborator { affs.push("collaborator"); }
|
||||
if affiliation.org_member { affs.push("organization_member"); }
|
||||
if affs.is_empty() { return Ok(vec![]); }
|
||||
|
||||
let mut repos = Vec::new();
|
||||
let mut url = self.url(&format!("user/repos?affiliation={}&per_page=100", affs.join(",")));
|
||||
loop {
|
||||
let resp = self.client.get(&url).send().await?;
|
||||
let next = resp.headers().get(header::LINK)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(parse_next_link);
|
||||
let v = Self::check(resp).await.context("list_repos")?;
|
||||
if let Some(arr) = v.as_array() {
|
||||
for o in arr {
|
||||
repos.push(RepoInfo {
|
||||
name: o["name"].as_str().unwrap_or_default().to_string(),
|
||||
url: o["html_url"].as_str().unwrap_or_default().to_string(),
|
||||
ssh_url: o["ssh_url"].as_str().unwrap_or_default().to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
match next {
|
||||
Some(next_url) => url = next_url,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok(repos)
|
||||
}
|
||||
|
||||
async fn delete_repo(&self, name: &str) -> Result<()> {
|
||||
let resp = self.client
|
||||
.delete(self.url(&format!("repos/{}/{}", self.owner, name)))
|
||||
.delete(self.url(&format!("repos/{}", self.slug(name))))
|
||||
.send().await?;
|
||||
Self::check(resp).await.context("delete_repo")?;
|
||||
Ok(())
|
||||
@@ -72,7 +140,7 @@ impl Provider for GitHubProvider {
|
||||
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)))
|
||||
.put(self.url(&format!("repos/{}/topics", self.slug(repo))))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("set_topics")?;
|
||||
Ok(())
|
||||
@@ -80,7 +148,7 @@ impl Provider for GitHubProvider {
|
||||
|
||||
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 ref_url = self.url(&format!("repos/{}/git/ref/heads/{}", self.slug(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()
|
||||
@@ -89,7 +157,7 @@ impl Provider for GitHubProvider {
|
||||
|
||||
let body = json!({ "ref": format!("refs/heads/{}", branch), "sha": sha });
|
||||
let resp = self.client
|
||||
.post(self.url(&format!("repos/{}/{}/git/refs", self.owner, repo)))
|
||||
.post(self.url(&format!("repos/{}/git/refs", self.slug(repo))))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("create_branch")?;
|
||||
Ok(())
|
||||
@@ -100,7 +168,7 @@ impl Provider for GitHubProvider {
|
||||
) -> 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)))
|
||||
.post(self.url(&format!("repos/{}/pulls", self.slug(repo))))
|
||||
.json(&payload).send().await?;
|
||||
let v = Self::check(resp).await.context("create_pull_request")?;
|
||||
Ok(PrInfo {
|
||||
@@ -109,10 +177,22 @@ impl Provider for GitHubProvider {
|
||||
})
|
||||
}
|
||||
|
||||
async fn create_issue(&self, repo: &str, title: &str, body: &str) -> Result<PrInfo> {
|
||||
let payload = json!({ "title": title, "body": body });
|
||||
let resp = self.client
|
||||
.post(self.url(&format!("repos/{}/issues", self.slug(repo))))
|
||||
.json(&payload).send().await?;
|
||||
let v = Self::check(resp).await.context("create_issue")?;
|
||||
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)))
|
||||
.put(self.url(&format!("repos/{}/collaborators/{}", self.slug(repo), user)))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("add_collaborator")?;
|
||||
Ok(())
|
||||
@@ -123,7 +203,7 @@ impl Provider for GitHubProvider {
|
||||
) -> 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)))
|
||||
.post(self.url(&format!("repos/{}/releases", self.slug(repo))))
|
||||
.json(&payload).send().await?;
|
||||
let v = Self::check(resp).await.context("create_release")?;
|
||||
Ok(v["html_url"].as_str().unwrap_or("").to_string())
|
||||
@@ -131,12 +211,12 @@ impl Provider for GitHubProvider {
|
||||
|
||||
async fn repo_meta(&self, repo: &str) -> Result<RepoMeta> {
|
||||
let resp = self.client
|
||||
.get(self.url(&format!("repos/{}/{}", self.owner, repo)))
|
||||
.get(self.url(&format!("repos/{}", self.slug(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)))
|
||||
.get(self.url(&format!("repos/{}/pulls?state=open&per_page=100", self.slug(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![]));
|
||||
@@ -166,7 +246,7 @@ impl Provider for GitHubProvider {
|
||||
) -> Result<()> {
|
||||
let body = json!({ "reviewers": reviewers });
|
||||
let resp = self.client
|
||||
.post(self.url(&format!("repos/{}/{}/pulls/{}/requested_reviewers", self.owner, repo, pr_number)))
|
||||
.post(self.url(&format!("repos/{}/pulls/{}/requested_reviewers", self.slug(repo), pr_number)))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("request_reviewers")?;
|
||||
Ok(())
|
||||
@@ -177,7 +257,7 @@ impl Provider for GitHubProvider {
|
||||
) -> Result<()> {
|
||||
let body = json!({ "labels": labels });
|
||||
let resp = self.client
|
||||
.post(self.url(&format!("repos/{}/{}/issues/{}/labels", self.owner, repo, issue_number)))
|
||||
.post(self.url(&format!("repos/{}/issues/{}/labels", self.slug(repo), issue_number)))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("add_labels")?;
|
||||
Ok(())
|
||||
@@ -189,10 +269,19 @@ impl Provider for GitHubProvider {
|
||||
Ok(v["login"].as_str().unwrap_or("unknown").to_string())
|
||||
}
|
||||
|
||||
async fn count_search(&self, query: &str) -> Result<u64> {
|
||||
let resp = self.client
|
||||
.get(self.url("search/issues"))
|
||||
.query(&[("q", query), ("per_page", "1")])
|
||||
.send().await?;
|
||||
let v = Self::check(resp).await.context("count_search")?;
|
||||
Ok(v["total_count"].as_u64().unwrap_or(0))
|
||||
}
|
||||
|
||||
async fn update_visibility(&self, repo: &str, private: bool) -> Result<()> {
|
||||
let body = serde_json::json!({ "private": private });
|
||||
let resp = self.client
|
||||
.patch(self.url(&format!("repos/{}/{}", self.owner, repo)))
|
||||
.patch(self.url(&format!("repos/{}", self.slug(repo))))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("update_visibility")?;
|
||||
Ok(())
|
||||
@@ -201,7 +290,7 @@ impl Provider for GitHubProvider {
|
||||
async fn rename_repo(&self, repo: &str, new_name: &str) -> Result<()> {
|
||||
let body = serde_json::json!({ "name": new_name });
|
||||
let resp = self.client
|
||||
.patch(self.url(&format!("repos/{}/{}", self.owner, repo)))
|
||||
.patch(self.url(&format!("repos/{}", self.slug(repo))))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("rename_repo")?;
|
||||
Ok(())
|
||||
@@ -210,7 +299,7 @@ impl Provider for GitHubProvider {
|
||||
async fn archive_repo(&self, repo: &str) -> Result<()> {
|
||||
let body = serde_json::json!({ "archived": true });
|
||||
let resp = self.client
|
||||
.patch(self.url(&format!("repos/{}/{}", self.owner, repo)))
|
||||
.patch(self.url(&format!("repos/{}", self.slug(repo))))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("archive_repo")?;
|
||||
Ok(())
|
||||
@@ -219,7 +308,7 @@ impl Provider for GitHubProvider {
|
||||
async fn update_description(&self, repo: &str, description: &str) -> Result<()> {
|
||||
let body = serde_json::json!({ "description": description });
|
||||
let resp = self.client
|
||||
.patch(self.url(&format!("repos/{}/{}", self.owner, repo)))
|
||||
.patch(self.url(&format!("repos/{}", self.slug(repo))))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("update_description")?;
|
||||
Ok(())
|
||||
@@ -227,7 +316,7 @@ impl Provider for GitHubProvider {
|
||||
|
||||
async fn list_pull_requests(&self, repo: &str) -> Result<Vec<PrDetail>> {
|
||||
let resp = self.client
|
||||
.get(self.url(&format!("repos/{}/{}/pulls?state=open&per_page=100", self.owner, repo)))
|
||||
.get(self.url(&format!("repos/{}/pulls?state=open&per_page=100", self.slug(repo))))
|
||||
.send().await?;
|
||||
if !resp.status().is_success() { return Ok(vec![]); }
|
||||
let list: Value = resp.json().await.unwrap_or(Value::Array(vec![]));
|
||||
@@ -285,8 +374,23 @@ impl Provider for GitHubProvider {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a GitHub API RFC3339 timestamp into a Unix timestamp, defaulting
|
||||
/// to 0 on a parse failure rather than propagating an error for what's
|
||||
/// always a secondary/display-only field.
|
||||
fn parse_gh_ts(s: &str) -> i64 {
|
||||
chrono::DateTime::parse_from_rfc3339(s)
|
||||
.map(|dt| dt.timestamp())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Pulls the `rel="next"` URL out of a GitHub pagination `Link` header, e.g.
|
||||
/// `<https://api.github.com/user/repos?page=2>; rel="next", <...>; rel="last"`.
|
||||
fn parse_next_link(link: &str) -> Option<String> {
|
||||
link.split(',').find_map(|part| {
|
||||
let part = part.trim();
|
||||
if !part.contains("rel=\"next\"") { return None; }
|
||||
let start = part.find('<')? + 1;
|
||||
let end = part.find('>')?;
|
||||
Some(part[start..end].to_string())
|
||||
})
|
||||
}
|
||||
+16
-4
@@ -1,3 +1,6 @@
|
||||
//! `Provider` implementation for GitLab (gitlab.com or self-hosted, via a
|
||||
//! custom `base_url`), talking to the REST API v4 directly over `reqwest`.
|
||||
|
||||
use super::{PrInfo, Provider, RepoInfo, RepoMeta};
|
||||
use anyhow::{Context, Result};
|
||||
use async_trait::async_trait;
|
||||
@@ -11,6 +14,9 @@ pub struct GitLabProvider {
|
||||
}
|
||||
|
||||
impl GitLabProvider {
|
||||
/// Builds a client authenticated with `token`, scoped to `namespace`'s
|
||||
/// projects by default. `base_url` lets this point at a self-hosted
|
||||
/// GitLab instance instead of `gitlab.com`.
|
||||
pub fn new(base_url: &str, namespace: &str, token: &str) -> Result<Self> {
|
||||
let mut headers = header::HeaderMap::new();
|
||||
headers.insert(
|
||||
@@ -19,7 +25,7 @@ impl GitLabProvider {
|
||||
);
|
||||
headers.insert(
|
||||
header::USER_AGENT,
|
||||
header::HeaderValue::from_static("fxg/0.1"),
|
||||
header::HeaderValue::from_static("vulp/0.1"),
|
||||
);
|
||||
let client = Client::builder().default_headers(headers).build()?;
|
||||
Ok(Self {
|
||||
@@ -29,16 +35,22 @@ impl GitLabProvider {
|
||||
})
|
||||
}
|
||||
|
||||
/// Joins `path` onto `base_url`, normalizing the slash between them.
|
||||
fn url(&self, path: &str) -> String {
|
||||
format!("{}/{}", self.base_url, path.trim_start_matches('/'))
|
||||
}
|
||||
|
||||
/// Builds GitLab's URL-encoded project identifier (`namespace%2Frepo`),
|
||||
/// since the v4 API addresses projects by encoded path rather than a
|
||||
/// separate numeric id field this codebase tracks.
|
||||
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")
|
||||
}
|
||||
|
||||
/// Parses a response as JSON if the status was successful, otherwise
|
||||
/// turns the non-2xx status and body into an error.
|
||||
async fn check(resp: Response) -> Result<Value> {
|
||||
let status = resp.status();
|
||||
let text = resp.text().await?;
|
||||
@@ -52,7 +64,7 @@ impl GitLabProvider {
|
||||
|
||||
#[async_trait]
|
||||
impl Provider for GitLabProvider {
|
||||
async fn create_repo(&self, name: &str, private: bool, description: &str) -> Result<RepoInfo> {
|
||||
async fn create_repo(&self, name: &str, private: bool, description: &str, _owner: Option<&str>) -> Result<RepoInfo> {
|
||||
let visibility = if private { "private" } else { "public" };
|
||||
let body = json!({
|
||||
"name": name,
|
||||
@@ -141,11 +153,11 @@ impl Provider for GitLabProvider {
|
||||
}
|
||||
|
||||
async fn add_labels(
|
||||
&self, repo: &str, pr_number: u64, labels: &[String],
|
||||
&self, repo: &str, issue_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)))
|
||||
.put(self.url(&format!("projects/{}/merge_requests/{}", self.repo_id(repo), issue_number)))
|
||||
.json(&body).send().await?;
|
||||
Self::check(resp).await.context("add_labels")?;
|
||||
Ok(())
|
||||
|
||||
+60
-2
@@ -1,3 +1,8 @@
|
||||
//! `Provider` — the trait every hosting backend (GitHub, GitLab, Gitea/
|
||||
//! Forgejo) implements, plus the data types its methods exchange and the
|
||||
//! `make_provider`/`default_account` helpers that pick and construct the
|
||||
//! right implementation for a given `Account`.
|
||||
|
||||
mod gitea;
|
||||
mod github;
|
||||
mod gitlab;
|
||||
@@ -11,6 +16,7 @@ use anyhow::Result;
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Result of creating a remote repository: its clone URLs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RepoInfo {
|
||||
pub name: String,
|
||||
@@ -18,12 +24,26 @@ pub struct RepoInfo {
|
||||
pub ssh_url: String,
|
||||
}
|
||||
|
||||
/// Which categories of remote repos to include in a bulk import. `owner` is
|
||||
/// always true in practice (the user's own repos) — kept as a field rather
|
||||
/// than implied so all three read the same way at the call site.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct RepoAffiliation {
|
||||
pub owner: bool,
|
||||
pub collaborator: bool,
|
||||
pub org_member: bool,
|
||||
}
|
||||
|
||||
/// Result of creating a pull/merge request or issue: just enough to link
|
||||
/// back to it (full detail, when needed, comes from `list_pull_requests`).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PrInfo {
|
||||
pub number: u64,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
/// Basic repo metadata fetched for the Settings tab and the hub's cached
|
||||
/// stars/issues/PRs counts.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[allow(dead_code)]
|
||||
pub struct RepoMeta {
|
||||
@@ -34,6 +54,8 @@ pub struct RepoMeta {
|
||||
pub topics: Vec<String>,
|
||||
}
|
||||
|
||||
/// One pull/merge request, as shown in the hub's PRs tab and the repo
|
||||
/// detail view's PRs tab.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct PrDetail {
|
||||
pub repo: String,
|
||||
@@ -49,6 +71,8 @@ pub struct PrDetail {
|
||||
pub labels: Vec<String>,
|
||||
}
|
||||
|
||||
/// One issue, as shown in the hub's Issues tab and the repo detail view's
|
||||
/// Issues tab.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct IssueDetail {
|
||||
pub repo: String,
|
||||
@@ -62,14 +86,35 @@ pub struct IssueDetail {
|
||||
pub comments: u32,
|
||||
}
|
||||
|
||||
/// A hosting backend's API surface. Methods that not every provider
|
||||
/// implements (`list_orgs`, `list_repos`, optional repo settings) have a
|
||||
/// default that either returns an empty result or bails with a clear
|
||||
/// "not supported" error, so callers can call any method on any provider
|
||||
/// without matching on which one it is.
|
||||
#[async_trait]
|
||||
#[allow(dead_code)]
|
||||
pub trait Provider: Send + Sync {
|
||||
/// Create a new remote repository.
|
||||
/// Create a new remote repository. `owner` is `Some(org)` to create it
|
||||
/// under an organization/group instead of the authenticated user's
|
||||
/// personal account.
|
||||
async fn create_repo(
|
||||
&self, name: &str, private: bool, description: &str,
|
||||
&self, name: &str, private: bool, description: &str, owner: Option<&str>,
|
||||
) -> Result<RepoInfo>;
|
||||
|
||||
/// Organizations/groups the authenticated user can create repos under.
|
||||
/// Empty by default — providers without a sensible equivalent just don't
|
||||
/// offer the picker.
|
||||
async fn list_orgs(&self) -> Result<Vec<String>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// List repos visible to the authenticated user matching the given
|
||||
/// affiliation flags. Empty by default — providers without a sensible
|
||||
/// equivalent just don't offer bulk import.
|
||||
async fn list_repos(&self, _affiliation: &RepoAffiliation) -> Result<Vec<RepoInfo>> {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// Delete a remote repository.
|
||||
async fn delete_repo(&self, name: &str) -> Result<()>;
|
||||
|
||||
@@ -84,6 +129,12 @@ pub trait Provider: Send + Sync {
|
||||
&self, repo: &str, title: &str, body: &str, head: &str, base: &str,
|
||||
) -> Result<PrInfo>;
|
||||
|
||||
/// Open a new issue. Not every provider's API is wired up yet — default
|
||||
/// bails the same way other not-yet-implemented methods on this trait do.
|
||||
async fn create_issue(&self, _repo: &str, _title: &str, _body: &str) -> Result<PrInfo> {
|
||||
anyhow::bail!("create_issue not supported by this provider")
|
||||
}
|
||||
|
||||
/// Request reviewers for an open pull request / merge request.
|
||||
async fn request_reviewers(
|
||||
&self, _repo: &str, _pr_number: u64, _reviewers: &[String],
|
||||
@@ -119,6 +170,13 @@ pub trait Provider: Send + Sync {
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
/// Total hit count for a provider-side issue/PR search query (e.g.
|
||||
/// "is:pr is:merged user:X") — used by the Overview tab's merged/closed
|
||||
/// counters. Providers without a search API report nothing.
|
||||
async fn count_search(&self, _query: &str) -> Result<u64> {
|
||||
anyhow::bail!("count_search not supported by this provider")
|
||||
}
|
||||
|
||||
/// Verify the token works, return the authenticated username.
|
||||
async fn whoami(&self) -> Result<String>;
|
||||
|
||||
|
||||
Reference in new issue
Block a user