Skip to main content

orch/cli/
mod.rs

1pub mod chat;
2pub mod commit;
3pub mod cooldown;
4pub mod cost;
5pub mod dashboard;
6pub mod doctor;
7pub mod events;
8pub mod job;
9pub mod ndjson;
10pub mod notify;
11pub mod prune;
12pub mod service;
13pub mod session;
14pub mod stats;
15pub mod stream;
16pub mod task;
17pub mod tree;
18pub mod webhook;
19
20use crate::cmd::SyncCommandErrorContext;
21use crate::config;
22use crate::engine::tasks::TaskManager;
23use anyhow::Context;
24use std::sync::Arc;
25
26/// Initialize orch for a project.
27pub fn init(repo: Option<String>) -> anyhow::Result<()> {
28    let orch_home = crate::home::orch_home()?;
29    std::fs::create_dir_all(&orch_home)?;
30
31    let config_path = orch_home.join("config.yml");
32
33    let repo_value = match repo {
34        Some(r) => {
35            // Normalize: accept SSH URLs, HTTPS URLs, or owner/repo slugs
36            if let Some((owner, name)) = parse_github_slug(&r) {
37                format!("{}/{}", owner, name)
38            } else {
39                r
40            }
41        }
42        None => {
43            // Try to detect from git remote (origin) by reading the remote URL
44            let git_remote = std::process::Command::new("git")
45                .args(["remote", "get-url", "origin"])
46                .output_with_context();
47
48            match git_remote {
49                Ok(o) if o.status.success() => {
50                    let url = String::from_utf8_lossy(&o.stdout).trim().to_string();
51                    if let Some((owner, repo)) = parse_github_slug(&url) {
52                        format!("{}/{}", owner, repo)
53                    } else {
54                        eprintln!("Could not parse repository from git remote: {}", url);
55                        eprintln!("Use --repo OWNER/REPO");
56                        std::process::exit(1);
57                    }
58                }
59                _ => {
60                    eprintln!("Could not detect repository. Use --repo OWNER/REPO");
61                    std::process::exit(1);
62                }
63            }
64        }
65    };
66
67    // Ensure global config exists
68    if !config_path.exists() {
69        let content = "# Orch global configuration\n# See: https://github.com/gabrielkoerich/orch\n\nprojects: []\n\nrouter:\n  mode: llm\n  agent: claude\n  model: haiku\n";
70        std::fs::write(&config_path, content)?;
71    }
72
73    println!("Initialized orch for {repo_value}");
74    println!("Global config: {}", config_path.display());
75
76    // Create project-local .orch.yml if not exists
77    let local_config = std::path::Path::new(".orch.yml");
78    if !local_config.exists() {
79        std::fs::write(
80            local_config,
81            format!("# Project-specific orch config\ngh:\n  repo: \"{repo_value}\"\n"),
82        )?;
83        println!("Created .orch.yml");
84    }
85
86    // Register project in global config
87    let cwd = std::env::current_dir()?;
88    let cwd_str = cwd.to_string_lossy().to_string();
89
90    // Check if already registered
91    let paths = config::get_project_paths().unwrap_or_default();
92    if !paths.iter().any(|p| p == &cwd_str) {
93        project_add(".")?;
94    } else {
95        println!("Project already registered in global config");
96    }
97
98    // Install orch-review workflow
99    install_review_workflow(&cwd)?;
100
101    // Guidance for board setup
102    println!();
103    println!("Next steps:");
104    println!("  orch project board list      — find GitHub Projects V2 boards");
105    println!("  orch project board link <id> — link a board for status tracking");
106
107    Ok(())
108}
109
110/// The orch review gate workflow template, loaded from the repo's own workflow file.
111/// Installed by `orch init` into `.github/workflows/orch-review.yml`.
112const ORCH_REVIEW_WORKFLOW: &str = include_str!("../../.github/workflows/orch-review.yml");
113
114/// Install the orch review gate workflow into the project.
115fn install_review_workflow(project_dir: &std::path::Path) -> anyhow::Result<()> {
116    let workflows_dir = project_dir.join(".github").join("workflows");
117    let workflow_path = workflows_dir.join("orch-review.yml");
118
119    if workflow_path.exists() {
120        println!(
121            "Review workflow already exists: {}",
122            workflow_path.display()
123        );
124        return Ok(());
125    }
126
127    std::fs::create_dir_all(&workflows_dir)?;
128    std::fs::write(&workflow_path, ORCH_REVIEW_WORKFLOW)?;
129    println!(
130        "Installed review gate workflow: {}",
131        workflow_path.display()
132    );
133
134    Ok(())
135}
136
137/// Show orch logs.
138pub fn log(lines: &str) -> anyhow::Result<()> {
139    let state_dir = crate::home::state_dir().unwrap_or_default();
140    let brew_prefix = std::env::var("HOMEBREW_PREFIX").unwrap_or_else(|_| "/opt/homebrew".into());
141
142    let mut log_files = Vec::new();
143
144    let candidates = [
145        state_dir.join("orch.log"),
146        state_dir.join("orch.error.log"),
147        std::path::PathBuf::from(&brew_prefix).join("var/log/orch.log"),
148        std::path::PathBuf::from(&brew_prefix).join("var/log/orch.error.log"),
149    ];
150
151    for path in &candidates {
152        if path.exists()
153            && std::fs::metadata(path)
154                .map(|m| m.len() > 0)
155                .unwrap_or(false)
156        {
157            log_files.push(path.clone());
158        } else {
159            // Fall back to rotated log files (orch.log.1, orch.log.2, ...) if primary is empty
160            for n in 1..=5 {
161                let rotated = std::path::PathBuf::from(format!("{}.{}", path.display(), n));
162                if rotated.exists()
163                    && std::fs::metadata(&rotated)
164                        .map(|m| m.len() > 0)
165                        .unwrap_or(false)
166                {
167                    log_files.push(rotated);
168                    break;
169                }
170            }
171        }
172    }
173
174    if log_files.is_empty() {
175        println!("No log files found");
176        return Ok(());
177    }
178
179    if lines == "watch" {
180        let args: Vec<String> = std::iter::once("-f".to_string())
181            .chain(log_files.iter().map(|p| p.to_string_lossy().to_string()))
182            .collect();
183        let status = std::process::Command::new("tail")
184            .args(&args)
185            .status_with_context()?;
186        std::process::exit(status.code().unwrap_or(1));
187    } else {
188        let n = lines.parse::<usize>().unwrap_or(50);
189        for path in &log_files {
190            let filename = path.file_name().unwrap_or_default().to_string_lossy();
191            println!("=== {filename} ===");
192
193            let content = std::fs::read_to_string(path)?;
194            let all_lines: Vec<&str> = content.lines().collect();
195            let start = if all_lines.len() > n {
196                all_lines.len() - n
197            } else {
198                0
199            };
200            for line in &all_lines[start..] {
201                println!("{line}");
202            }
203            println!();
204        }
205    }
206
207    Ok(())
208}
209
210/// List installed agent CLIs.
211pub fn agents() {
212    let agents = crate::engine::configured_agents();
213
214    println!("{:<12} {:<10} PATH", "AGENT", "STATUS");
215    println!("{}", "-".repeat(60));
216
217    for agent in &agents {
218        match which::which(agent) {
219            Ok(path) => {
220                // Try to get version
221                let version = std::process::Command::new(agent)
222                    .arg("--version")
223                    .output_with_context()
224                    .ok()
225                    .and_then(|o| {
226                        if o.status.success() {
227                            Some(
228                                String::from_utf8_lossy(&o.stdout)
229                                    .lines()
230                                    .next()
231                                    .unwrap_or("")
232                                    .trim()
233                                    .to_string(),
234                            )
235                        } else {
236                            None
237                        }
238                    })
239                    .unwrap_or_default();
240
241                let info = if version.is_empty() {
242                    path.display().to_string()
243                } else {
244                    format!("{} ({})", path.display(), version)
245                };
246                println!("{:<12} {:<10} {}", agent, "installed", info);
247            }
248            Err(_) => {
249                println!("{:<12} {:<10} ", agent, "missing");
250            }
251        }
252    }
253}
254
255/// Show task metrics summary.
256///
257/// `since_hours` controls the time window (default 24; pass 168 for 7 days, etc.).
258pub async fn metrics(details: bool, since_hours: u32) -> anyhow::Result<()> {
259    let store = init_store().await?;
260
261    let summary = store.get_metrics_summary(since_hours).await?;
262    let window_label = hours_to_label(since_hours);
263
264    println!();
265    let header = format!("Orch Metrics (Last {})", window_label);
266    let width = 58usize;
267    let pad = width.saturating_sub(header.len() + 2);
268    let left_pad = pad / 2;
269    let right_pad = pad - left_pad;
270    println!("╔{}╗", "═".repeat(width));
271    println!(
272        "║{}{header}{}║",
273        " ".repeat(left_pad),
274        " ".repeat(right_pad)
275    );
276    println!("╚{}╝", "═".repeat(width));
277    println!();
278
279    // Task counts
280    println!(" Tasks:");
281    println!("   {:>6} completed", summary.tasks_completed_24h);
282    println!("   {:>6} failed", summary.tasks_failed_24h);
283    println!();
284
285    // Average duration by complexity
286    println!(" Average Duration by Complexity:");
287    if let Some(d) = summary.avg_duration_simple {
288        println!("   {:>6.1}s (simple)", d);
289    } else {
290        println!("   {:>6} (simple)", "-");
291    }
292    if let Some(d) = summary.avg_duration_medium {
293        println!("   {:>6.1}s (medium)", d);
294    } else {
295        println!("   {:>6} (medium)", "-");
296    }
297    if let Some(d) = summary.avg_duration_complex {
298        println!("   {:>6.1}s (complex)", d);
299    } else {
300        println!("   {:>6} (complex)", "-");
301    }
302    println!();
303
304    // Agent success rates
305    if !summary.agent_stats.is_empty() {
306        println!(" Agent Success Rates:");
307        for stat in &summary.agent_stats {
308            println!(
309                "   {:<12} {:>4} runs, {:>5.1}% success",
310                stat.agent, stat.total_runs, stat.success_rate
311            );
312        }
313        println!();
314    }
315
316    // Rate limits
317    println!(" Rate Limit Events: {:>6}", summary.rate_limits_24h);
318    println!();
319
320    if details {
321        // Slow tasks
322        let slow = store.get_slow_tasks(since_hours).await?;
323        if !slow.is_empty() {
324            println!(" Slowest Tasks (Last {}):", window_label);
325            println!(
326                "   {:<20} {:<12} {:<10} DURATION",
327                "TASK ID", "AGENT", "COMPLEXITY"
328            );
329            println!("   {}", "-".repeat(56));
330            for t in &slow {
331                println!(
332                    "   {:<20} {:<12} {:<10} {:.1}s",
333                    t.task_id,
334                    t.agent,
335                    t.complexity.as_deref().unwrap_or("-"),
336                    t.duration_seconds
337                );
338            }
339            println!();
340        }
341
342        // Error distribution
343        let errors = store.get_error_distribution(since_hours).await?;
344        if !errors.is_empty() {
345            println!(" Error Distribution (Last {}):", window_label);
346            println!("   {:<30} {:>6}", "ERROR TYPE", "COUNT");
347            println!("   {}", "-".repeat(38));
348            for e in &errors {
349                println!(
350                    "   {:<30} {:>6}",
351                    e.error_type.as_deref().unwrap_or("unknown"),
352                    e.count
353                );
354            }
355            println!();
356        } else {
357            println!(" Error Distribution (Last {}): none", window_label);
358            println!();
359        }
360
361        // Repeated review loops
362        let review_loops = store.get_high_review_cycle_tasks(since_hours).await?;
363        if !review_loops.is_empty() {
364            println!(" Tasks with Repeated Review Loops (Last {}):", window_label);
365            println!("   {:<10} {:<12} TITLE", "CYCLES", "AGENT");
366            println!("   {}", "-".repeat(60));
367            for t in &review_loops {
368                println!(
369                    "   {:<10} {:<12} {}",
370                    t.review_cycles,
371                    t.agent.as_deref().unwrap_or("-"),
372                    t.title,
373                );
374            }
375            println!();
376        }
377    }
378
379    Ok(())
380}
381
382/// Convert hours to a human-readable label (e.g. 24 → "24h", 168 → "7d").
383pub fn hours_to_label(hours: u32) -> String {
384    if hours.is_multiple_of(24) {
385        format!("{}d", hours / 24)
386    } else {
387        format!("{hours}h")
388    }
389}
390
391/// Parse a `--since` string like "24h", "7d", "30d" into hours.
392/// Returns `None` if the format is unrecognised.
393pub fn parse_since(s: &str) -> Option<u32> {
394    if let Some(d) = s.strip_suffix('d') {
395        d.parse::<u32>().ok().map(|n| n * 24)
396    } else if let Some(h) = s.strip_suffix('h') {
397        h.parse::<u32>().ok()
398    } else {
399        // bare number treated as hours
400        s.parse::<u32>().ok()
401    }
402}
403
404/// List accessible GitHub Projects V2 boards.
405pub async fn board_list() -> anyhow::Result<()> {
406    use crate::github::projects::ProjectSync;
407
408    let projects = ProjectSync::list_projects().await?;
409
410    if projects.is_empty() {
411        println!("No GitHub Projects V2 boards found");
412        return Ok(());
413    }
414
415    println!("{:<50} {:<8} ID", "TITLE", "NUMBER");
416    println!("{}", "-".repeat(80));
417    for p in &projects {
418        println!("{:<50} #{:<7} {}", p.title, p.number, p.id);
419    }
420
421    Ok(())
422}
423
424/// Link current repo to a GitHub Projects V2 board by ID and discover fields.
425pub async fn board_link(project_id: &str) -> anyhow::Result<()> {
426    use crate::github::projects::{write_project_config, ProjectSync};
427
428    println!("Discovering board fields...");
429    let sync = ProjectSync::discover_fields(project_id).await?;
430
431    write_project_config(&sync).await?;
432
433    println!("Linked board: {}", project_id);
434    println!("Status field: {}", sync.status_field_id());
435    println!("Column mappings:");
436    for (col, opt_id) in sync.status_map() {
437        println!("  {col}: {opt_id}");
438    }
439
440    Ok(())
441}
442
443/// Re-discover field IDs from configured board and update config.
444pub async fn board_sync() -> anyhow::Result<()> {
445    use crate::github::projects::{write_project_config, ProjectSync};
446
447    let project_id = config::get("gh.project_id").map_err(|_| {
448        anyhow::anyhow!("no board configured — run `orch project board link <id>` first")
449    })?;
450
451    if project_id.is_empty() {
452        anyhow::bail!("no board configured — run `orch project board link <id>` first");
453    }
454
455    println!("Syncing board fields for {}...", project_id);
456    let sync = ProjectSync::discover_fields(&project_id).await?;
457
458    write_project_config(&sync).await?;
459
460    println!("Updated config:");
461    println!("  Status field: {}", sync.status_field_id());
462    for (col, opt_id) in sync.status_map() {
463        println!("  {col}: {opt_id}");
464    }
465
466    Ok(())
467}
468
469/// Show current board configuration.
470pub fn board_info() -> anyhow::Result<()> {
471    let project_id = config::get("gh.project_id").unwrap_or_default();
472
473    if project_id.is_empty() {
474        println!("No board configured");
475        println!("  Run `orch project board list` to see available boards");
476        println!("  Run `orch project board link <id>` to link one");
477        return Ok(());
478    }
479
480    println!("Board ID: {}", project_id);
481
482    if let Ok(field_id) = config::get("gh.project_status_field_id") {
483        println!("Status field: {}", field_id);
484    }
485
486    for col in &["backlog", "in_progress", "review", "done"] {
487        if let Ok(opt_id) = config::get(&format!("gh.project_status_map.{col}")) {
488            println!("  {col}: {opt_id}");
489        }
490    }
491
492    Ok(())
493}
494
495/// Try to parse a GitHub slug (owner/repo) from the input.
496///
497/// Accepts:
498/// - `owner/repo` — direct slug
499/// - `https://github.com/owner/repo` — GitHub URL (with optional .git suffix)
500///
501/// Returns `None` if the input looks like a local path.
502fn parse_github_slug(input: &str) -> Option<(String, String)> {
503    // SSH URL format: git@github.com:owner/repo.git
504    if input.starts_with("git@github.com:") {
505        let path = input
506            .trim_start_matches("git@github.com:")
507            .trim_end_matches('/')
508            .trim_end_matches(".git");
509        let parts: Vec<&str> = path.splitn(3, '/').collect();
510        if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
511            return Some((parts[0].to_string(), parts[1].to_string()));
512        }
513        return None;
514    }
515
516    // GitHub URL format
517    if input.starts_with("https://github.com/") || input.starts_with("http://github.com/") {
518        let path = input
519            .trim_start_matches("https://github.com/")
520            .trim_start_matches("http://github.com/")
521            .trim_end_matches('/')
522            .trim_end_matches(".git");
523        let parts: Vec<&str> = path.splitn(3, '/').collect();
524        if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
525            return Some((parts[0].to_string(), parts[1].to_string()));
526        }
527        return None;
528    }
529
530    // Skip anything that looks like a local path
531    if input.starts_with('/')
532        || input.starts_with('.')
533        || input.starts_with('~')
534        || (input.contains(std::path::MAIN_SEPARATOR) && std::path::MAIN_SEPARATOR != '/')
535    {
536        return None;
537    }
538
539    // owner/repo slug (exactly one slash, no path-like characters)
540    let parts: Vec<&str> = input.splitn(3, '/').collect();
541    if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
542        // Sanity check: slugs don't contain spaces or typical path characters
543        let owner = parts[0];
544        let repo = parts[1].trim_end_matches(".git");
545        if !owner.contains(' ') && !repo.contains(' ') && !repo.contains('/') {
546            return Some((owner.to_string(), repo.to_string()));
547        }
548    }
549
550    None
551}
552
553/// Add a project to the global registry.
554///
555/// Accepts:
556/// - A local path (existing behavior)
557/// - A GitHub slug (`owner/repo`) — auto-clones as a bare repo
558/// - A GitHub URL (`https://github.com/owner/repo`) — auto-clones as a bare repo
559pub fn project_add(input: &str) -> anyhow::Result<()> {
560    // Check if this is a GitHub slug or URL
561    if let Some((owner, repo)) = parse_github_slug(input) {
562        return project_add_github(&owner, &repo);
563    }
564
565    // Local path (existing behavior)
566    project_add_local(input)
567}
568
569/// Add a local project path to the global registry.
570fn project_add_local(path: &str) -> anyhow::Result<()> {
571    let abs_path = if path == "." {
572        std::env::current_dir()?
573    } else {
574        std::path::PathBuf::from(path).canonicalize()?
575    };
576
577    // Verify .orch.yml exists in the project
578    let orch_yml = abs_path.join(".orch.yml");
579    if !orch_yml.exists() {
580        // Check for legacy .orchestrator.yml
581        let legacy = abs_path.join(".orchestrator.yml");
582        if legacy.exists() {
583            println!("Found legacy .orchestrator.yml — consider renaming to .orch.yml");
584        } else {
585            anyhow::bail!(
586                "no .orch.yml found in {} — run `orch init` in the project first",
587                abs_path.display()
588            );
589        }
590    }
591
592    let path_str = abs_path.to_string_lossy().to_string();
593    register_project_path(&path_str)?;
594
595    // Show the repo from .orch.yml if available
596    if orch_yml.exists() {
597        let project_content = std::fs::read_to_string(&orch_yml)?;
598        let project_doc: serde_norway::Value = serde_norway::from_str(&project_content)?;
599        if let Some(repo) = project_doc
600            .get("gh")
601            .and_then(|gh| gh.get("repo"))
602            .and_then(|r| r.as_str())
603        {
604            println!("  repo: {}", repo);
605        }
606    }
607
608    Ok(())
609}
610
611/// Clone a GitHub repo as a bare clone and register it.
612fn project_add_github(owner: &str, repo: &str) -> anyhow::Result<()> {
613    let projects_dir = crate::home::projects_dir()?;
614    let bare_path = projects_dir.join(owner).join(format!("{repo}.git"));
615    let slug = format!("{owner}/{repo}");
616
617    if bare_path.exists() {
618        println!("Bare clone already exists: {}", bare_path.display());
619    } else {
620        // Create parent directory
621        let parent = bare_path
622            .parent()
623            .ok_or_else(|| anyhow::anyhow!("bare path has no parent: {}", bare_path.display()))?;
624        std::fs::create_dir_all(parent)?;
625
626        println!("Cloning {slug} as bare repo...");
627        let status = std::process::Command::new("gh")
628            .args([
629                "repo",
630                "clone",
631                &slug,
632                &bare_path.to_string_lossy(),
633                "--",
634                "--bare",
635            ])
636            .status_with_context()?;
637
638        if !status.success() {
639            anyhow::bail!("gh repo clone failed for {slug}");
640        }
641
642        println!("Cloned to {}", bare_path.display());
643    }
644
645    // Create .orch.yml inside the bare clone if it doesn't exist
646    let orch_yml = bare_path.join(".orch.yml");
647    if !orch_yml.exists() {
648        let content = format!("# Project-specific orch config\ngh:\n  repo: \"{slug}\"\n");
649        std::fs::write(&orch_yml, content)?;
650        println!("Created .orch.yml with gh.repo: {slug}");
651    }
652
653    // Register in global config
654    let path_str = bare_path.to_string_lossy().to_string();
655    register_project_path(&path_str)?;
656    println!("  repo: {slug}");
657
658    Ok(())
659}
660
661/// Register a project path in the global config (shared by local and GitHub flows).
662fn register_project_path(path_str: &str) -> anyhow::Result<()> {
663    let config_path = crate::home::config_path()?;
664    let content = if config_path.exists() {
665        std::fs::read_to_string(&config_path)?
666    } else {
667        String::new()
668    };
669
670    let mut doc: serde_norway::Value = if content.is_empty() {
671        serde_norway::Value::Mapping(serde_norway::Mapping::new())
672    } else {
673        serde_norway::from_str(&content)?
674    };
675
676    let root = doc
677        .as_mapping_mut()
678        .ok_or_else(|| anyhow::anyhow!("config is not a YAML mapping"))?;
679
680    // Get or create projects list
681    let projects_key = serde_norway::Value::String("projects".to_string());
682    if !root.contains_key(&projects_key) {
683        root.insert(
684            projects_key.clone(),
685            serde_norway::Value::Sequence(Vec::new()),
686        );
687    }
688
689    let projects = root
690        .get_mut(&projects_key)
691        .and_then(|v| v.as_sequence_mut())
692        .ok_or_else(|| anyhow::anyhow!("projects is not a list"))?;
693
694    // Check for duplicates
695    let already_exists = projects
696        .iter()
697        .any(|p| p.as_str().map(|s| s == path_str).unwrap_or(false));
698
699    if already_exists {
700        println!("Project already registered: {}", path_str);
701        return Ok(());
702    }
703
704    projects.push(serde_norway::Value::String(path_str.to_string()));
705    std::fs::write(&config_path, serde_norway::to_string(&doc)?)?;
706
707    println!("Added project: {}", path_str);
708
709    Ok(())
710}
711
712/// Remove a project from the global registry.
713pub fn project_remove(path: &str) -> anyhow::Result<()> {
714    let abs_path = std::path::PathBuf::from(path)
715        .canonicalize()
716        .unwrap_or_else(|_| std::path::PathBuf::from(path));
717    let path_str = abs_path.to_string_lossy().to_string();
718
719    let config_path = crate::home::config_path()?;
720    if !config_path.exists() {
721        anyhow::bail!("no global config found");
722    }
723
724    let content = std::fs::read_to_string(&config_path)?;
725    let mut doc: serde_norway::Value = serde_norway::from_str(&content)?;
726
727    let root = doc
728        .as_mapping_mut()
729        .ok_or_else(|| anyhow::anyhow!("config is not a YAML mapping"))?;
730
731    let projects_key = serde_norway::Value::String("projects".to_string());
732    let projects = root
733        .get_mut(&projects_key)
734        .and_then(|v| v.as_sequence_mut())
735        .ok_or_else(|| anyhow::anyhow!("no projects list in config"))?;
736
737    let before_len = projects.len();
738    projects.retain(|p| {
739        p.as_str()
740            .map(|s| s != path_str && s != path)
741            .unwrap_or(true)
742    });
743
744    if projects.len() == before_len {
745        println!("Project not found: {}", path);
746        return Ok(());
747    }
748
749    std::fs::write(&config_path, serde_norway::to_string(&doc)?)?;
750    println!("Removed project: {}", path_str);
751
752    Ok(())
753}
754
755/// List all registered projects.
756pub fn project_list() -> anyhow::Result<()> {
757    let projects = config::get_project_paths()?;
758
759    if projects.is_empty() {
760        println!("No projects registered");
761        println!("  Run `orch project add <path>` to register a project");
762        return Ok(());
763    }
764
765    println!("{:<50} REPO", "PATH");
766    println!("{}", "-".repeat(80));
767
768    for path_str in &projects {
769        let path = std::path::Path::new(path_str);
770
771        // Try to read repo from .orch.yml
772        let repo = read_project_repo(path).unwrap_or_else(|| "(.orch.yml not found)".to_string());
773
774        let status = if path.exists() { "" } else { " (missing)" };
775
776        println!("{:<50} {}{}", path_str, repo, status);
777    }
778
779    Ok(())
780}
781
782/// Read gh.repo from a project's .orch.yml.
783fn read_project_repo(project_path: &std::path::Path) -> Option<String> {
784    let orch_yml = project_path.join(".orch.yml");
785    let content = std::fs::read_to_string(&orch_yml).ok()?;
786    let doc: serde_norway::Value = serde_norway::from_str(&content).ok()?;
787    doc.get("gh")
788        .and_then(|gh| gh.get("repo"))
789        .and_then(|r| r.as_str())
790        .map(String::from)
791}
792
793/// Initialize task manager with database and backend.
794pub async fn init_task_manager() -> anyhow::Result<TaskManager> {
795    use crate::backends::github::GitHubBackend;
796    use crate::backends::ExternalBackend;
797    use crate::store::TaskStore;
798
799    let repo = config::get_current_repo()
800        .context("'repo' not set — run `orch init` or set gh.repo in .orch.yml")?;
801    let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
802    let store = Arc::new(TaskStore::open(&crate::store::default_db_path().await?).await?);
803    Ok(TaskManager::with_store(backend, store, repo))
804}
805
806/// Initialize the unified task store for CLI commands.
807pub async fn init_store() -> anyhow::Result<crate::store::TaskStore> {
808    crate::store::TaskStore::open(&crate::store::default_db_path().await?).await
809}
810
811#[cfg(test)]
812mod tests {
813    use super::*;
814
815    #[test]
816    fn hours_to_label_prefers_days_for_full_day_windows() {
817        assert_eq!(hours_to_label(24), "1d");
818        assert_eq!(hours_to_label(168), "7d");
819        assert_eq!(hours_to_label(30), "30h");
820    }
821
822    #[test]
823    fn parse_since_supports_days_hours_and_bare_numbers() {
824        assert_eq!(parse_since("7d"), Some(168));
825        assert_eq!(parse_since("24h"), Some(24));
826        assert_eq!(parse_since("36"), Some(36));
827        assert_eq!(parse_since("bad"), None);
828    }
829
830    #[test]
831    fn parse_slug_owner_repo() {
832        let result = parse_github_slug("gabrielkoerich/orch");
833        assert_eq!(
834            result,
835            Some(("gabrielkoerich".to_string(), "orch".to_string()))
836        );
837    }
838
839    #[test]
840    fn parse_slug_https_url() {
841        let result = parse_github_slug("https://github.com/gabrielkoerich/orch");
842        assert_eq!(
843            result,
844            Some(("gabrielkoerich".to_string(), "orch".to_string()))
845        );
846    }
847
848    #[test]
849    fn parse_slug_https_url_with_git_suffix() {
850        let result = parse_github_slug("https://github.com/gabrielkoerich/orch.git");
851        assert_eq!(
852            result,
853            Some(("gabrielkoerich".to_string(), "orch".to_string()))
854        );
855    }
856
857    #[test]
858    fn parse_slug_https_url_trailing_slash() {
859        let result = parse_github_slug("https://github.com/gabrielkoerich/orch/");
860        assert_eq!(
861            result,
862            Some(("gabrielkoerich".to_string(), "orch".to_string()))
863        );
864    }
865
866    #[test]
867    fn parse_slug_absolute_path_returns_none() {
868        assert_eq!(parse_github_slug("/Users/gb/Projects/my-app"), None);
869    }
870
871    #[test]
872    fn parse_slug_relative_path_returns_none() {
873        assert_eq!(parse_github_slug("./my-app"), None);
874    }
875
876    #[test]
877    fn parse_slug_dot_returns_none() {
878        assert_eq!(parse_github_slug("."), None);
879    }
880
881    #[test]
882    fn parse_slug_tilde_path_returns_none() {
883        assert_eq!(parse_github_slug("~/Projects/my-app"), None);
884    }
885
886    #[test]
887    fn parse_slug_single_word_returns_none() {
888        assert_eq!(parse_github_slug("my-app"), None);
889    }
890
891    #[test]
892    fn parse_slug_three_segments_returns_none() {
893        // Three segments like a deep path shouldn't match as a slug
894        assert_eq!(parse_github_slug("a/b/c"), None);
895    }
896
897    #[test]
898    fn parse_slug_ssh_url() {
899        let result = parse_github_slug("git@github.com:gabrielkoerich/bean.git");
900        assert_eq!(
901            result,
902            Some(("gabrielkoerich".to_string(), "bean".to_string()))
903        );
904    }
905
906    #[test]
907    fn parse_slug_ssh_url_no_git_suffix() {
908        let result = parse_github_slug("git@github.com:gabrielkoerich/bean");
909        assert_eq!(
910            result,
911            Some(("gabrielkoerich".to_string(), "bean".to_string()))
912        );
913    }
914}