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