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:
dualfroz committed 2026-07-06 14:42:52 +02:00
1 parent 5787b14880
commit 0468c032c6
100 files changed
+11614 -9840

No files matched your search

+33 -16
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>;