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
26pub 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 if let Some((owner, name)) = parse_github_slug(&r) {
37 format!("{}/{}", owner, name)
38 } else {
39 r
40 }
41 }
42 None => {
43 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 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 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 let cwd = std::env::current_dir()?;
88 let cwd_str = cwd.to_string_lossy().to_string();
89
90 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_review_workflow(&cwd)?;
100 set_reviewer_variable(&repo_value);
101
102 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
111const ORCH_REVIEW_WORKFLOW: &str = include_str!("orch-review.template.yml");
114
115fn 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
150fn 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
173pub 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 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
246pub 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 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
291pub 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 println!(" Tasks:");
317 println!(" {:>6} completed", summary.tasks_completed_24h);
318 println!(" {:>6} failed", summary.tasks_failed_24h);
319 println!();
320
321 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 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 println!(" Rate Limit Events: {:>6}", summary.rate_limits_24h);
354 println!();
355
356 if details {
357 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 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 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
418pub 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
427pub 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 s.parse::<u32>().ok()
437 }
438}
439
440pub 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
460pub 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
479pub 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
505pub 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
531pub 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
556fn parse_github_slug(input: &str) -> Option<(String, String)> {
564 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 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 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 let parts: Vec<&str> = input.splitn(3, '/').collect();
602 if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
603 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
614pub fn project_add(input: &str) -> anyhow::Result<()> {
621 if let Some((owner, repo)) = parse_github_slug(input) {
623 return project_add_github(&owner, &repo);
624 }
625
626 project_add_local(input)
628}
629
630fn 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 let orch_yml = abs_path.join(".orch.yml");
640 if !orch_yml.exists() {
641 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 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
672fn 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 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 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 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
722fn 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
744pub 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
770pub 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 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
797fn 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
808pub 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
821pub 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 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 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}