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::types::{AvailableRepository, ProjectRepository};
6use crate::platform::api::PlatformApiClient;
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.split('/').skip(3).collect::<Vec<_>>().join("/").strip_suffix(".git") {
92            return Some(path.to_string());
93        }
94        // Without .git suffix
95        let path: String = url.split('/').skip(3).collect::<Vec<_>>().join("/");
96        if !path.is_empty() {
97            return Some(path);
98        }
99    }
100
101    None
102}
103
104/// Find a repository in the available repositories list by full name
105fn find_in_available<'a>(
106    repo_full_name: &str,
107    available: &'a [AvailableRepository],
108) -> Option<&'a AvailableRepository> {
109    available
110        .iter()
111        .find(|r| r.full_name.eq_ignore_ascii_case(repo_full_name))
112}
113
114/// Check if a repository ID is in the connected list
115fn is_repo_connected(repo_id: i64, connected_ids: &[i64]) -> bool {
116    connected_ids.contains(&repo_id)
117}
118
119/// Extract organization/owner name from a repo full name
120fn extract_org_name(repo_full_name: &str) -> String {
121    repo_full_name
122        .split('/')
123        .next()
124        .unwrap_or(repo_full_name)
125        .to_string()
126}
127
128/// Prompt user to connect a detected repository
129fn prompt_connect_repository(
130    available: &AvailableRepository,
131    connected: &[ProjectRepository],
132) -> RepositorySelectionResult {
133    println!(
134        "\n{} Detected repository: {}",
135        "→".cyan(),
136        available.full_name.cyan()
137    );
138    println!(
139        "{}",
140        "This repository is not connected to the project.".dimmed()
141    );
142
143    // Build options
144    let connect_option = format!("Connect {} (detected)", available.full_name);
145    let mut options = vec![connect_option];
146
147    // Add connected repos as alternatives
148    for repo in connected {
149        options.push(format!(
150            "Use {} (already connected)",
151            repo.repository_full_name
152        ));
153    }
154
155    let selection = Select::new("What would you like to do?", options)
156        .with_render_config(wizard_render_config())
157        .with_help_message("Use ↑/↓ to navigate, Enter to select")
158        .prompt();
159
160    match selection {
161        Ok(choice) if choice.starts_with("Connect") => {
162            RepositorySelectionResult::ConnectNew(available.clone())
163        }
164        Ok(choice) => {
165            // Find which connected repo was selected
166            let repo_name = choice
167                .split(" (already connected)")
168                .next()
169                .unwrap_or("")
170                .trim()
171                .trim_start_matches("Use ");
172            if let Some(repo) = connected
173                .iter()
174                .find(|r| r.repository_full_name == repo_name)
175            {
176                RepositorySelectionResult::Selected(repo.clone())
177            } else {
178                RepositorySelectionResult::Cancelled
179            }
180        }
181        Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
182            RepositorySelectionResult::Cancelled
183        }
184        Err(_) => RepositorySelectionResult::Cancelled,
185    }
186}
187
188/// Prompt user to install GitHub App
189async fn prompt_github_app_install(
190    client: &PlatformApiClient,
191    org_name: &str,
192) -> RepositorySelectionResult {
193    println!(
194        "\n{} GitHub App not installed for: {}",
195        "⚠".yellow(),
196        org_name.cyan()
197    );
198    println!(
199        "{}",
200        "The Syncable GitHub App needs to be installed to connect this repository.".dimmed()
201    );
202
203    match client.get_github_installation_url().await {
204        Ok(response) => {
205            let install = Confirm::new("Open browser to install GitHub App?")
206                .with_default(true)
207                .prompt();
208
209            if let Ok(true) = install {
210                if webbrowser::open(&response.installation_url).is_ok() {
211                    println!(
212                        "{} Opened browser. Complete the installation, then run this command again.",
213                        "→".cyan()
214                    );
215                } else {
216                    println!("Visit: {}", response.installation_url);
217                }
218            }
219            RepositorySelectionResult::NeedsGitHubApp {
220                installation_url: response.installation_url,
221                org_name: org_name.to_string(),
222            }
223        }
224        Err(e) => RepositorySelectionResult::Error(format!("Failed to get installation URL: {}", e)),
225    }
226}
227
228/// Select repository for deployment
229///
230/// Smart repository selection with connection flow:
231/// 1. Check for GitHub App installations
232/// 2. Fetch connected and available repositories
233/// 3. Detect local git remote and match against repos
234/// 4. Offer to connect if local repo available but not connected
235/// 5. Fall back to manual selection from available repos
236pub async fn select_repository(
237    client: &PlatformApiClient,
238    project_id: &str,
239    project_path: &Path,
240) -> RepositorySelectionResult {
241    // Check for GitHub App installations first
242    let installations = match client.list_github_installations().await {
243        Ok(response) => response.installations,
244        Err(e) => {
245            return RepositorySelectionResult::Error(format!(
246                "Failed to fetch GitHub installations: {}",
247                e
248            ));
249        }
250    };
251
252    // If no installations, prompt to install GitHub App
253    if installations.is_empty() {
254        println!(
255            "\n{} No GitHub App installations found.",
256            "⚠".yellow()
257        );
258        match client.get_github_installation_url().await {
259            Ok(response) => {
260                println!("Install the Syncable GitHub App to connect repositories.");
261                let install = Confirm::new("Open browser to install GitHub App?")
262                    .with_default(true)
263                    .prompt();
264
265                if let Ok(true) = install {
266                    if webbrowser::open(&response.installation_url).is_ok() {
267                        println!(
268                            "{} Opened browser. Complete the installation, then run this command again.",
269                            "→".cyan()
270                        );
271                    } else {
272                        println!("Visit: {}", response.installation_url);
273                    }
274                }
275                return RepositorySelectionResult::NoInstallations {
276                    installation_url: response.installation_url,
277                };
278            }
279            Err(e) => {
280                return RepositorySelectionResult::Error(format!(
281                    "Failed to get installation URL: {}",
282                    e
283                ));
284            }
285        }
286    }
287
288    // Fetch connected repositories
289    let repos_response = match client.list_project_repositories(project_id).await {
290        Ok(response) => response,
291        Err(e) => {
292            return RepositorySelectionResult::Error(format!(
293                "Failed to fetch repositories: {}",
294                e
295            ));
296        }
297    };
298    let connected_repos = repos_response.repositories;
299
300    // Fetch available repositories (from all GitHub installations)
301    let available_response = match client
302        .list_available_repositories(Some(project_id), None, None)
303        .await
304    {
305        Ok(response) => response,
306        Err(e) => {
307            return RepositorySelectionResult::Error(format!(
308                "Failed to fetch available repositories: {}",
309                e
310            ));
311        }
312    };
313    let available_repos = available_response.repositories;
314    let connected_ids = available_response.connected_repositories;
315
316    // Try to auto-detect from git remote
317    let detected_repo_name = detect_git_remote(project_path).and_then(|url| parse_repo_from_url(&url));
318
319    if let Some(ref local_repo_name) = detected_repo_name {
320        // Check if already connected to this project
321        if let Some(connected) = connected_repos
322            .iter()
323            .find(|r| r.repository_full_name.eq_ignore_ascii_case(local_repo_name))
324        {
325            // Auto-select connected repo
326            println!(
327                "\n{} Using detected repository: {}",
328                "✓".green(),
329                connected.repository_full_name.cyan()
330            );
331            return RepositorySelectionResult::Selected(connected.clone());
332        }
333
334        // Check if available but not connected
335        if let Some(available) = find_in_available(local_repo_name, &available_repos) {
336            if !is_repo_connected(available.id, &connected_ids) {
337                // Offer to connect this repository
338                return prompt_connect_repository(available, &connected_repos);
339            }
340        }
341
342        // Local repo not in available list - might need GitHub App for this org
343        let org_name = extract_org_name(local_repo_name);
344        let org_has_installation = installations
345            .iter()
346            .any(|i| i.account_login.eq_ignore_ascii_case(&org_name));
347
348        if !org_has_installation {
349            // Need to install GitHub App for this organization
350            return prompt_github_app_install(client, &org_name).await;
351        }
352
353        // Org has installation but repo not available - might be private or restricted
354        println!(
355            "\n{} Repository {} not accessible.",
356            "⚠".yellow(),
357            local_repo_name.cyan()
358        );
359        println!(
360            "{}",
361            "Check that the Syncable GitHub App has access to this repository.".dimmed()
362        );
363    }
364
365    // No local repo detected or couldn't match - show selection UI
366    if connected_repos.is_empty() && available_repos.is_empty() {
367        println!(
368            "\n{} No repositories available.",
369            "⚠".yellow()
370        );
371        println!(
372            "{}",
373            "Connect a repository using the GitHub App installation.".dimmed()
374        );
375        return RepositorySelectionResult::NoRepositories;
376    }
377
378    display_step_header(
379        0,
380        "Select Repository",
381        "Choose which repository to deploy from.",
382    );
383
384    // Build options: connected repos first, then available (unconnected) repos
385    let mut options: Vec<RepositoryOption> = connected_repos
386        .iter()
387        .map(|repo| {
388            let is_detected = detected_repo_name
389                .as_ref()
390                .map(|name| repo.repository_full_name.eq_ignore_ascii_case(name))
391                .unwrap_or(false);
392            RepositoryOption {
393                repository: repo.clone(),
394                is_detected,
395            }
396        })
397        .collect();
398
399    // Put detected repo first if found
400    options.sort_by(|a, b| b.is_detected.cmp(&a.is_detected));
401
402    if options.is_empty() {
403        // No connected repos - offer available repos to connect
404        println!(
405            "{}",
406            "No repositories connected yet. Select one to connect:".dimmed()
407        );
408
409        let available_options: Vec<String> = available_repos
410            .iter()
411            .filter(|r| !is_repo_connected(r.id, &connected_ids))
412            .map(|r| r.full_name.clone())
413            .collect();
414
415        if available_options.is_empty() {
416            return RepositorySelectionResult::NoRepositories;
417        }
418
419        let selection = Select::new("Select repository to connect:", available_options)
420            .with_render_config(wizard_render_config())
421            .with_help_message("Use ↑/↓ to navigate, Enter to select")
422            .prompt();
423
424        match selection {
425            Ok(selected_name) => {
426                if let Some(available) = available_repos.iter().find(|r| r.full_name == selected_name)
427                {
428                    return RepositorySelectionResult::ConnectNew(available.clone());
429                }
430                RepositorySelectionResult::Cancelled
431            }
432            Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
433                RepositorySelectionResult::Cancelled
434            }
435            Err(_) => RepositorySelectionResult::Cancelled,
436        }
437    } else {
438        // Show connected repos for selection
439        let selection = Select::new("Select repository:", options)
440            .with_render_config(wizard_render_config())
441            .with_help_message("Use ↑/↓ to navigate, Enter to select")
442            .prompt();
443
444        match selection {
445            Ok(selected) => {
446                println!(
447                    "\n{} Selected repository: {}",
448                    "✓".green(),
449                    selected.repository.repository_full_name.cyan()
450                );
451                RepositorySelectionResult::Selected(selected.repository)
452            }
453            Err(InquireError::OperationCanceled) | Err(InquireError::OperationInterrupted) => {
454                RepositorySelectionResult::Cancelled
455            }
456            Err(_) => RepositorySelectionResult::Cancelled,
457        }
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn test_parse_repo_from_ssh_url() {
467        let url = "git@github.com:owner/my-repo.git";
468        assert_eq!(parse_repo_from_url(url), Some("owner/my-repo".to_string()));
469    }
470
471    #[test]
472    fn test_parse_repo_from_https_url() {
473        let url = "https://github.com/owner/my-repo.git";
474        assert_eq!(parse_repo_from_url(url), Some("owner/my-repo".to_string()));
475    }
476
477    #[test]
478    fn test_parse_repo_from_https_url_no_git() {
479        let url = "https://github.com/owner/my-repo";
480        assert_eq!(parse_repo_from_url(url), Some("owner/my-repo".to_string()));
481    }
482
483    #[test]
484    fn test_repository_selection_result_variants() {
485        let repo = ProjectRepository {
486            id: "test".to_string(),
487            project_id: "proj".to_string(),
488            repository_id: 123,
489            repository_name: "test".to_string(),
490            repository_full_name: "owner/test".to_string(),
491            repository_owner: "owner".to_string(),
492            repository_private: false,
493            default_branch: Some("main".to_string()),
494            is_active: true,
495            connection_type: None,
496            repository_type: None,
497            is_primary_git_ops: None,
498            github_installation_id: None,
499            user_id: None,
500            created_at: None,
501            updated_at: None,
502        };
503        let available = AvailableRepository {
504            id: 456,
505            name: "test-repo".to_string(),
506            full_name: "owner/test-repo".to_string(),
507            owner: Some("owner".to_string()),
508            private: false,
509            default_branch: Some("main".to_string()),
510            description: None,
511            html_url: None,
512            installation_id: Some(789),
513        };
514        let _ = RepositorySelectionResult::Selected(repo);
515        let _ = RepositorySelectionResult::ConnectNew(available);
516        let _ = RepositorySelectionResult::NeedsGitHubApp {
517            installation_url: "https://github.com/apps/syncable".to_string(),
518            org_name: "my-org".to_string(),
519        };
520        let _ = RepositorySelectionResult::NoInstallations {
521            installation_url: "https://github.com/apps/syncable".to_string(),
522        };
523        let _ = RepositorySelectionResult::NoRepositories;
524        let _ = RepositorySelectionResult::Cancelled;
525        let _ = RepositorySelectionResult::Error("test".to_string());
526    }
527
528    #[test]
529    fn test_extract_org_name() {
530        assert_eq!(extract_org_name("owner/repo"), "owner");
531        assert_eq!(extract_org_name("my-org/my-app"), "my-org");
532        assert_eq!(extract_org_name("repo-only"), "repo-only");
533    }
534
535    #[test]
536    fn test_is_repo_connected() {
537        let connected = vec![1, 2, 3, 5];
538        assert!(is_repo_connected(1, &connected));
539        assert!(is_repo_connected(3, &connected));
540        assert!(!is_repo_connected(4, &connected));
541        assert!(!is_repo_connected(100, &connected));
542    }
543
544    #[test]
545    fn test_find_in_available() {
546        let available = vec![
547            AvailableRepository {
548                id: 1,
549                name: "repo-a".to_string(),
550                full_name: "owner/repo-a".to_string(),
551                owner: Some("owner".to_string()),
552                private: false,
553                default_branch: Some("main".to_string()),
554                description: None,
555                html_url: None,
556                installation_id: Some(100),
557            },
558            AvailableRepository {
559                id: 2,
560                name: "repo-b".to_string(),
561                full_name: "other/repo-b".to_string(),
562                owner: Some("other".to_string()),
563                private: true,
564                default_branch: Some("main".to_string()),
565                description: None,
566                html_url: None,
567                installation_id: Some(200),
568            },
569        ];
570
571        let found = find_in_available("owner/repo-a", &available);
572        assert!(found.is_some());
573        assert_eq!(found.unwrap().id, 1);
574
575        // Case insensitive
576        let found_case = find_in_available("OWNER/REPO-A", &available);
577        assert!(found_case.is_some());
578
579        let not_found = find_in_available("nonexistent/repo", &available);
580        assert!(not_found.is_none());
581    }
582}