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