Skip to main content

orch/cli/
mod.rs

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