Skip to main content

torii_lib/platforms/
pr.rs

1use crate::error::{Result, ToriiError};
2use serde::{Deserialize, Serialize};
3// Platform-specific URL/token helpers re-exported so historical
4// `crate::pr::…` paths keep working after the per-platform split.
5pub(crate) use super::azure::pr::{parse_azure_url, split_azure_owner};
6use super::azure::AzurePrClient;
7use super::bitbucket::BitbucketPrClient;
8pub use super::gitea::pr::{gitea_base_url, resolve_gitea_token};
9use super::gitea::GiteaPrClient;
10use super::github::GitHubPrClient;
11use super::gitlab::GitLabPrClient;
12use super::radicle::RadiclePrClient;
13use super::sourcehut::SourcehutPrClient;
14
15// ============================================================================
16// Shared types
17// ============================================================================
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct PullRequest {
21    pub number: u64,
22    pub title: String,
23    pub body: Option<String>,
24    pub state: String,
25    pub head: String,
26    pub base: String,
27    pub author: String,
28    pub url: String,
29    pub draft: bool,
30    pub mergeable: Option<bool>,
31    pub created_at: String,
32}
33
34#[derive(Debug, Clone)]
35pub struct CreatePrOptions {
36    pub title: String,
37    pub body: Option<String>,
38    pub head: String,
39    pub base: String,
40    pub draft: bool,
41}
42
43#[derive(Debug, Clone)]
44pub enum MergeMethod {
45    Merge,
46    Squash,
47    Rebase,
48}
49
50impl std::fmt::Display for MergeMethod {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            MergeMethod::Merge => write!(f, "merge"),
54            MergeMethod::Squash => write!(f, "squash"),
55            MergeMethod::Rebase => write!(f, "rebase"),
56        }
57    }
58}
59
60// ============================================================================
61// Trait
62// ============================================================================
63
64pub struct UpdatePrOptions {
65    pub title: Option<String>,
66    pub body: Option<String>,
67    pub base: Option<String>,
68}
69
70#[allow(dead_code)]
71pub trait PrClient: Send {
72    fn create(&self, owner: &str, repo: &str, opts: CreatePrOptions) -> Result<PullRequest>;
73    fn list(&self, owner: &str, repo: &str, state: &str) -> Result<Vec<PullRequest>>;
74    fn get(&self, owner: &str, repo: &str, number: u64) -> Result<PullRequest>;
75    fn merge(&self, owner: &str, repo: &str, number: u64, method: MergeMethod) -> Result<()>;
76    fn close(&self, owner: &str, repo: &str, number: u64) -> Result<()>;
77    fn update(&self, owner: &str, repo: &str, number: u64, opts: UpdatePrOptions) -> Result<()>;
78    fn delete_branch(&self, owner: &str, repo: &str, branch: &str) -> Result<()>;
79    fn checkout_branch(&self, pr: &PullRequest) -> String;
80}
81
82// ============================================================================
83// GitHub
84// ============================================================================
85
86pub fn get_pr_client(platform: &str) -> Result<Box<dyn PrClient>> {
87    match platform.to_lowercase().as_str() {
88        "github"    => Ok(Box::new(GitHubPrClient::new()?)),
89        "gitlab"    => Ok(Box::new(GitLabPrClient::new()?)),
90        "gitea"     => Ok(Box::new(GiteaPrClient::new()?)),
91        "sourcehut" => Ok(Box::new(SourcehutPrClient::new()?)),
92        "radicle"   => Ok(Box::new(RadiclePrClient::new()?)),
93        "bitbucket" => Ok(Box::new(BitbucketPrClient::new()?)),
94        "azure"     => Ok(Box::new(AzurePrClient::new()?)),
95        other => Err(ToriiError::Unsupported(format!("Unsupported platform: {}. Supported: github, gitlab, gitea, sourcehut, radicle, bitbucket, azure", other))),
96    }
97}
98
99/// Detect platform + owner/repo from the `origin` remote URL.
100/// Convenience wrapper around `detect_platform_from_remote_named` for
101/// callers that don't need to choose which remote to inspect.
102pub fn detect_platform_from_remote(repo_path: &str) -> Option<(String, String, String)> {
103    detect_platform_from_remote_named(repo_path, "origin")
104}
105
106/// 0.8.0 — fuller detect that also returns the API base URL the
107/// rest of torii should hit. Tries the platforms.toml registry
108/// first (so self-hosted instances get their custom URL); falls
109/// back to the builtin per-platform default. Returns None if the
110/// remote doesn't resolve to any known platform.
111pub fn detect_platform_full(
112    repo_path: &str,
113    remote_name: &str,
114) -> Option<(String, String, String, String)> {
115    let (platform, owner, repo) = detect_platform_from_remote_named(repo_path, remote_name)?;
116    let api_base_url = resolve_api_base_url(repo_path, remote_name, &platform);
117    Some((platform, owner, repo, api_base_url))
118}
119
120fn resolve_api_base_url(repo_path: &str, remote_name: &str, platform: &str) -> String {
121    // Try the registry first.
122    if let Ok(repo) = git2::Repository::discover(repo_path) {
123        if let Ok(rem) = repo.find_remote(remote_name) {
124            if let Some(url) = rem.url() {
125                if let Some(host) = extract_host(url) {
126                    if let Some(entry) = crate::platforms_registry::find_by_host(repo_path, &host) {
127                        return entry.api_base_url;
128                    }
129                }
130            }
131        }
132    }
133    // Fall back to the builtin default for the resolved platform.
134    match platform {
135        "github" => "https://api.github.com".to_string(),
136        "gitlab" => "https://gitlab.com/api/v4".to_string(),
137        "gitea" => "https://codeberg.org/api/v1".to_string(),
138        "bitbucket" => "https://api.bitbucket.org/2.0".to_string(),
139        _ => String::new(),
140    }
141}
142
143/// 0.8.0 — extract the host from a git remote URL. Handles the four
144/// shapes the rest of torii deals with: `https://host/...`,
145/// `git@host:owner/repo.git`, `ssh://git@host:22/owner/repo.git`,
146/// and the rare `host/owner/repo` shorthand we tolerate.
147fn extract_host(url: &str) -> Option<String> {
148    if let Some(rest) = url
149        .strip_prefix("https://")
150        .or_else(|| url.strip_prefix("http://"))
151    {
152        let host = rest.split(['/', ':']).next()?;
153        return Some(host.to_string());
154    }
155    if let Some(rest) = url.strip_prefix("ssh://") {
156        // Skip optional `user@`.
157        let after_user = rest.split('@').last()?;
158        let host = after_user.split([':', '/']).next()?;
159        return Some(host.to_string());
160    }
161    if let Some(at) = url.find('@') {
162        if let Some(colon) = url[at + 1..].find(':') {
163            return Some(url[at + 1..at + 1 + colon].to_string());
164        }
165    }
166    None
167}
168
169/// Pull owner / repo out of a git remote URL. The path after the
170/// host is split on `/`; the last two non-empty segments are the
171/// owner + repo (with `.git` trimmed). Subgroups (GitLab) collapse
172/// into the owner field — that's already how the GitLab client
173/// expects it.
174fn extract_owner_repo(url: &str) -> Option<(String, String)> {
175    let path_part: String = if let Some(at) = url.find('@') {
176        // git@host:owner/repo.git → owner/repo.git
177        url[at + 1..].splitn(2, ':').nth(1)?.to_string()
178    } else if let Some(after_scheme) = url.split("://").nth(1) {
179        // https://host/owner/repo.git → owner/repo.git
180        after_scheme.splitn(2, '/').nth(1)?.to_string()
181    } else {
182        url.to_string()
183    };
184    let cleaned = path_part.trim_end_matches('/').trim_end_matches(".git");
185    let segments: Vec<&str> = cleaned.split('/').filter(|s| !s.is_empty()).collect();
186    if segments.len() < 2 {
187        return None;
188    }
189    let repo = segments.last()?.to_string();
190    let owner_segments = &segments[..segments.len() - 1];
191    let owner = owner_segments.join("/");
192    Some((owner, repo))
193}
194
195/// Same as `detect_platform_from_remote` but takes the remote name
196/// explicitly. Used by the platform-management commands
197/// (`pipeline`, `job`, `package`, `release`) to support managing a
198/// project mirrored across multiple platforms — e.g. gitorii itself
199/// has `origin → gitlab` and `github-paskidev → github`, and a user
200/// may want to query either via `--remote NAME`.
201pub fn detect_platform_from_remote_named(
202    repo_path: &str,
203    remote_name: &str,
204) -> Option<(String, String, String)> {
205    let repo = git2::Repository::discover(repo_path).ok()?;
206    let remote = repo.find_remote(remote_name).ok()?;
207    let url = remote.url()?.to_string();
208
209    // 0.7.13: Codeberg (Forgejo-based) detected as "gitea" — they share
210    // the same API surface. Self-hosted Gitea/Forgejo instances need
211    // explicit declaration via ~/.config/torii/platforms.toml (coming
212    // in 0.8.0); for now they fall through to None.
213    // 0.7.15: git.sr.ht detected as "sourcehut" — issues + builds
214    // supported, PR / release / package have no equivalent there.
215    // 0.7.16: rad:// URLs detected as "radicle" — fully peer-to-peer,
216    // all ops drive the local `rad` CLI. owner is the RID; repo is
217    // unused (Radicle is per-project, not per-repo-within-org).
218    // 0.7.17: bitbucket.org detected as "bitbucket". Bitbucket Cloud
219    // only — self-hosted Bitbucket Data Center has a different URL
220    // shape and API surface, falls through to None for now.
221    // 0.7.18: Azure DevOps detected from `dev.azure.com`,
222    // `ssh.dev.azure.com`, or the legacy `*.visualstudio.com`. Azure
223    // uses a 3-level path (org/project/repo) which doesn't fit the
224    // owner/repo shape — we pack `org/project` into `owner` and let
225    // the AzureClient split it back. See parser below.
226    let platform = if url.contains("github.com") {
227        "github"
228    } else if url.contains("gitlab.com") {
229        "gitlab"
230    } else if url.contains("codeberg.org") {
231        "gitea"
232    } else if url.contains("git.sr.ht") {
233        "sourcehut"
234    } else if url.starts_with("rad://") || url.starts_with("rad@") {
235        "radicle"
236    } else if url.contains("bitbucket.org") {
237        "bitbucket"
238    } else if url.contains("dev.azure.com") || url.contains(".visualstudio.com") {
239        "azure"
240    } else {
241        // 0.8.0 — self-hosted lookup via platforms.toml registry.
242        // We parse the URL host out of the remote (ssh / https
243        // both work) and ask the registry for a matching entry.
244        // Codeberg / Forgejo route through the Gitea client, so
245        // their `kind` strings map onto the platform string the
246        // rest of torii's switch tables key on.
247        if let Some(host) = extract_host(&url) {
248            if let Some(entry) = crate::platforms_registry::find_by_host(repo_path, &host) {
249                // Map registry `kind` strings onto the platform
250                // discriminators the rest of torii uses.
251                let mapped: &str = match entry.kind.as_str() {
252                    "gitlab" => "gitlab",
253                    "github" | "github_enterprise" => "github",
254                    "gitea" | "forgejo" | "codeberg" => "gitea",
255                    "bitbucket" | "bitbucket_data_center" => "bitbucket",
256                    other => other,
257                };
258                // Static-lifetime requirement of the local `platform`
259                // binding above is satisfied by the matched arms;
260                // for an unknown kind we bail rather than guess.
261                let static_kind: &'static str = match mapped {
262                    "gitlab" => "gitlab",
263                    "github" => "github",
264                    "gitea" => "gitea",
265                    "bitbucket" => "bitbucket",
266                    _ => return None,
267                };
268                let owner_repo = extract_owner_repo(&url)?;
269                return Some((static_kind.to_string(), owner_repo.0, owner_repo.1));
270            }
271        }
272        return None;
273    };
274
275    // Radicle URLs are `rad://<seed-host>/<RID>` — there's no
276    // owner/repo split, the RID identifies the project globally. We
277    // shove the RID into `owner` and leave `repo` empty so callers
278    // have a non-empty key to work with.
279    if platform == "radicle" {
280        let rid = url
281            .trim_start_matches("rad://")
282            .trim_start_matches("rad@")
283            .split('/')
284            .last()?
285            .trim_end_matches(".git")
286            .to_string();
287        return Some((platform.to_string(), rid, String::new()));
288    }
289
290    // Azure DevOps URL shapes:
291    //   HTTPS modern:  https://dev.azure.com/{org}/{project}/_git/{repo}
292    //   HTTPS legacy:  https://{org}.visualstudio.com/{project}/_git/{repo}
293    //   SSH modern:    git@ssh.dev.azure.com:v3/{org}/{project}/{repo}
294    // We pack `org/project` into the `owner` slot — the AzureClient
295    // splits it on `/` at call time so api-url construction has all
296    // three parts.
297    if platform == "azure" {
298        let (org, project, repo_name) = parse_azure_url(&url)?;
299        return Some((
300            platform.to_string(),
301            format!("{}/{}", org, project),
302            repo_name,
303        ));
304    }
305
306    let path = if url.contains('@') {
307        url.splitn(2, ':').nth(1)?
308    } else {
309        url.trim_start_matches("https://")
310            .trim_start_matches("http://")
311            .splitn(2, '/')
312            .nth(1)?
313    };
314
315    let path = path.trim_end_matches(".git");
316    let mut parts = path.splitn(2, '/');
317    let owner = parts.next()?.to_string();
318    let repo_name = parts.next()?.to_string();
319
320    Some((platform.to_string(), owner, repo_name))
321}