1use 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#[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.split('/').skip(3).collect::<Vec<_>>().join("/").strip_suffix(".git") {
92 return Some(path.to_string());
93 }
94 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
104fn 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
114fn is_repo_connected(repo_id: i64, connected_ids: &[i64]) -> bool {
116 connected_ids.contains(&repo_id)
117}
118
119fn 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
128fn 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 let connect_option = format!("Connect {} (detected)", available.full_name);
145 let mut options = vec![connect_option];
146
147 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 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
188async 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
228pub async fn select_repository(
237 client: &PlatformApiClient,
238 project_id: &str,
239 project_path: &Path,
240) -> RepositorySelectionResult {
241 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 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 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 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 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 if let Some(connected) = connected_repos
322 .iter()
323 .find(|r| r.repository_full_name.eq_ignore_ascii_case(local_repo_name))
324 {
325 println!(
327 "\n{} Using detected repository: {}",
328 "✓".green(),
329 connected.repository_full_name.cyan()
330 );
331 return RepositorySelectionResult::Selected(connected.clone());
332 }
333
334 if let Some(available) = find_in_available(local_repo_name, &available_repos) {
336 if !is_repo_connected(available.id, &connected_ids) {
337 return prompt_connect_repository(available, &connected_repos);
339 }
340 }
341
342 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 return prompt_github_app_install(client, &org_name).await;
351 }
352
353 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 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 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 options.sort_by(|a, b| b.is_detected.cmp(&a.is_detected));
401
402 if options.is_empty() {
403 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 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 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}