1use serde::{Deserialize, Serialize};
2use crate::error::{Result, ToriiError};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6pub enum Visibility {
7 Public,
8 Private,
9 Internal, }
11
12#[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
24pub trait PlatformClient {
26 fn create_repo(&self, name: &str, description: Option<&str>, visibility: Visibility, namespace: Option<&str>) -> Result<RemoteRepo>;
32
33 fn delete_repo(&self, owner: &str, repo: &str) -> Result<()>;
35
36 fn update_repo(&self, owner: &str, repo: &str, settings: RepoSettings) -> Result<RemoteRepo>;
38
39 fn get_repo(&self, owner: &str, repo: &str) -> Result<RemoteRepo>;
41
42 fn list_repos(&self) -> Result<Vec<RemoteRepo>>;
44
45 fn set_visibility(&self, owner: &str, repo: &str, visibility: Visibility) -> Result<()>;
47
48 fn configure_features(&self, owner: &str, repo: &str, features: RepoFeatures) -> Result<()>;
50}
51
52#[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#[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#[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 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 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 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 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 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
335pub 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, "visibility": visibility_str,
373 });
374
375 if let Some(desc) = description {
376 body["description"] = serde_json::json!(desc);
377 }
378
379 let client = reqwest::blocking::Client::new();
382 if let Some(ns) = namespace {
383 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 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
716pub 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 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#[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
816fn 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 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 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#[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 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#[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 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 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 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
1039pub 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
1078pub 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}