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