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
27pub 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 if let Some((owner, name)) = parse_github_slug(&r) {
38 format!("{}/{}", owner, name)
39 } else {
40 r
41 }
42 }
43 None => {
44 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 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 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 let cwd = std::env::current_dir()?;
89 let cwd_str = cwd.to_string_lossy().to_string();
90
91 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_review_workflow(&cwd)?;
101 set_reviewer_variable(&repo_value);
102
103 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
112const ORCH_REVIEW_WORKFLOW: &str = include_str!("orch-review.template.yml");
115
116fn 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
151fn 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
174pub 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 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
247pub 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 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
292pub 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 println!(" Tasks:");
318 println!(" {:>6} completed", summary.tasks_completed_24h);
319 println!(" {:>6} failed", summary.tasks_failed_24h);
320 println!();
321
322 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 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 println!(" Rate Limit Events: {:>6}", summary.rate_limits_24h);
355 println!();
356
357 if details {
358 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 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 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
419pub 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
428pub 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 s.parse::<u32>().ok()
438 }
439}
440
441pub 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
461pub 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
480pub 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
506pub 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
532pub 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
557fn parse_github_slug(input: &str) -> Option<(String, String)> {
565 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 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 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 let parts: Vec<&str> = input.splitn(3, '/').collect();
603 if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
604 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
615pub fn project_add(input: &str) -> anyhow::Result<()> {
622 if let Some((owner, repo)) = parse_github_slug(input) {
624 return project_add_github(&owner, &repo);
625 }
626
627 project_add_local(input)
629}
630
631fn 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 let orch_yml = abs_path.join(".orch.yml");
641 if !orch_yml.exists() {
642 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 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
673fn 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 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 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 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
723fn 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
745pub 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
771pub 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 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
798fn 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
809pub 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
822pub 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 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 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}