1use crate::error::{Result, ToriiError};
2use serde::{Deserialize, Serialize};
3pub(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#[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
60pub 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
82pub 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
99pub fn detect_platform_from_remote(repo_path: &str) -> Option<(String, String, String)> {
103 detect_platform_from_remote_named(repo_path, "origin")
104}
105
106pub 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 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 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
143fn 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 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
169fn extract_owner_repo(url: &str) -> Option<(String, String)> {
175 let path_part: String = if let Some(at) = url.find('@') {
176 url[at + 1..].splitn(2, ':').nth(1)?.to_string()
178 } else if let Some(after_scheme) = url.split("://").nth(1) {
179 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
195pub 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 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 if let Some(host) = extract_host(&url) {
248 if let Some(entry) = crate::platforms_registry::find_by_host(repo_path, &host) {
249 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 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 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 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}