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