1use crate::platform::api::PlatformApiClient;
6use crate::platform::api::types::{AvailableRepository, ProjectRepository};
7use crate::wizard::render::{display_step_header, wizard_render_config};
8use colored::Colorize;
9use inquire::{Confirm, InquireError, Select};
10use std::fmt;
11use std::path::Path;
12use std::process::Command;
13
14#[derive(Debug, Clone)]
16pub enum RepositorySelectionResult {
17 Selected(ProjectRepository),
19 ConnectNew(AvailableRepository),
21 NeedsGitHubApp {
23 installation_url: String,
24 org_name: String,
25 },
26 NoInstallations { installation_url: String },
28 NoRepositories,
30 Cancelled,
32 Error(String),
34}
35
36struct RepositoryOption {
38 repository: ProjectRepository,
39 is_detected: bool,
40}
41
42impl fmt::Display for RepositoryOption {
43 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44 let marker = if self.is_detected { " (detected)" } else { "" };
45 write!(
46 f,
47 "{}{} {}",
48 self.repository.repository_full_name.cyan(),
49 marker.green(),
50 self.repository
51 .default_branch
52 .as_deref()
53 .unwrap_or("main")
54 .dimmed()
55 )
56 }
57}
58
59fn detect_git_remote(project_path: &Path) -> Option<String> {
61 let output = Command::new("git")
62 .args(["remote", "get-url", "origin"])
63 .current_dir(project_path)
64 .output()
65 .ok()?;
66
67 if output.status.success() {
68 let url = String::from_utf8(output.stdout).ok()?;
69 Some(url.trim().to_string())
70 } else {
71 None
72 }
73}
74
75fn parse_repo_from_url(url: &str) -> Option<String> {
78 let url = url.trim();
79
80 if url.starts_with("git@") {
82 let parts: Vec<&str> = url.split(':').collect();
83 if parts.len() == 2 {
84 let path = parts[1].trim_end_matches(".git");
85 return Some(path.to_string());
86 }
87 }
88
89 if url.starts_with("https://") || url.starts_with("http://") {
91 if let Some(path) = url
92 .split('/')
93 .skip(3)
94 .collect::<Vec<_>>()
95 .join("/")
96 .strip_suffix(".git")
97 {
98 return Some(path.to_string());
99 }
100 let path: String = url.split('/').skip(3).collect::<Vec<_>>().join("/");
102 if !path.is_empty() {
103 return Some(path);
104 }
105 }
106
107 None
108}
109
110fn find_in_available<'a>(
112 repo_full_name: &str,
113 available: &'a [AvailableRepository],
114) -> Option<&'a AvailableRepository> {
115 available
116 .iter()
117 .find(|r| r.full_name.eq_ignore_ascii_case(repo_full_name))
118}
119
120fn is_repo_connected(repo_id: i64, connected_ids: &[i64]) -> bool {
122 connected_ids.contains(&repo_id)
123}
124
125fn extract_org_name(repo_full_name: &str) -> String {
127 repo_full_name
128 .split('/')
129 .next()
130 .unwrap_or(repo_full_name)
131 .to_string()
132}
133
134fn prompt_connect_repository(
136 available: &AvailableRepository,
137 connected: &[ProjectRepository],
138) -> RepositorySelectionResult {
139 println!(
140 "\n{} Detected repository: {}",
141 "→".cyan(),
142 available.full_name.cyan()
143 );
144 println!(
145 "{}",
146 "This repository is not connected to the project.".dimmed()
147 );
148
149 let connect_option = format!("Connect {} (detected)", available.full_name);
151 let mut options = vec![connect_option];
152
153 for repo in connected {
155 options.push(format!(
156 "Use {} (already connected)",
157 repo.repository_full_name
158 ));
159 }
160
161 let selection = Select::new("What would you like to do?", options)
162 .with_render_config(wizard_render_config())
163 .with_help_message("Use ↑/↓ to navigate, Enter to select")
164 .prompt();
165
166 match selection {
167 Ok(choice) if choice.starts_with("Connect") => {
168 RepositorySelectionResult::ConnectNew(available.clone())
169 }
170 Ok(choice) => {
171 let repo_name = choice
173 .split(" (already connected)")
174 .next()
175 .unwrap_or("")
176 .trim()
177 .trim_start_matches("Use ");
178 if let Some(repo) = connected
179 .iter()
180 .find(|r| r.repository_full_name == repo_name)
181 {
182 RepositorySelectionResult::Selected(repo.clone())
183 } else {
184 RepositorySelectionResult::Cancelled
185 }
186 }
187 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
188 RepositorySelectionResult::Cancelled
189 }
190 Err(_) => RepositorySelectionResult::Cancelled,
191 }
192}
193
194async fn prompt_github_app_install(
196 client: &PlatformApiClient,
197 org_name: &str,
198) -> RepositorySelectionResult {
199 println!(
200 "\n{} GitHub App not installed for: {}",
201 "⚠".yellow(),
202 org_name.cyan()
203 );
204 println!(
205 "{}",
206 "The Syncable GitHub App needs to be installed to connect this repository.".dimmed()
207 );
208
209 match client.get_github_installation_url().await {
210 Ok(response) => {
211 let install = Confirm::new("Open browser to install GitHub App?")
212 .with_default(true)
213 .prompt();
214
215 if let Ok(true) = install {
216 if webbrowser::open(&response.installation_url).is_ok() {
217 println!(
218 "{} Opened browser. Complete the installation, then run this command again.",
219 "→".cyan()
220 );
221 } else {
222 println!("Visit: {}", response.installation_url);
223 }
224 }
225 RepositorySelectionResult::NeedsGitHubApp {
226 installation_url: response.installation_url,
227 org_name: org_name.to_string(),
228 }
229 }
230 Err(e) => {
231 RepositorySelectionResult::Error(format!("Failed to get installation URL: {}", e))
232 }
233 }
234}
235
236pub async fn select_repository(
245 client: &PlatformApiClient,
246 project_id: &str,
247 project_path: &Path,
248) -> RepositorySelectionResult {
249 let installations = match client.list_github_installations().await {
251 Ok(response) => response.installations,
252 Err(e) => {
253 return RepositorySelectionResult::Error(format!(
254 "Failed to fetch GitHub installations: {}",
255 e
256 ));
257 }
258 };
259
260 if installations.is_empty() {
262 println!("\n{} No GitHub App installations found.", "⚠".yellow());
263 match client.get_github_installation_url().await {
264 Ok(response) => {
265 println!("Install the Syncable GitHub App to connect repositories.");
266 let install = Confirm::new("Open browser to install GitHub App?")
267 .with_default(true)
268 .prompt();
269
270 if let Ok(true) = install {
271 if webbrowser::open(&response.installation_url).is_ok() {
272 println!(
273 "{} Opened browser. Complete the installation, then run this command again.",
274 "→".cyan()
275 );
276 } else {
277 println!("Visit: {}", response.installation_url);
278 }
279 }
280 return RepositorySelectionResult::NoInstallations {
281 installation_url: response.installation_url,
282 };
283 }
284 Err(e) => {
285 return RepositorySelectionResult::Error(format!(
286 "Failed to get installation URL: {}",
287 e
288 ));
289 }
290 }
291 }
292
293 let repos_response = match client.list_project_repositories(project_id).await {
295 Ok(response) => response,
296 Err(e) => {
297 return RepositorySelectionResult::Error(format!(
298 "Failed to fetch repositories: {}",
299 e
300 ));
301 }
302 };
303 let connected_repos = repos_response.repositories;
304
305 let available_response = match client
307 .list_available_repositories(Some(project_id), None, None)
308 .await
309 {
310 Ok(response) => response,
311 Err(e) => {
312 return RepositorySelectionResult::Error(format!(
313 "Failed to fetch available repositories: {}",
314 e
315 ));
316 }
317 };
318 let available_repos = available_response.repositories;
319 let connected_ids = available_response.connected_repositories;
320
321 let detected_repo_name =
323 detect_git_remote(project_path).and_then(|url| parse_repo_from_url(&url));
324
325 if let Some(ref local_repo_name) = detected_repo_name {
326 if let Some(connected) = connected_repos
328 .iter()
329 .find(|r| r.repository_full_name.eq_ignore_ascii_case(local_repo_name))
330 {
331 println!(
333 "\n{} Using detected repository: {}",
334 "✓".green(),
335 connected.repository_full_name.cyan()
336 );
337 return RepositorySelectionResult::Selected(connected.clone());
338 }
339
340 if let Some(available) = find_in_available(local_repo_name, &available_repos) {
342 if !is_repo_connected(available.id, &connected_ids) {
343 return prompt_connect_repository(available, &connected_repos);
345 }
346 }
347
348 let org_name = extract_org_name(local_repo_name);
350 let org_has_installation = installations
351 .iter()
352 .any(|i| i.account_login.eq_ignore_ascii_case(&org_name));
353
354 if !org_has_installation {
355 return prompt_github_app_install(client, &org_name).await;
357 }
358
359 println!(
361 "\n{} Repository {} not accessible.",
362 "⚠".yellow(),
363 local_repo_name.cyan()
364 );
365 println!(
366 "{}",
367 "Check that the Syncable GitHub App has access to this repository.".dimmed()
368 );
369 }
370
371 if connected_repos.is_empty() && available_repos.is_empty() {
373 println!("\n{} No repositories available.", "⚠".yellow());
374 println!(
375 "{}",
376 "Connect a repository using the GitHub App installation.".dimmed()
377 );
378 return RepositorySelectionResult::NoRepositories;
379 }
380
381 display_step_header(
382 0,
383 "Select Repository",
384 "Choose which repository to deploy from.",
385 );
386
387 let mut options: Vec<RepositoryOption> = connected_repos
389 .iter()
390 .map(|repo| {
391 let is_detected = detected_repo_name
392 .as_ref()
393 .map(|name| repo.repository_full_name.eq_ignore_ascii_case(name))
394 .unwrap_or(false);
395 RepositoryOption {
396 repository: repo.clone(),
397 is_detected,
398 }
399 })
400 .collect();
401
402 options.sort_by(|a, b| b.is_detected.cmp(&a.is_detected));
404
405 if options.is_empty() {
406 println!(
408 "{}",
409 "No repositories connected yet. Select one to connect:".dimmed()
410 );
411
412 let available_options: Vec<String> = available_repos
413 .iter()
414 .filter(|r| !is_repo_connected(r.id, &connected_ids))
415 .map(|r| r.full_name.clone())
416 .collect();
417
418 if available_options.is_empty() {
419 return RepositorySelectionResult::NoRepositories;
420 }
421
422 let selection = Select::new("Select repository to connect:", available_options)
423 .with_render_config(wizard_render_config())
424 .with_help_message("Use ↑/↓ to navigate, Enter to select")
425 .prompt();
426
427 match selection {
428 Ok(selected_name) => {
429 if let Some(available) = available_repos
430 .iter()
431 .find(|r| r.full_name == selected_name)
432 {
433 return RepositorySelectionResult::ConnectNew(available.clone());
434 }
435 RepositorySelectionResult::Cancelled
436 }
437 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
438 RepositorySelectionResult::Cancelled
439 }
440 Err(_) => RepositorySelectionResult::Cancelled,
441 }
442 } else {
443 let selection = Select::new("Select repository:", options)
445 .with_render_config(wizard_render_config())
446 .with_help_message("Use ↑/↓ to navigate, Enter to select")
447 .prompt();
448
449 match selection {
450 Ok(selected) => {
451 println!(
452 "\n{} Selected repository: {}",
453 "✓".green(),
454 selected.repository.repository_full_name.cyan()
455 );
456 RepositorySelectionResult::Selected(selected.repository)
457 }
458 Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
459 RepositorySelectionResult::Cancelled
460 }
461 Err(_) => RepositorySelectionResult::Cancelled,
462 }
463 }
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn test_parse_repo_from_ssh_url() {
472 let url = "git@github.com:owner/my-repo.git";
473 assert_eq!(parse_repo_from_url(url), Some("owner/my-repo".to_string()));
474 }
475
476 #[test]
477 fn test_parse_repo_from_https_url() {
478 let url = "https://github.com/owner/my-repo.git";
479 assert_eq!(parse_repo_from_url(url), Some("owner/my-repo".to_string()));
480 }
481
482 #[test]
483 fn test_parse_repo_from_https_url_no_git() {
484 let url = "https://github.com/owner/my-repo";
485 assert_eq!(parse_repo_from_url(url), Some("owner/my-repo".to_string()));
486 }
487
488 #[test]
489 fn test_repository_selection_result_variants() {
490 let repo = ProjectRepository {
491 id: "test".to_string(),
492 project_id: "proj".to_string(),
493 repository_id: 123,
494 repository_name: "test".to_string(),
495 repository_full_name: "owner/test".to_string(),
496 repository_owner: "owner".to_string(),
497 repository_private: false,
498 default_branch: Some("main".to_string()),
499 is_active: true,
500 connection_type: None,
501 repository_type: None,
502 is_primary_git_ops: None,
503 github_installation_id: None,
504 user_id: None,
505 created_at: None,
506 updated_at: None,
507 };
508 let available = AvailableRepository {
509 id: 456,
510 name: "test-repo".to_string(),
511 full_name: "owner/test-repo".to_string(),
512 owner: Some("owner".to_string()),
513 private: false,
514 default_branch: Some("main".to_string()),
515 description: None,
516 html_url: None,
517 installation_id: Some(789),
518 };
519 let _ = RepositorySelectionResult::Selected(repo);
520 let _ = RepositorySelectionResult::ConnectNew(available);
521 let _ = RepositorySelectionResult::NeedsGitHubApp {
522 installation_url: "https://github.com/apps/syncable".to_string(),
523 org_name: "my-org".to_string(),
524 };
525 let _ = RepositorySelectionResult::NoInstallations {
526 installation_url: "https://github.com/apps/syncable".to_string(),
527 };
528 let _ = RepositorySelectionResult::NoRepositories;
529 let _ = RepositorySelectionResult::Cancelled;
530 let _ = RepositorySelectionResult::Error("test".to_string());
531 }
532
533 #[test]
534 fn test_extract_org_name() {
535 assert_eq!(extract_org_name("owner/repo"), "owner");
536 assert_eq!(extract_org_name("my-org/my-app"), "my-org");
537 assert_eq!(extract_org_name("repo-only"), "repo-only");
538 }
539
540 #[test]
541 fn test_is_repo_connected() {
542 let connected = vec![1, 2, 3, 5];
543 assert!(is_repo_connected(1, &connected));
544 assert!(is_repo_connected(3, &connected));
545 assert!(!is_repo_connected(4, &connected));
546 assert!(!is_repo_connected(100, &connected));
547 }
548
549 #[test]
550 fn test_find_in_available() {
551 let available = vec![
552 AvailableRepository {
553 id: 1,
554 name: "repo-a".to_string(),
555 full_name: "owner/repo-a".to_string(),
556 owner: Some("owner".to_string()),
557 private: false,
558 default_branch: Some("main".to_string()),
559 description: None,
560 html_url: None,
561 installation_id: Some(100),
562 },
563 AvailableRepository {
564 id: 2,
565 name: "repo-b".to_string(),
566 full_name: "other/repo-b".to_string(),
567 owner: Some("other".to_string()),
568 private: true,
569 default_branch: Some("main".to_string()),
570 description: None,
571 html_url: None,
572 installation_id: Some(200),
573 },
574 ];
575
576 let found = find_in_available("owner/repo-a", &available);
577 assert!(found.is_some());
578 assert_eq!(found.unwrap().id, 1);
579
580 let found_case = find_in_available("OWNER/REPO-A", &available);
582 assert!(found_case.is_some());
583
584 let not_found = find_in_available("nonexistent/repo", &available);
585 assert!(not_found.is_none());
586 }
587}