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/// One display-only reconciliation pass. No engine, TaskStore, router,
496/// task ingestion, configuration write or agent process is initialized here.
497pub async fn board_reconcile(repo: Option<&str>) -> anyhow::Result<()> {
498    let repo = match repo {
499        Some(repo) => repo.to_owned(),
500        None => config::get_current_repo()?,
501    };
502    let config = crate::github::projects::label_status::LabelSyncConfig::from_config()?;
503    let gh = crate::github::http::GhHttp::new()?;
504    let updated = board_reconcile_with(&config, &gh, &repo).await?;
505    println!("Updated {updated} project Status field(s) in this pass; skipped items are reported at debug level.");
506    Ok(())
507}
508
509pub(crate) async fn board_reconcile_with(
510    config: &crate::github::projects::label_status::LabelSyncConfig,
511    api: &impl crate::github::projects::label_status::ProjectApi,
512    repo: &str,
513) -> anyhow::Result<usize> {
514    if !config.configured_for_repo(repo) {
515        anyhow::bail!("no enabled gh.project_label_sync board configured for this repository");
516    }
517    config.reconcile(api, repo).await
518}
519
520/// Try to parse a GitHub slug (owner/repo) from the input.
521///
522/// Accepts:
523/// - `owner/repo` — direct slug
524/// - `https://github.com/owner/repo` — GitHub URL (with optional .git suffix)
525///
526/// Returns `None` if the input looks like a local path.
527fn parse_github_slug(input: &str) -> Option<(String, String)> {
528    // SSH URL format: git@github.com:owner/repo.git
529    if input.starts_with("git@github.com:") {
530        let path = input
531            .trim_start_matches("git@github.com:")
532            .trim_end_matches('/')
533            .trim_end_matches(".git");
534        let parts: Vec<&str> = path.splitn(3, '/').collect();
535        if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
536            return Some((parts[0].to_string(), parts[1].to_string()));
537        }
538        return None;
539    }
540
541    // GitHub URL format
542    if input.starts_with("https://github.com/") || input.starts_with("http://github.com/") {
543        let path = input
544            .trim_start_matches("https://github.com/")
545            .trim_start_matches("http://github.com/")
546            .trim_end_matches('/')
547            .trim_end_matches(".git");
548        let parts: Vec<&str> = path.splitn(3, '/').collect();
549        if parts.len() >= 2 && !parts[0].is_empty() && !parts[1].is_empty() {
550            return Some((parts[0].to_string(), parts[1].to_string()));
551        }
552        return None;
553    }
554
555    // Skip anything that looks like a local path
556    if input.starts_with('/')
557        || input.starts_with('.')
558        || input.starts_with('~')
559        || (input.contains(std::path::MAIN_SEPARATOR) && std::path::MAIN_SEPARATOR != '/')
560    {
561        return None;
562    }
563
564    // owner/repo slug (exactly one slash, no path-like characters)
565    let parts: Vec<&str> = input.splitn(3, '/').collect();
566    if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
567        // Sanity check: slugs don't contain spaces or typical path characters
568        let owner = parts[0];
569        let repo = parts[1].trim_end_matches(".git");
570        if !owner.contains(' ') && !repo.contains(' ') && !repo.contains('/') {
571            return Some((owner.to_string(), repo.to_string()));
572        }
573    }
574
575    None
576}
577
578/// Add a project to the global registry.
579///
580/// Accepts:
581/// - A local path (existing behavior)
582/// - A GitHub slug (`owner/repo`) — auto-clones as a bare repo
583/// - A GitHub URL (`https://github.com/owner/repo`) — auto-clones as a bare repo
584pub fn project_add(input: &str) -> anyhow::Result<()> {
585    // Check if this is a GitHub slug or URL
586    if let Some((owner, repo)) = parse_github_slug(input) {
587        return project_add_github(&owner, &repo);
588    }
589
590    // Local path (existing behavior)
591    project_add_local(input)
592}
593
594/// Add a local project path to the global registry.
595fn project_add_local(path: &str) -> anyhow::Result<()> {
596    let abs_path = if path == "." {
597        std::env::current_dir()?
598    } else {
599        std::path::PathBuf::from(path).canonicalize()?
600    };
601
602    // Verify .orch.yml exists in the project
603    let orch_yml = abs_path.join(".orch.yml");
604    if !orch_yml.exists() {
605        // Check for legacy .orchestrator.yml
606        let legacy = abs_path.join(".orchestrator.yml");
607        if legacy.exists() {
608            println!("Found legacy .orchestrator.yml — consider renaming to .orch.yml");
609        } else {
610            anyhow::bail!(
611                "no .orch.yml found in {} — run `orch init` in the project first",
612                abs_path.display()
613            );
614        }
615    }
616
617    let path_str = abs_path.to_string_lossy().to_string();
618    register_project_path(&path_str)?;
619
620    // Show the repo from .orch.yml if available
621    if orch_yml.exists() {
622        let project_content = std::fs::read_to_string(&orch_yml)?;
623        let project_doc: serde_norway::Value = serde_norway::from_str(&project_content)?;
624        if let Some(repo) = project_doc
625            .get("gh")
626            .and_then(|gh| gh.get("repo"))
627            .and_then(|r| r.as_str())
628        {
629            println!("  repo: {}", repo);
630        }
631    }
632
633    Ok(())
634}
635
636/// Clone a GitHub repo as a bare clone and register it.
637fn project_add_github(owner: &str, repo: &str) -> anyhow::Result<()> {
638    let projects_dir = crate::home::projects_dir()?;
639    let bare_path = projects_dir.join(owner).join(format!("{repo}.git"));
640    let slug = format!("{owner}/{repo}");
641
642    if bare_path.exists() {
643        println!("Bare clone already exists: {}", bare_path.display());
644    } else {
645        // Create parent directory
646        let parent = bare_path
647            .parent()
648            .ok_or_else(|| anyhow::anyhow!("bare path has no parent: {}", bare_path.display()))?;
649        std::fs::create_dir_all(parent)?;
650
651        println!("Cloning {slug} as bare repo...");
652        let status = std::process::Command::new("gh")
653            .args([
654                "repo",
655                "clone",
656                &slug,
657                &bare_path.to_string_lossy(),
658                "--",
659                "--bare",
660            ])
661            .status_with_context()?;
662
663        if !status.success() {
664            anyhow::bail!("gh repo clone failed for {slug}");
665        }
666
667        println!("Cloned to {}", bare_path.display());
668    }
669
670    // Create .orch.yml inside the bare clone if it doesn't exist
671    let orch_yml = bare_path.join(".orch.yml");
672    if !orch_yml.exists() {
673        let content = format!("# Project-specific orch config\ngh:\n  repo: \"{slug}\"\n");
674        std::fs::write(&orch_yml, content)?;
675        println!("Created .orch.yml with gh.repo: {slug}");
676    }
677
678    // Register in global config
679    let path_str = bare_path.to_string_lossy().to_string();
680    register_project_path(&path_str)?;
681    println!("  repo: {slug}");
682
683    Ok(())
684}
685
686/// Register a project path in the global config (shared by local and GitHub flows).
687fn register_project_path(path_str: &str) -> anyhow::Result<()> {
688    let config_path = crate::home::config_path()?;
689    let content = if config_path.exists() {
690        std::fs::read_to_string(&config_path)?
691    } else {
692        String::new()
693    };
694
695    let mut doc: serde_norway::Value = if content.is_empty() {
696        serde_norway::Value::Mapping(serde_norway::Mapping::new())
697    } else {
698        serde_norway::from_str(&content)?
699    };
700
701    let root = doc
702        .as_mapping_mut()
703        .ok_or_else(|| anyhow::anyhow!("config is not a YAML mapping"))?;
704
705    // Get or create projects list
706    let projects_key = serde_norway::Value::String("projects".to_string());
707    if !root.contains_key(&projects_key) {
708        root.insert(
709            projects_key.clone(),
710            serde_norway::Value::Sequence(Vec::new()),
711        );
712    }
713
714    let projects = root
715        .get_mut(&projects_key)
716        .and_then(|v| v.as_sequence_mut())
717        .ok_or_else(|| anyhow::anyhow!("projects is not a list"))?;
718
719    // Check for duplicates
720    let already_exists = projects
721        .iter()
722        .any(|p| p.as_str().map(|s| s == path_str).unwrap_or(false));
723
724    if already_exists {
725        println!("Project already registered: {}", path_str);
726        return Ok(());
727    }
728
729    projects.push(serde_norway::Value::String(path_str.to_string()));
730    std::fs::write(&config_path, serde_norway::to_string(&doc)?)?;
731
732    println!("Added project: {}", path_str);
733
734    Ok(())
735}
736
737/// Remove a project from the global registry.
738pub fn project_remove(path: &str) -> anyhow::Result<()> {
739    let abs_path = std::path::PathBuf::from(path)
740        .canonicalize()
741        .unwrap_or_else(|_| std::path::PathBuf::from(path));
742    let path_str = abs_path.to_string_lossy().to_string();
743
744    let config_path = crate::home::config_path()?;
745    if !config_path.exists() {
746        anyhow::bail!("no global config found");
747    }
748
749    let content = std::fs::read_to_string(&config_path)?;
750    let mut doc: serde_norway::Value = serde_norway::from_str(&content)?;
751
752    let root = doc
753        .as_mapping_mut()
754        .ok_or_else(|| anyhow::anyhow!("config is not a YAML mapping"))?;
755
756    let projects_key = serde_norway::Value::String("projects".to_string());
757    let projects = root
758        .get_mut(&projects_key)
759        .and_then(|v| v.as_sequence_mut())
760        .ok_or_else(|| anyhow::anyhow!("no projects list in config"))?;
761
762    let before_len = projects.len();
763    projects.retain(|p| {
764        p.as_str()
765            .map(|s| s != path_str && s != path)
766            .unwrap_or(true)
767    });
768
769    if projects.len() == before_len {
770        println!("Project not found: {}", path);
771        return Ok(());
772    }
773
774    std::fs::write(&config_path, serde_norway::to_string(&doc)?)?;
775    println!("Removed project: {}", path_str);
776
777    Ok(())
778}
779
780/// List all registered projects.
781pub fn project_list() -> anyhow::Result<()> {
782    let projects = config::get_project_paths()?;
783
784    if projects.is_empty() {
785        println!("No projects registered");
786        println!("  Run `orch project add <path>` to register a project");
787        return Ok(());
788    }
789
790    println!("{:<50} REPO", "PATH");
791    println!("{}", "-".repeat(80));
792
793    for path_str in &projects {
794        let path = std::path::Path::new(path_str);
795
796        // Try to read repo from .orch.yml
797        let repo = read_project_repo(path).unwrap_or_else(|| "(.orch.yml not found)".to_string());
798
799        let status = if path.exists() { "" } else { " (missing)" };
800
801        println!("{:<50} {}{}", path_str, repo, status);
802    }
803
804    Ok(())
805}
806
807/// Read gh.repo from a project's .orch.yml.
808fn read_project_repo(project_path: &std::path::Path) -> Option<String> {
809    let orch_yml = project_path.join(".orch.yml");
810    let content = std::fs::read_to_string(&orch_yml).ok()?;
811    let doc: serde_norway::Value = serde_norway::from_str(&content).ok()?;
812    doc.get("gh")
813        .and_then(|gh| gh.get("repo"))
814        .and_then(|r| r.as_str())
815        .map(String::from)
816}
817
818/// Initialize task manager with database and backend.
819pub async fn init_task_manager() -> anyhow::Result<TaskManager> {
820    use crate::backends::github::GitHubBackend;
821    use crate::backends::ExternalBackend;
822    use crate::store::TaskStore;
823
824    let repo = config::get_current_repo()
825        .context("'repo' not set — run `orch init` or set gh.repo in .orch.yml")?;
826    let backend: Arc<dyn ExternalBackend> = Arc::new(GitHubBackend::new(repo.clone())?);
827    let store = Arc::new(TaskStore::open(&crate::store::default_db_path().await?).await?);
828    Ok(TaskManager::with_store(backend, store, repo))
829}
830
831/// Initialize the unified task store for CLI commands.
832pub async fn init_store() -> anyhow::Result<crate::store::TaskStore> {
833    crate::store::TaskStore::open(&crate::store::default_db_path().await?).await
834}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839
840    #[test]
841    fn hours_to_label_prefers_days_for_full_day_windows() {
842        assert_eq!(hours_to_label(24), "1d");
843        assert_eq!(hours_to_label(168), "7d");
844        assert_eq!(hours_to_label(30), "30h");
845    }
846
847    #[test]
848    fn parse_since_supports_days_hours_and_bare_numbers() {
849        assert_eq!(parse_since("7d"), Some(168));
850        assert_eq!(parse_since("24h"), Some(24));
851        assert_eq!(parse_since("36"), Some(36));
852        assert_eq!(parse_since("bad"), None);
853    }
854
855    #[test]
856    fn parse_slug_owner_repo() {
857        let result = parse_github_slug("gabrielkoerich/orch");
858        assert_eq!(
859            result,
860            Some(("gabrielkoerich".to_string(), "orch".to_string()))
861        );
862    }
863
864    #[test]
865    fn parse_slug_https_url() {
866        let result = parse_github_slug("https://github.com/gabrielkoerich/orch");
867        assert_eq!(
868            result,
869            Some(("gabrielkoerich".to_string(), "orch".to_string()))
870        );
871    }
872
873    #[test]
874    fn parse_slug_https_url_with_git_suffix() {
875        let result = parse_github_slug("https://github.com/gabrielkoerich/orch.git");
876        assert_eq!(
877            result,
878            Some(("gabrielkoerich".to_string(), "orch".to_string()))
879        );
880    }
881
882    #[test]
883    fn parse_slug_https_url_trailing_slash() {
884        let result = parse_github_slug("https://github.com/gabrielkoerich/orch/");
885        assert_eq!(
886            result,
887            Some(("gabrielkoerich".to_string(), "orch".to_string()))
888        );
889    }
890
891    #[test]
892    fn parse_slug_absolute_path_returns_none() {
893        assert_eq!(parse_github_slug("/Users/gb/Projects/my-app"), None);
894    }
895
896    #[test]
897    fn parse_slug_relative_path_returns_none() {
898        assert_eq!(parse_github_slug("./my-app"), None);
899    }
900
901    #[test]
902    fn parse_slug_dot_returns_none() {
903        assert_eq!(parse_github_slug("."), None);
904    }
905
906    #[test]
907    fn parse_slug_tilde_path_returns_none() {
908        assert_eq!(parse_github_slug("~/Projects/my-app"), None);
909    }
910
911    #[test]
912    fn parse_slug_single_word_returns_none() {
913        assert_eq!(parse_github_slug("my-app"), None);
914    }
915
916    #[test]
917    fn parse_slug_three_segments_returns_none() {
918        // Three segments like a deep path shouldn't match as a slug
919        assert_eq!(parse_github_slug("a/b/c"), None);
920    }
921
922    #[test]
923    fn parse_slug_ssh_url() {
924        let result = parse_github_slug("git@github.com:gabrielkoerich/bean.git");
925        assert_eq!(
926            result,
927            Some(("gabrielkoerich".to_string(), "bean".to_string()))
928        );
929    }
930
931    #[test]
932    fn parse_slug_ssh_url_no_git_suffix() {
933        let result = parse_github_slug("git@github.com:gabrielkoerich/bean");
934        assert_eq!(
935            result,
936            Some(("gabrielkoerich".to_string(), "bean".to_string()))
937        );
938    }
939}