Skip to main content

syncable_cli/wizard/
repository_selection.rs

1//! Repository selection step for the deployment wizard
2//!
3//! Detects the repository from local git remote or asks user to select.
4
5use 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/// Result of repository selection step
15#[derive(Debug, Clone)]
16pub enum RepositorySelectionResult {
17    /// User selected a repository (already connected)
18    Selected(ProjectRepository),
19    /// User chose to connect a new repository
20    ConnectNew(AvailableRepository),
21    /// Need GitHub App installation for this org
22    NeedsGitHubApp {
23        installation_url: String,
24        org_name: String,
25    },
26    /// No GitHub App installations found
27    NoInstallations { installation_url: String },
28    /// No repositories connected to project
29    NoRepositories,
30    /// User cancelled the wizard
31    Cancelled,
32    /// An error occurred
33    Error(String),
34}
35
36/// Wrapper for displaying repository options in the selection menu
37struct 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
59/// Detect the git remote URL from the current directory
60fn 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
75/// Parse repository full name from git remote URL
76/// Handles both SSH (git@github.com:owner/repo.git) and HTTPS (https://github.com/owner/repo.git)
77fn parse_repo_from_url(url: &str) -> Option<String> {
78    let url = url.trim();
79
80    // SSH format: git@github.com:owner/repo.git
81    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    // HTTPS format: https://github.com/owner/repo.git
90    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        // Without .git suffix
101        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
110/// Find a repository in the available repositories list by full name
111fn 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
120/// Check if a repository ID is in the connected list
121fn is_repo_connected(repo_id: i64, connected_ids: &[i64]) -> bool {
122    connected_ids.contains(&repo_id)
123}
124
125/// Extract organization/owner name from a repo full name
126fn 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
134/// Prompt user to connect a detected repository
135fn 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    // Build options
150    let connect_option = format!("Connect {} (detected)", available.full_name);
151    let mut options = vec![connect_option];
152
153    // Add connected repos as alternatives
154    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            // Find which connected repo was selected
172            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
194/// Prompt user to install GitHub App
195async 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
236/// Select repository for deployment
237///
238/// Smart repository selection with connection flow:
239/// 1. Check for GitHub App installations
240/// 2. Fetch connected and available repositories
241/// 3. Detect local git remote and match against repos
242/// 4. Offer to connect if local repo available but not connected
243/// 5. Fall back to manual selection from available repos
244pub async fn select_repository(
245    client: &PlatformApiClient,
246    project_id: &str,
247    project_path: &Path,
248) -> RepositorySelectionResult {
249    // Check for GitHub App installations first
250    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 no installations, prompt to install GitHub App
261    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    // Fetch connected repositories
294    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    // Fetch available repositories (from all GitHub installations)
306    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    // Try to auto-detect from git remote
322    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        // Check if already connected to this project
327        if let Some(connected) = connected_repos
328            .iter()
329            .find(|r| r.repository_full_name.eq_ignore_ascii_case(local_repo_name))
330        {
331            // Auto-select connected repo
332            println!(
333                "\n{} Using detected repository: {}",
334                "✓".green(),
335                connected.repository_full_name.cyan()
336            );
337            return RepositorySelectionResult::Selected(connected.clone());
338        }
339
340        // Check if available but not connected
341        if let Some(available) = find_in_available(local_repo_name, &available_repos) {
342            if !is_repo_connected(available.id, &connected_ids) {
343                // Offer to connect this repository
344                return prompt_connect_repository(available, &connected_repos);
345            }
346        }
347
348        // Local repo not in available list - might need GitHub App for this org
349        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            // Need to install GitHub App for this organization
356            return prompt_github_app_install(client, &org_name).await;
357        }
358
359        // Org has installation but repo not available - might be private or restricted
360        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    // No local repo detected or couldn't match - show selection UI
372    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    // Build options: connected repos first, then available (unconnected) repos
388    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    // Put detected repo first if found
403    options.sort_by(|a, b| b.is_detected.cmp(&a.is_detected));
404
405    if options.is_empty() {
406        // No connected repos - offer available repos to connect
407        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        // Show connected repos for selection
444        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        // Case insensitive
581        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}