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