Skip to main content

torii_lib/workspace/
remote.rs

1use serde::{Deserialize, Serialize};
2use crate::error::{Result, ToriiError};
3
4/// Remote repository visibility
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub enum Visibility {
7    Public,
8    Private,
9    Internal, // GitLab only
10}
11
12/// Remote repository information
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct RemoteRepo {
15    pub name: String,
16    pub description: Option<String>,
17    pub visibility: Visibility,
18    pub default_branch: String,
19    pub url: String,
20    pub ssh_url: String,
21    pub clone_url: String,
22}
23
24/// Platform-specific API client trait
25pub trait PlatformClient {
26    /// Create a new repository
27    /// Create a repository.
28    /// `namespace`: None → authenticated user's personal account.
29    /// Some(owner) → organization (GitHub/Gitea/Forgejo/Codeberg) or
30    /// group/subgroup path (GitLab).
31    fn create_repo(&self, name: &str, description: Option<&str>, visibility: Visibility, namespace: Option<&str>) -> Result<RemoteRepo>;
32    
33    /// Delete a repository
34    fn delete_repo(&self, owner: &str, repo: &str) -> Result<()>;
35    
36    /// Update repository settings
37    fn update_repo(&self, owner: &str, repo: &str, settings: RepoSettings) -> Result<RemoteRepo>;
38    
39    /// Get repository information
40    fn get_repo(&self, owner: &str, repo: &str) -> Result<RemoteRepo>;
41    
42    /// List user repositories
43    fn list_repos(&self) -> Result<Vec<RemoteRepo>>;
44    
45    /// Set repository visibility
46    fn set_visibility(&self, owner: &str, repo: &str, visibility: Visibility) -> Result<()>;
47    
48    /// Enable/disable features
49    fn configure_features(&self, owner: &str, repo: &str, features: RepoFeatures) -> Result<()>;
50}
51
52/// Repository settings for updates
53#[derive(Debug, Clone, Default)]
54#[allow(dead_code)]
55pub struct RepoSettings {
56    pub description: Option<String>,
57    pub homepage: Option<String>,
58    pub visibility: Option<Visibility>,
59    pub default_branch: Option<String>,
60    pub has_issues: Option<bool>,
61    pub has_wiki: Option<bool>,
62    pub has_downloads: Option<bool>,
63    pub allow_squash_merge: Option<bool>,
64    pub allow_merge_commit: Option<bool>,
65    pub allow_rebase_merge: Option<bool>,
66}
67
68/// Repository features configuration
69#[derive(Debug, Clone, Default)]
70#[allow(dead_code)]
71pub struct RepoFeatures {
72    pub issues: Option<bool>,
73    pub wiki: Option<bool>,
74    pub downloads: Option<bool>,
75    pub projects: Option<bool>,
76    pub discussions: Option<bool>,
77}
78
79/// GitHub API client (placeholder - requires reqwest)
80#[allow(dead_code)]
81pub struct GitHubClient {
82    token: String,
83    base_url: String,
84}
85
86impl GitHubClient {
87    pub fn new(token: String) -> Self {
88        Self {
89            token,
90            base_url: "https://api.github.com".to_string(),
91        }
92    }
93    
94    fn get_token() -> Result<String> {
95        crate::auth::resolve_token("github", ".").value
96            .ok_or_else(|| ToriiError::Auth { provider: "github".into(), message: "GitHub token not found. Run: torii auth set github YOUR_TOKEN".to_string() })
97    }
98}
99
100impl PlatformClient for GitHubClient {
101    fn create_repo(&self, name: &str, description: Option<&str>, visibility: Visibility, namespace: Option<&str>) -> Result<RemoteRepo> {
102        let private = matches!(visibility, Visibility::Private | Visibility::Internal);
103
104        let mut body = serde_json::json!({
105            "name": name,
106            "private": private,
107            "auto_init": false,
108        });
109        if let Some(desc) = description {
110            body["description"] = serde_json::Value::String(desc.to_string());
111        }
112
113        // GitHub: org repos go through `/orgs/{org}/repos`. Personal repos
114        // through `/user/repos`. Same body shape; endpoint switches.
115        let url = match namespace {
116            Some(org) => format!("https://api.github.com/orgs/{}/repos", org),
117            None => "https://api.github.com/user/repos".to_string(),
118        };
119
120        let client = reqwest::blocking::Client::new();
121        let resp = client
122            .post(&url)
123            .header("Authorization", format!("token {}", self.token))
124            .header("Accept", "application/vnd.github.v3+json")
125            .header("User-Agent", "torii-cli")
126            .json(&body)
127            .send()
128            .map_err(|e| ToriiError::Network { provider: "github".into(), message: e.to_string() })?;
129
130        if !resp.status().is_success() {
131            let status = resp.status().as_u16();
132            let msg = resp.text().unwrap_or_default();
133            return Err(ToriiError::PlatformApi { provider: "github".into(), status, message: msg });
134        }
135
136        let json: serde_json::Value = resp.json()
137            .map_err(|e| ToriiError::MalformedResponse { provider: "github".into(), message: format!("Failed to parse GitHub response: {}", e) })?;
138
139        let repo_name = json["name"].as_str().unwrap_or(name).to_string();
140        let owner = json["owner"]["login"].as_str().unwrap_or("unknown").to_string();
141
142        Ok(RemoteRepo {
143            name: repo_name.clone(),
144            description: description.map(|s| s.to_string()),
145            visibility,
146            default_branch: "main".to_string(),
147            url: format!("https://github.com/{}/{}", owner, repo_name),
148            ssh_url: format!("git@github.com:{}/{}.git", owner, repo_name),
149            clone_url: format!("https://github.com/{}/{}.git", owner, repo_name),
150        })
151    }
152    
153    fn delete_repo(&self, owner: &str, repo: &str) -> Result<()> {
154        // Native API call — no longer requires `gh` to be installed.
155        // Permissions: requires the token to have the `delete_repo` scope.
156        let url = format!("https://api.github.com/repos/{}/{}", owner, repo);
157        let resp = reqwest::blocking::Client::new()
158            .delete(&url)
159            .header("Authorization", format!("token {}", self.token))
160            .header("Accept", "application/vnd.github.v3+json")
161            .header("User-Agent", "torii-cli")
162            .send()
163            .map_err(|e| ToriiError::Network { provider: "github".into(), message: e.to_string() })?;
164
165        match resp.status().as_u16() {
166            204 => {
167                println!("✅ Repository deleted from GitHub");
168                Ok(())
169            }
170            403 => Err(ToriiError::Auth { provider: "github".into(), message: format!(
171                "GitHub refused the delete (HTTP 403). Token needs the `delete_repo` scope; \
172                 add it at https://github.com/settings/tokens or use a fine-grained token \
173                 with `Administration: write` on `{}/{}`.", owner, repo
174            ) }),
175            404 => Err(ToriiError::Auth { provider: "github".into(), message: format!(
176                "GitHub returned 404 for `{}/{}` — repo doesn't exist or token can't see it.",
177                owner, repo
178            ) }),
179            other => {
180                let msg = resp.text().unwrap_or_default();
181                Err(ToriiError::PlatformApi {
182                    provider: "github".into(),
183                    status: other,
184                    message: msg,
185                })
186            }
187        }
188    }
189    
190    fn update_repo(&self, owner: &str, repo: &str, settings: RepoSettings) -> Result<RemoteRepo> {
191        let repo_name = format!("{}/{}", owner, repo);
192        let mut args = vec!["repo", "edit", &repo_name];
193        
194        let mut temp_args = Vec::new();
195        
196        if let Some(desc) = &settings.description {
197            temp_args.push("--description".to_string());
198            temp_args.push(desc.clone());
199        }
200        
201        if let Some(homepage) = &settings.homepage {
202            temp_args.push("--homepage".to_string());
203            temp_args.push(homepage.clone());
204        }
205        
206        if let Some(vis) = &settings.visibility {
207            match vis {
208                Visibility::Public => temp_args.push("--visibility=public".to_string()),
209                Visibility::Private => temp_args.push("--visibility=private".to_string()),
210                Visibility::Internal => temp_args.push("--visibility=private".to_string()),
211            }
212        }
213        
214        if let Some(branch) = &settings.default_branch {
215            temp_args.push("--default-branch".to_string());
216            temp_args.push(branch.clone());
217        }
218        
219        // Convert temp_args to string slices
220        let arg_refs: Vec<&str> = temp_args.iter().map(|s| s.as_str()).collect();
221        args.extend(arg_refs);
222        
223        let output = std::process::Command::new("gh")
224            .args(&args)
225            .output();
226        
227        match output {
228            Ok(out) if out.status.success() => {
229                println!("✅ Repository settings updated");
230                self.get_repo(owner, repo)
231            }
232            _ => {
233                Err(ToriiError::Subprocess {
234                    tool: "gh".into(),
235                    message: "Failed to update repository settings".to_string(),
236                })
237            }
238        }
239    }
240    
241    fn get_repo(&self, owner: &str, repo: &str) -> Result<RemoteRepo> {
242        let repo_name = format!("{}/{}", owner, repo);
243        let output = std::process::Command::new("gh")
244            .args(&["repo", "view", &repo_name, "--json", "name,description,visibility,defaultBranchRef,url,sshUrl"])
245            .output();
246        
247        match output {
248            Ok(out) if out.status.success() => {
249                // Parse JSON output (simplified)
250                Ok(RemoteRepo {
251                    name: repo.to_string(),
252                    description: None,
253                    visibility: Visibility::Private,
254                    default_branch: "main".to_string(),
255                    url: format!("https://github.com/{}/{}", owner, repo),
256                    ssh_url: format!("git@github.com:{}/{}.git", owner, repo),
257                    clone_url: format!("https://github.com/{}/{}.git", owner, repo),
258                })
259            }
260            _ => {
261                Err(ToriiError::Subprocess {
262                    tool: "gh".into(),
263                    message: "Failed to get repository information".to_string(),
264                })
265            }
266        }
267    }
268    
269    fn list_repos(&self) -> Result<Vec<RemoteRepo>> {
270        let output = std::process::Command::new("gh")
271            .args(&["repo", "list", "--json", "name,description,visibility", "--limit", "100"])
272            .output();
273        
274        match output {
275            Ok(out) if out.status.success() => {
276                // Return empty list for now (would parse JSON in full implementation)
277                Ok(Vec::new())
278            }
279            _ => {
280                Err(ToriiError::Subprocess {
281                    tool: "gh".into(),
282                    message: "Failed to list repositories".to_string(),
283                })
284            }
285        }
286    }
287    
288    fn set_visibility(&self, owner: &str, repo: &str, visibility: Visibility) -> Result<()> {
289        let mut settings = RepoSettings::default();
290        settings.visibility = Some(visibility);
291        self.update_repo(owner, repo, settings)?;
292        Ok(())
293    }
294    
295    fn configure_features(&self, owner: &str, repo: &str, features: RepoFeatures) -> Result<()> {
296        let repo_name = format!("{}/{}", owner, repo);
297        let mut args = vec!["repo", "edit", &repo_name];
298        
299        let mut temp_args = Vec::new();
300        
301        if let Some(issues) = features.issues {
302            temp_args.push(if issues { "--enable-issues".to_string() } else { "--disable-issues".to_string() });
303        }
304        
305        if let Some(wiki) = features.wiki {
306            temp_args.push(if wiki { "--enable-wiki".to_string() } else { "--disable-wiki".to_string() });
307        }
308        
309        if let Some(projects) = features.projects {
310            temp_args.push(if projects { "--enable-projects".to_string() } else { "--disable-projects".to_string() });
311        }
312        
313        let arg_refs: Vec<&str> = temp_args.iter().map(|s| s.as_str()).collect();
314        args.extend(arg_refs);
315        
316        let output = std::process::Command::new("gh")
317            .args(&args)
318            .output();
319        
320        match output {
321            Ok(out) if out.status.success() => {
322                println!("✅ Repository features configured");
323                Ok(())
324            }
325            _ => {
326                Err(ToriiError::Subprocess {
327                    tool: "gh".into(),
328                    message: "Failed to configure repository features".to_string(),
329                })
330            }
331        }
332    }
333}
334
335/// GitLab API client (placeholder)
336pub struct GitLabClient {
337    token: Option<String>,
338    base_url: String,
339}
340
341impl GitLabClient {
342    pub fn new(token: Option<String>, base_url: Option<String>) -> Self {
343        Self { 
344            token,
345            base_url: base_url.unwrap_or_else(|| "https://gitlab.com/api/v4".to_string()),
346        }
347    }
348    
349    #[allow(dead_code)]
350    pub fn with_url(token: String, base_url: String) -> Self {
351        Self {
352            token: Some(token),
353            base_url,
354        }
355    }
356}
357
358impl PlatformClient for GitLabClient {
359    fn create_repo(&self, name: &str, description: Option<&str>, visibility: Visibility, namespace: Option<&str>) -> Result<RemoteRepo> {
360        let token = self.token.as_ref()
361            .ok_or_else(|| ToriiError::Auth { provider: "gitlab".into(), message: "GitLab token not found. Set GITLAB_TOKEN environment variable".to_string() })?;
362
363        let visibility_str = match visibility {
364            Visibility::Public => "public",
365            Visibility::Private => "private",
366            Visibility::Internal => "internal",
367        };
368
369        let mut body = serde_json::json!({
370            "name": name,
371            "path": name,  // url slug = name (GitLab default)
372            "visibility": visibility_str,
373        });
374
375        if let Some(desc) = description {
376            body["description"] = serde_json::json!(desc);
377        }
378
379        // GitLab: groups/subgroups need a numeric namespace_id. Resolve the
380        // path → id via the groups API. Personal projects omit it.
381        let client = reqwest::blocking::Client::new();
382        if let Some(ns) = namespace {
383            // GitLab namespaces can be groups (org-style) OR users (personal).
384            // Try /groups/{ns} first; on 404 fall back to /users?username={ns}
385            // because groups/<username> always 404s.
386            let ns_encoded = crate::url::encode(ns);
387            let group_url = format!("{}/groups/{}", self.base_url, ns_encoded);
388            let group_resp = client
389                .get(&group_url)
390                .header("Authorization", format!("Bearer {}", token))
391                .send()
392                .map_err(|e| ToriiError::Network { provider: "gitlab".into(), message: format!("group lookup failed: {}", e) })?;
393
394            let ns_id = if group_resp.status().is_success() {
395                let group: serde_json::Value = group_resp.json()
396                    .map_err(|e| ToriiError::MalformedResponse { provider: "gitlab".into(), message: format!("GitLab group parse: {}", e) })?;
397                group["id"].as_i64().ok_or_else(|| ToriiError::MalformedResponse { provider: "gitlab".into(), message: format!("GitLab group `{}` returned no id", ns) })?
398            } else if group_resp.status().as_u16() == 404 {
399                // Try as a user. /users?username=… returns an array.
400                let user_url = format!("{}/users?username={}", self.base_url, ns_encoded);
401                let user_resp = client
402                    .get(&user_url)
403                    .header("Authorization", format!("Bearer {}", token))
404                    .send()
405                    .map_err(|e| ToriiError::Network { provider: "gitlab".into(), message: format!("user lookup failed: {}", e) })?;
406                if !user_resp.status().is_success() {
407                    return Err(ToriiError::MalformedResponse {
408                        provider: "gitlab".into(),
409                        message: format!("namespace `{}` is neither a group nor a user", ns),
410                    });
411                }
412                let users: serde_json::Value = user_resp.json()
413                    .map_err(|e| ToriiError::MalformedResponse { provider: "gitlab".into(), message: format!("GitLab user parse: {}", e) })?;
414                let user = users.as_array()
415                    .and_then(|a| a.first())
416                    .ok_or_else(|| ToriiError::Usage(
417                        format!("GitLab namespace `{}` not found", ns)
418                    ))?;
419                user["namespace_id"].as_i64()
420                    .or_else(|| user["id"].as_i64())
421                    .ok_or_else(|| ToriiError::MalformedResponse { provider: "gitlab".into(), message: format!("GitLab user `{}` returned no namespace_id", ns) })?
422            } else {
423                let group_status = group_resp.status().as_u16();
424                let err = group_resp.text().unwrap_or_default();
425                return Err(ToriiError::PlatformApi {
426                    provider: "gitlab".into(),
427                    status: group_status,
428                    message: format!("namespace `{}` lookup failed: {}", ns, err),
429                });
430            };
431            body["namespace_id"] = serde_json::json!(ns_id);
432        }
433
434        let response = client
435            .post(format!("{}/projects", self.base_url))
436            .header("Authorization", format!("Bearer {}", token))
437            .header("Content-Type", "application/json")
438            .json(&body)
439            .send()
440            .map_err(|e| ToriiError::Network { provider: "gitlab".into(), message: format!("GitLab API request failed: {}", e) })?;
441
442        if !response.status().is_success() {
443            let status = response.status().as_u16();
444            let error_text = response.text().unwrap_or_else(|_| "Unknown error".to_string());
445            return Err(ToriiError::PlatformApi {
446                provider: "gitlab".into(),
447                status,
448                message: error_text,
449            });
450        }
451
452        let project: serde_json::Value = response.json()
453            .map_err(|e| ToriiError::MalformedResponse { provider: "gitlab".into(), message: format!("Failed to parse GitLab response: {}", e) })?;
454
455        Ok(RemoteRepo {
456            name: project["name"].as_str().unwrap_or(name).to_string(),
457            description: project["description"].as_str().map(|s| s.to_string()),
458            visibility,
459            default_branch: project["default_branch"].as_str().unwrap_or("main").to_string(),
460            url: project["web_url"].as_str().unwrap_or("").to_string(),
461            ssh_url: project["ssh_url_to_repo"].as_str().unwrap_or("").to_string(),
462            clone_url: project["http_url_to_repo"].as_str().unwrap_or("").to_string(),
463        })
464    }
465    
466    fn delete_repo(&self, owner: &str, repo: &str) -> Result<()> {
467        let token = self.token.as_ref()
468            .ok_or_else(|| ToriiError::Auth { provider: "gitlab".into(), message: "GitLab token not found. Set GITLAB_TOKEN environment variable".to_string() })?;
469
470        let path_str = format!("{}/{}", owner, repo);
471        let project_path = crate::url::encode(&path_str);
472        let client = reqwest::blocking::Client::new();
473        let response = client
474            .delete(format!("{}/projects/{}", self.base_url, project_path))
475            .header("Authorization", format!("Bearer {}", token))
476            .send()
477            .map_err(|e| ToriiError::Network { provider: "gitlab".into(), message: format!("GitLab API request failed: {}", e) })?;
478
479        if !response.status().is_success() {
480            let status = response.status().as_u16();
481            let error_text = response.text().unwrap_or_else(|_| "Unknown error".to_string());
482            return Err(ToriiError::PlatformApi {
483                provider: "gitlab".into(),
484                status,
485                message: error_text,
486            });
487        }
488
489        Ok(())
490    }
491    
492    fn update_repo(&self, owner: &str, repo: &str, settings: RepoSettings) -> Result<RemoteRepo> {
493        let token = self.token.as_ref()
494            .ok_or_else(|| ToriiError::Auth { provider: "gitlab".into(), message: "GitLab token not found. Set GITLAB_TOKEN environment variable".to_string() })?;
495
496        let path_str = format!("{}/{}", owner, repo);
497        let project_path = crate::url::encode(&path_str);
498        let mut body = serde_json::json!({});
499
500        if let Some(desc) = settings.description {
501            body["description"] = serde_json::json!(desc);
502        }
503        if let Some(vis) = settings.visibility {
504            let vis_str = match vis {
505                Visibility::Public => "public",
506                Visibility::Private => "private",
507                Visibility::Internal => "internal",
508            };
509            body["visibility"] = serde_json::json!(vis_str);
510        }
511        if let Some(branch) = settings.default_branch {
512            body["default_branch"] = serde_json::json!(branch);
513        }
514
515        let client = reqwest::blocking::Client::new();
516        let response = client
517            .put(format!("{}/projects/{}", self.base_url, project_path))
518            .header("Authorization", format!("Bearer {}", token))
519            .header("Content-Type", "application/json")
520            .json(&body)
521            .send()
522            .map_err(|e| ToriiError::Network { provider: "gitlab".into(), message: format!("GitLab API request failed: {}", e) })?;
523
524        if !response.status().is_success() {
525            let status = response.status().as_u16();
526            let error_text = response.text().unwrap_or_else(|_| "Unknown error".to_string());
527            return Err(ToriiError::PlatformApi {
528                provider: "gitlab".into(),
529                status,
530                message: error_text,
531            });
532        }
533
534        let project: serde_json::Value = response.json()
535            .map_err(|e| ToriiError::MalformedResponse { provider: "gitlab".into(), message: format!("Failed to parse GitLab response: {}", e) })?;
536
537        let visibility = match project["visibility"].as_str() {
538            Some("public") => Visibility::Public,
539            Some("internal") => Visibility::Internal,
540            _ => Visibility::Private,
541        };
542
543        Ok(RemoteRepo {
544            name: project["name"].as_str().unwrap_or(repo).to_string(),
545            description: project["description"].as_str().map(|s| s.to_string()),
546            visibility,
547            default_branch: project["default_branch"].as_str().unwrap_or("main").to_string(),
548            url: project["web_url"].as_str().unwrap_or("").to_string(),
549            ssh_url: project["ssh_url_to_repo"].as_str().unwrap_or("").to_string(),
550            clone_url: project["http_url_to_repo"].as_str().unwrap_or("").to_string(),
551        })
552    }
553    
554    fn get_repo(&self, owner: &str, repo: &str) -> Result<RemoteRepo> {
555        let token = self.token.as_ref()
556            .ok_or_else(|| ToriiError::Auth { provider: "gitlab".into(), message: "GitLab token not found. Set GITLAB_TOKEN environment variable".to_string() })?;
557
558        let path_str = format!("{}/{}", owner, repo);
559        let project_path = crate::url::encode(&path_str);
560        let client = reqwest::blocking::Client::new();
561        let response = client
562            .get(format!("{}/projects/{}", self.base_url, project_path))
563            .header("Authorization", format!("Bearer {}", token))
564            .send()
565            .map_err(|e| ToriiError::Network { provider: "gitlab".into(), message: format!("GitLab API request failed: {}", e) })?;
566
567        if !response.status().is_success() {
568            let status = response.status().as_u16();
569            let error_text = response.text().unwrap_or_else(|_| "Unknown error".to_string());
570            return Err(ToriiError::PlatformApi {
571                provider: "gitlab".into(),
572                status,
573                message: error_text,
574            });
575        }
576
577        let project: serde_json::Value = response.json()
578            .map_err(|e| ToriiError::MalformedResponse { provider: "gitlab".into(), message: format!("Failed to parse GitLab response: {}", e) })?;
579
580        let visibility = match project["visibility"].as_str() {
581            Some("public") => Visibility::Public,
582            Some("internal") => Visibility::Internal,
583            _ => Visibility::Private,
584        };
585
586        Ok(RemoteRepo {
587            name: project["name"].as_str().unwrap_or(repo).to_string(),
588            description: project["description"].as_str().map(|s| s.to_string()),
589            visibility,
590            default_branch: project["default_branch"].as_str().unwrap_or("main").to_string(),
591            url: project["web_url"].as_str().unwrap_or("").to_string(),
592            ssh_url: project["ssh_url_to_repo"].as_str().unwrap_or("").to_string(),
593            clone_url: project["http_url_to_repo"].as_str().unwrap_or("").to_string(),
594        })
595    }
596    
597    fn list_repos(&self) -> Result<Vec<RemoteRepo>> {
598        let token = self.token.as_ref()
599            .ok_or_else(|| ToriiError::Auth { provider: "gitlab".into(), message: "GitLab token not found. Set GITLAB_TOKEN environment variable".to_string() })?;
600
601        let client = reqwest::blocking::Client::new();
602        let response = client
603            .get(format!("{}/projects?membership=true&per_page=100", self.base_url))
604            .header("Authorization", format!("Bearer {}", token))
605            .send()
606            .map_err(|e| ToriiError::Network { provider: "gitlab".into(), message: format!("GitLab API request failed: {}", e) })?;
607
608        if !response.status().is_success() {
609            let status = response.status().as_u16();
610            let error_text = response.text().unwrap_or_else(|_| "Unknown error".to_string());
611            return Err(ToriiError::PlatformApi {
612                provider: "gitlab".into(),
613                status,
614                message: error_text,
615            });
616        }
617
618        let projects: Vec<serde_json::Value> = response.json()
619            .map_err(|e| ToriiError::MalformedResponse { provider: "gitlab".into(), message: format!("Failed to parse GitLab response: {}", e) })?;
620
621        Ok(projects.iter().map(|project| {
622            let visibility = match project["visibility"].as_str() {
623                Some("public") => Visibility::Public,
624                Some("internal") => Visibility::Internal,
625                _ => Visibility::Private,
626            };
627
628            RemoteRepo {
629                name: project["name"].as_str().unwrap_or("").to_string(),
630                description: project["description"].as_str().map(|s| s.to_string()),
631                visibility,
632                default_branch: project["default_branch"].as_str().unwrap_or("main").to_string(),
633                url: project["web_url"].as_str().unwrap_or("").to_string(),
634                ssh_url: project["ssh_url_to_repo"].as_str().unwrap_or("").to_string(),
635                clone_url: project["http_url_to_repo"].as_str().unwrap_or("").to_string(),
636            }
637        }).collect())
638    }
639    
640    fn set_visibility(&self, owner: &str, repo: &str, visibility: Visibility) -> Result<()> {
641        let token = self.token.as_ref()
642            .ok_or_else(|| ToriiError::Auth { provider: "gitlab".into(), message: "GitLab token not found. Set GITLAB_TOKEN environment variable".to_string() })?;
643
644        let path_str = format!("{}/{}", owner, repo);
645        let project_path = crate::url::encode(&path_str);
646        let visibility_str = match visibility {
647            Visibility::Public => "public",
648            Visibility::Private => "private",
649            Visibility::Internal => "internal",
650        };
651
652        let body = serde_json::json!({
653            "visibility": visibility_str,
654        });
655
656        let client = reqwest::blocking::Client::new();
657        let response = client
658            .put(format!("{}/projects/{}", self.base_url, project_path))
659            .header("Authorization", format!("Bearer {}", token))
660            .header("Content-Type", "application/json")
661            .json(&body)
662            .send()
663            .map_err(|e| ToriiError::Network { provider: "gitlab".into(), message: format!("GitLab API request failed: {}", e) })?;
664
665        if !response.status().is_success() {
666            let status = response.status().as_u16();
667            let error_text = response.text().unwrap_or_else(|_| "Unknown error".to_string());
668            return Err(ToriiError::PlatformApi {
669                provider: "gitlab".into(),
670                status,
671                message: error_text,
672            });
673        }
674
675        Ok(())
676    }
677    
678    fn configure_features(&self, owner: &str, repo: &str, features: RepoFeatures) -> Result<()> {
679        let token = self.token.as_ref()
680            .ok_or_else(|| ToriiError::Auth { provider: "gitlab".into(), message: "GitLab token not found. Set GITLAB_TOKEN environment variable".to_string() })?;
681
682        let path_str = format!("{}/{}", owner, repo);
683        let project_path = crate::url::encode(&path_str);
684        let mut body = serde_json::json!({});
685
686        if let Some(issues) = features.issues {
687            body["issues_enabled"] = serde_json::json!(issues);
688        }
689        if let Some(wiki) = features.wiki {
690            body["wiki_enabled"] = serde_json::json!(wiki);
691        }
692
693        let client = reqwest::blocking::Client::new();
694        let response = client
695            .put(format!("{}/projects/{}", self.base_url, project_path))
696            .header("Authorization", format!("Bearer {}", token))
697            .header("Content-Type", "application/json")
698            .json(&body)
699            .send()
700            .map_err(|e| ToriiError::Network { provider: "gitlab".into(), message: format!("GitLab API request failed: {}", e) })?;
701
702        if !response.status().is_success() {
703            let status = response.status().as_u16();
704            let error_text = response.text().unwrap_or_else(|_| "Unknown error".to_string());
705            return Err(ToriiError::PlatformApi {
706                provider: "gitlab".into(),
707                status,
708                message: error_text,
709            });
710        }
711
712        Ok(())
713    }
714}
715
716/// Get appropriate platform client based on platform name
717pub fn get_platform_client(platform: &str) -> Result<Box<dyn PlatformClient>> {
718    match platform.to_lowercase().as_str() {
719        "github" => {
720            let token = GitHubClient::get_token()?;
721            Ok(Box::new(GitHubClient::new(token)))
722        }
723        "gitlab" => {
724            let token = crate::auth::resolve_token("gitlab", ".").value
725                .ok_or_else(|| ToriiError::Auth { provider: "gitlab".into(), message: "GitLab token not found. Run: torii auth set gitlab YOUR_TOKEN".to_string() })?;
726            let base_url = std::env::var("GITLAB_URL").ok();
727            Ok(Box::new(GitLabClient::new(Some(token), base_url)))
728        }
729        "gitea" => {
730            // Codeberg and Forgejo share the Gitea API — accept the
731            // Codeberg/Forgejo token as a fallback so a single
732            // `torii auth set codeberg ...` works.
733            let token = crate::auth::resolve_token("gitea", ".").value
734                .or_else(|| crate::auth::resolve_token("codeberg", ".").value)
735                .or_else(|| crate::auth::resolve_token("forgejo", ".").value);
736            let base_url = std::env::var("GITEA_URL")
737                .unwrap_or_else(|_| "https://gitea.com".to_string());
738            Ok(Box::new(GiteaClient::new(token, base_url)))
739        }
740        "forgejo" => {
741            let token = crate::auth::resolve_token("forgejo", ".").value
742                .or_else(|| crate::auth::resolve_token("gitea", ".").value)
743                .or_else(|| crate::auth::resolve_token("codeberg", ".").value);
744            let base_url = std::env::var("FORGEJO_URL")
745                .unwrap_or_else(|_| "https://codeberg.org".to_string());
746            Ok(Box::new(ForgejoClient::new(token, base_url)))
747        }
748        "codeberg" => {
749            let token = crate::auth::resolve_token("codeberg", ".").value
750                .or_else(|| crate::auth::resolve_token("gitea", ".").value)
751                .or_else(|| crate::auth::resolve_token("forgejo", ".").value);
752            Ok(Box::new(CodebergClient::new(token)))
753        }
754        "bitbucket" => {
755            let token = crate::auth::resolve_token("bitbucket", ".").value;
756            Ok(Box::new(BitbucketClient::new(token)))
757        }
758        "sourcehut" => {
759            let token = crate::auth::resolve_token("sourcehut", ".").value;
760            Ok(Box::new(SourcehutClient::new(token)))
761        }
762        "azure" => {
763            Ok(Box::new(AzureClient::new()))
764        }
765        "radicle" => {
766            Ok(Box::new(RadicleClient::new()))
767        }
768        _ => Err(ToriiError::Unsupported(
769            format!(
770                "Unsupported platform: {}. Supported: github, gitlab, gitea, forgejo, codeberg, bitbucket, sourcehut, azure, radicle",
771                platform
772            )
773        )),
774    }
775}
776
777// ============================================================================
778// Gitea/Forgejo/Codeberg Clients
779// ============================================================================
780
781#[allow(dead_code)]
782pub struct GiteaClient {
783    token: Option<String>,
784    base_url: String,
785}
786
787impl GiteaClient {
788    pub fn new(token: Option<String>, base_url: String) -> Self {
789        Self { token, base_url }
790    }
791}
792
793#[allow(dead_code)]
794pub struct ForgejoClient {
795    token: Option<String>,
796    base_url: String,
797}
798
799impl ForgejoClient {
800    pub fn new(token: Option<String>, base_url: String) -> Self {
801        Self { token, base_url }
802    }
803}
804
805#[allow(dead_code)]
806pub struct CodebergClient {
807    token: Option<String>,
808}
809
810impl CodebergClient {
811    pub fn new(token: Option<String>) -> Self {
812        Self { token }
813    }
814}
815
816// Placeholder implementations - will be completed with API calls
817// ──────────────────────────────────────────────────────────────────────────────
818// Gitea / Forgejo / Codeberg — all three share the Gitea API.
819//
820// We implement the shared bits as free functions and delegate from each
821// client. Only the base URL differs (gitea.com / codeberg.org / a
822// self-hosted Forgejo instance via FORGEJO_URL / GITEA_URL env vars).
823//
824// For 0.7.19 we wire `set_visibility` end-to-end; create / delete /
825// update / list still return clear "not implemented yet" errors —
826// follow-up work tracked in ROADMAP.
827
828fn gitea_token<'a>(label: &str, token: &'a Option<String>) -> Result<&'a String> {
829    token.as_ref().ok_or_else(|| ToriiError::Auth {
830        provider: label.to_lowercase(),
831        message: format!("{label} token not found. Run: torii auth set {} YOUR_TOKEN", label.to_lowercase()),
832    })
833}
834
835fn gitea_set_visibility(base_url: &str, token: &Option<String>, owner: &str, repo: &str, visibility: Visibility, label: &str) -> Result<()> {
836    // Gitea visibility is just a `private` boolean. "Internal" doesn't
837    // exist on Gitea — collapse to private.
838    let private = !matches!(visibility, Visibility::Public);
839    let tok = gitea_token(label, token)?;
840    let url = format!("{}/api/v1/repos/{}/{}", base_url.trim_end_matches('/'), owner, repo);
841    let body = serde_json::json!({ "private": private });
842    let req = crate::http::make_client().patch(&url)
843        .header("Authorization", format!("token {}", tok))
844        .header("Accept", "application/json")
845        .json(&body);
846    crate::http::send_empty(req, &format!("{} set visibility", label))
847}
848
849impl PlatformClient for GiteaClient {
850    fn create_repo(&self, _name: &str, _description: Option<&str>, _visibility: Visibility, _namespace: Option<&str>) -> Result<RemoteRepo> {
851        Err(ToriiError::Unsupported("Gitea create_repo not yet wired (use the web UI). `torii remote visibility` does work.".to_string()))
852    }
853    fn delete_repo(&self, _owner: &str, _repo: &str) -> Result<()> {
854        Err(ToriiError::Unsupported("Gitea delete_repo not yet wired".to_string()))
855    }
856    fn update_repo(&self, _owner: &str, _repo: &str, _settings: RepoSettings) -> Result<RemoteRepo> {
857        Err(ToriiError::Unsupported("Gitea update_repo not yet wired".to_string()))
858    }
859    fn get_repo(&self, _owner: &str, _repo: &str) -> Result<RemoteRepo> {
860        Err(ToriiError::Unsupported("Gitea get_repo not yet wired".to_string()))
861    }
862    fn list_repos(&self) -> Result<Vec<RemoteRepo>> {
863        Err(ToriiError::Unsupported("Gitea list_repos not yet wired".to_string()))
864    }
865    fn set_visibility(&self, owner: &str, repo: &str, visibility: Visibility) -> Result<()> {
866        gitea_set_visibility(&self.base_url, &self.token, owner, repo, visibility, "Gitea")
867    }
868    fn configure_features(&self, _owner: &str, _repo: &str, _features: RepoFeatures) -> Result<()> {
869        Err(ToriiError::Unsupported("Gitea configure_features not yet wired".to_string()))
870    }
871}
872
873impl PlatformClient for ForgejoClient {
874    fn create_repo(&self, _name: &str, _description: Option<&str>, _visibility: Visibility, _namespace: Option<&str>) -> Result<RemoteRepo> {
875        Err(ToriiError::Unsupported("Forgejo create_repo not yet wired (use the web UI). `torii remote visibility` does work.".to_string()))
876    }
877    fn delete_repo(&self, _owner: &str, _repo: &str) -> Result<()> {
878        Err(ToriiError::Unsupported("Forgejo delete_repo not yet wired".to_string()))
879    }
880    fn update_repo(&self, _owner: &str, _repo: &str, _settings: RepoSettings) -> Result<RemoteRepo> {
881        Err(ToriiError::Unsupported("Forgejo update_repo not yet wired".to_string()))
882    }
883    fn get_repo(&self, _owner: &str, _repo: &str) -> Result<RemoteRepo> {
884        Err(ToriiError::Unsupported("Forgejo get_repo not yet wired".to_string()))
885    }
886    fn list_repos(&self) -> Result<Vec<RemoteRepo>> {
887        Err(ToriiError::Unsupported("Forgejo list_repos not yet wired".to_string()))
888    }
889    fn set_visibility(&self, owner: &str, repo: &str, visibility: Visibility) -> Result<()> {
890        gitea_set_visibility(&self.base_url, &self.token, owner, repo, visibility, "Forgejo")
891    }
892    fn configure_features(&self, _owner: &str, _repo: &str, _features: RepoFeatures) -> Result<()> {
893        Err(ToriiError::Unsupported("Forgejo configure_features not yet wired".to_string()))
894    }
895}
896
897impl PlatformClient for CodebergClient {
898    fn create_repo(&self, _name: &str, _description: Option<&str>, _visibility: Visibility, _namespace: Option<&str>) -> Result<RemoteRepo> {
899        Err(ToriiError::Unsupported("Codeberg create_repo not yet wired (use the web UI). `torii remote visibility` does work.".to_string()))
900    }
901    fn delete_repo(&self, _owner: &str, _repo: &str) -> Result<()> {
902        Err(ToriiError::Unsupported("Codeberg delete_repo not yet wired".to_string()))
903    }
904    fn update_repo(&self, _owner: &str, _repo: &str, _settings: RepoSettings) -> Result<RemoteRepo> {
905        Err(ToriiError::Unsupported("Codeberg update_repo not yet wired".to_string()))
906    }
907    fn get_repo(&self, _owner: &str, _repo: &str) -> Result<RemoteRepo> {
908        Err(ToriiError::Unsupported("Codeberg get_repo not yet wired".to_string()))
909    }
910    fn list_repos(&self) -> Result<Vec<RemoteRepo>> {
911        Err(ToriiError::Unsupported("Codeberg list_repos not yet wired".to_string()))
912    }
913    fn set_visibility(&self, owner: &str, repo: &str, visibility: Visibility) -> Result<()> {
914        // Codeberg is just a Forgejo instance pinned to codeberg.org.
915        gitea_set_visibility("https://codeberg.org", &self.token, owner, repo, visibility, "Codeberg")
916    }
917    fn configure_features(&self, _owner: &str, _repo: &str, _features: RepoFeatures) -> Result<()> {
918        Err(ToriiError::Unsupported("Codeberg configure_features not yet wired".to_string()))
919    }
920}
921
922// ──────────────────────────────────────────────────────────────────────────────
923// Bitbucket Cloud — `PUT /2.0/repositories/{ws}/{repo}` with `is_private`.
924
925#[allow(dead_code)]
926pub struct BitbucketClient { token: Option<String> }
927
928impl BitbucketClient {
929    pub fn new(token: Option<String>) -> Self { Self { token } }
930
931    fn auth(&self) -> Result<String> {
932        let tok = self.token.as_ref().ok_or_else(|| ToriiError::Auth { provider: "bitbucket".into(), message: "Bitbucket token not found. Run: torii auth set bitbucket USERNAME:APP_PASSWORD".to_string() })?;
933        if tok.contains(':') {
934            use base64::Engine;
935            Ok(format!("Basic {}", base64::engine::general_purpose::STANDARD.encode(tok)))
936        } else {
937            Ok(format!("Bearer {}", tok))
938        }
939    }
940}
941
942impl PlatformClient for BitbucketClient {
943    fn create_repo(&self, _n: &str, _d: Option<&str>, _v: Visibility, _ns: Option<&str>) -> Result<RemoteRepo> {
944        Err(ToriiError::Unsupported("Bitbucket create_repo not yet wired".to_string()))
945    }
946    fn delete_repo(&self, _o: &str, _r: &str) -> Result<()> {
947        Err(ToriiError::Unsupported("Bitbucket delete_repo not yet wired".to_string()))
948    }
949    fn update_repo(&self, _o: &str, _r: &str, _s: RepoSettings) -> Result<RemoteRepo> {
950        Err(ToriiError::Unsupported("Bitbucket update_repo not yet wired".to_string()))
951    }
952    fn get_repo(&self, _o: &str, _r: &str) -> Result<RemoteRepo> {
953        Err(ToriiError::Unsupported("Bitbucket get_repo not yet wired".to_string()))
954    }
955    fn list_repos(&self) -> Result<Vec<RemoteRepo>> {
956        Err(ToriiError::Unsupported("Bitbucket list_repos not yet wired".to_string()))
957    }
958    fn set_visibility(&self, owner: &str, repo: &str, visibility: Visibility) -> Result<()> {
959        // Bitbucket: PUT /2.0/repositories/{ws}/{repo} with `is_private`.
960        // "Internal" doesn't exist — collapse to private.
961        let is_private = !matches!(visibility, Visibility::Public);
962        let url = format!("https://api.bitbucket.org/2.0/repositories/{}/{}", owner, repo);
963        let body = serde_json::json!({ "is_private": is_private });
964        let req = crate::http::make_client().put(&url)
965            .header("Authorization", self.auth()?)
966            .header("Accept", "application/json")
967            .json(&body);
968        crate::http::send_empty(req, "Bitbucket set visibility")
969    }
970    fn configure_features(&self, _o: &str, _r: &str, _f: RepoFeatures) -> Result<()> {
971        Err(ToriiError::Unsupported("Bitbucket configure_features not yet wired".to_string()))
972    }
973}
974
975// ──────────────────────────────────────────────────────────────────────────────
976// Sourcehut — `meta.sr.ht` GraphQL endpoint for visibility updates.
977
978#[allow(dead_code)]
979pub struct SourcehutClient { token: Option<String> }
980
981impl SourcehutClient {
982    pub fn new(token: Option<String>) -> Self { Self { token } }
983}
984
985impl PlatformClient for SourcehutClient {
986    fn create_repo(&self, _n: &str, _d: Option<&str>, _v: Visibility, _ns: Option<&str>) -> Result<RemoteRepo> {
987        Err(ToriiError::Unsupported("Sourcehut create_repo not yet wired".to_string()))
988    }
989    fn delete_repo(&self, _o: &str, _r: &str) -> Result<()> {
990        Err(ToriiError::Unsupported("Sourcehut delete_repo not yet wired".to_string()))
991    }
992    fn update_repo(&self, _o: &str, _r: &str, _s: RepoSettings) -> Result<RemoteRepo> {
993        Err(ToriiError::Unsupported("Sourcehut update_repo not yet wired".to_string()))
994    }
995    fn get_repo(&self, _o: &str, _r: &str) -> Result<RemoteRepo> {
996        Err(ToriiError::Unsupported("Sourcehut get_repo not yet wired".to_string()))
997    }
998    fn list_repos(&self) -> Result<Vec<RemoteRepo>> {
999        Err(ToriiError::Unsupported("Sourcehut list_repos not yet wired".to_string()))
1000    }
1001    fn set_visibility(&self, _owner: &str, repo: &str, visibility: Visibility) -> Result<()> {
1002        // git.sr.ht exposes visibility via a GraphQL mutation. Three
1003        // values: PUBLIC, UNLISTED, PRIVATE. We collapse torii's
1004        // (Public, Private, Internal) → (PUBLIC, PRIVATE, UNLISTED).
1005        let tok = self.token.as_ref().ok_or_else(|| ToriiError::Auth { provider: "sourcehut".into(), message: "Sourcehut token not found. Run: torii auth set sourcehut YOUR_TOKEN".to_string() })?;
1006        let target = match visibility {
1007            Visibility::Public   => "PUBLIC",
1008            Visibility::Private  => "PRIVATE",
1009            Visibility::Internal => "UNLISTED",
1010        };
1011        // git.sr.ht GraphQL is at https://git.sr.ht/query
1012        let query = serde_json::json!({
1013            "query": "mutation Update($name: String!, $visibility: Visibility!) { \
1014                       updateRepository(name: $name, input: { visibility: $visibility }) { id } }",
1015            "variables": { "name": repo, "visibility": target }
1016        });
1017        let req = crate::http::make_client().post("https://git.sr.ht/query")
1018            .header("Authorization", format!("Bearer {}", tok))
1019            .header("Accept", "application/json")
1020            .json(&query);
1021        // GraphQL servers always return 200 even on logical errors —
1022        // send_json then check for `errors`.
1023        let resp = crate::http::send_json(req, "Sourcehut set visibility")?;
1024        if let Some(errs) = resp.get("errors").and_then(|e| e.as_array()) {
1025            if !errs.is_empty() {
1026                return Err(ToriiError::MalformedResponse {
1027                    provider: "sourcehut".into(),
1028                    message: format!("GraphQL errors: {}", serde_json::to_string(errs).unwrap_or_default()),
1029                });
1030            }
1031        }
1032        Ok(())
1033    }
1034    fn configure_features(&self, _o: &str, _r: &str, _f: RepoFeatures) -> Result<()> {
1035        Err(ToriiError::Unsupported("Sourcehut configure_features not yet wired".to_string()))
1036    }
1037}
1038
1039// ──────────────────────────────────────────────────────────────────────────────
1040// Azure DevOps — visibility lives at the *project* level on Azure, not
1041// per-repo. We surface that clearly so the user knows where to go.
1042
1043pub struct AzureClient;
1044
1045impl AzureClient { pub fn new() -> Self { Self } }
1046
1047fn azure_visibility_unsupported() -> ToriiError {
1048    ToriiError::Unsupported(
1049        "Azure DevOps repo visibility is controlled at the *project* level, not \
1050         per-repo. Change it from `https://dev.azure.com/{org}/{project}/_settings/` \
1051         → Overview → Visibility. (Individual repos can be disabled but not made \
1052         public independently of their parent project.)".to_string()
1053    )
1054}
1055
1056impl PlatformClient for AzureClient {
1057    fn create_repo(&self, _n: &str, _d: Option<&str>, _v: Visibility, _ns: Option<&str>) -> Result<RemoteRepo> {
1058        Err(ToriiError::Unsupported("Azure DevOps create_repo not yet wired".to_string()))
1059    }
1060    fn delete_repo(&self, _o: &str, _r: &str) -> Result<()> {
1061        Err(ToriiError::Unsupported("Azure DevOps delete_repo not yet wired".to_string()))
1062    }
1063    fn update_repo(&self, _o: &str, _r: &str, _s: RepoSettings) -> Result<RemoteRepo> {
1064        Err(ToriiError::Unsupported("Azure DevOps update_repo not yet wired".to_string()))
1065    }
1066    fn get_repo(&self, _o: &str, _r: &str) -> Result<RemoteRepo> {
1067        Err(ToriiError::Unsupported("Azure DevOps get_repo not yet wired".to_string()))
1068    }
1069    fn list_repos(&self) -> Result<Vec<RemoteRepo>> {
1070        Err(ToriiError::Unsupported("Azure DevOps list_repos not yet wired".to_string()))
1071    }
1072    fn set_visibility(&self, _o: &str, _r: &str, _v: Visibility) -> Result<()> { Err(azure_visibility_unsupported()) }
1073    fn configure_features(&self, _o: &str, _r: &str, _f: RepoFeatures) -> Result<()> {
1074        Err(ToriiError::Unsupported("Azure DevOps configure_features not yet wired".to_string()))
1075    }
1076}
1077
1078// ──────────────────────────────────────────────────────────────────────────────
1079// Radicle — peer-to-peer, no central visibility setting. Replication
1080// is governed by who seeds the project, not by a host-side flag.
1081
1082pub struct RadicleClient;
1083
1084impl RadicleClient { pub fn new() -> Self { Self } }
1085
1086fn radicle_visibility_unsupported() -> ToriiError {
1087    ToriiError::Unsupported(
1088        "Radicle is peer-to-peer and has no central visibility setting. \
1089         Reachability is governed by who seeds the project — make a project \
1090         less discoverable by removing it from seed nodes, not by toggling a flag. \
1091         See `rad node` and `rad inspect`.".to_string()
1092    )
1093}
1094
1095impl PlatformClient for RadicleClient {
1096    fn create_repo(&self, _n: &str, _d: Option<&str>, _v: Visibility, _ns: Option<&str>) -> Result<RemoteRepo> {
1097        Err(ToriiError::Unsupported("Radicle uses `rad init` to create projects locally, not a REST API.".to_string()))
1098    }
1099    fn delete_repo(&self, _o: &str, _r: &str) -> Result<()> {
1100        Err(ToriiError::Unsupported("Radicle has no remote-delete — projects exist as long as someone seeds them.".to_string()))
1101    }
1102    fn update_repo(&self, _o: &str, _r: &str, _s: RepoSettings) -> Result<RemoteRepo> { Err(radicle_visibility_unsupported()) }
1103    fn get_repo(&self, _o: &str, _r: &str) -> Result<RemoteRepo> {
1104        Err(ToriiError::Unsupported("Radicle get_repo not yet wired — use `rad inspect <RID>` directly.".to_string()))
1105    }
1106    fn list_repos(&self) -> Result<Vec<RemoteRepo>> {
1107        Err(ToriiError::Unsupported("Radicle list_repos not yet wired — use `rad ls` directly.".to_string()))
1108    }
1109    fn set_visibility(&self, _o: &str, _r: &str, _v: Visibility) -> Result<()> { Err(radicle_visibility_unsupported()) }
1110    fn configure_features(&self, _o: &str, _r: &str, _f: RepoFeatures) -> Result<()> {
1111        Err(ToriiError::Unsupported("Radicle has no per-repo features knob.".to_string()))
1112    }
1113}