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