1use crate::backends::{ExternalBackend, ExternalId, Status};
8use crate::engine;
9use crate::engine::tasks::TaskManager;
10use crate::github::http::GhHttp;
11use crate::github::types::GitHubIssue;
12use crate::store::TaskStatus;
13use crate::store::TaskStore;
14use async_trait::async_trait;
15use std::fmt;
16use std::fmt::Display;
17use std::fmt::Formatter;
18use std::sync::Arc;
19
20#[async_trait]
25pub trait CommandStoreOps: Send + Sync {
26 async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>>;
27
28 async fn set_block_reason(&self, id: i64, reason: Option<&str>) -> anyhow::Result<()>;
29
30 async fn reset_counters(&self, id: i64) -> anyhow::Result<()>;
33
34 async fn status(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<TaskStatus>>;
36}
37
38#[async_trait]
39impl CommandStoreOps for TaskStore {
40 async fn resolve_task_id(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<i64>> {
41 self.resolve_task_id(repo, task_id).await
42 }
43
44 async fn set_block_reason(&self, id: i64, reason: Option<&str>) -> anyhow::Result<()> {
45 self.set_block_reason(id, reason).await
46 }
47
48 async fn reset_counters(&self, id: i64) -> anyhow::Result<()> {
49 self.reset_counters(id).await
50 }
51
52 async fn status(&self, repo: &str, task_id: &str) -> anyhow::Result<Option<TaskStatus>> {
53 let Some(id) = self.resolve_task_id(repo, task_id).await? else {
54 return Ok(None);
55 };
56 Ok(Some(self.get(id).await?.status))
57 }
58}
59
60#[async_trait]
65pub trait CommandGh: Send + Sync {
66 async fn get_issue(&self, repo: &str, number: &str) -> anyhow::Result<GitHubIssue>;
67
68 async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool>;
69
70 async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()>;
71}
72
73#[async_trait]
74impl CommandGh for GhHttp {
75 async fn get_issue(&self, repo: &str, number: &str) -> anyhow::Result<GitHubIssue> {
76 GhHttp::get_issue(self, repo, number).await
77 }
78
79 async fn is_collaborator(&self, repo: &str, username: &str) -> anyhow::Result<bool> {
80 GhHttp::is_collaborator(self, repo, username).await
81 }
82
83 async fn close_issue(&self, repo: &str, number: &str) -> anyhow::Result<()> {
84 GhHttp::close_issue(self, repo, number).await
85 }
86}
87
88#[derive(Debug, Clone, PartialEq)]
90pub enum OwnerCommand {
91 Retry,
93 Reroute(Option<String>),
95 Close,
97 Block(Option<String>),
99 Unblock,
101 Review,
103}
104
105impl Display for OwnerCommand {
106 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
107 match self {
108 Self::Retry => write!(f, "/retry"),
109 Self::Reroute(Some(a)) => write!(f, "/reroute {a}"),
110 Self::Reroute(None) => write!(f, "/reroute"),
111 Self::Close => write!(f, "/close"),
112 Self::Block(Some(r)) => write!(f, "/block {r}"),
113 Self::Block(None) => write!(f, "/block"),
114 Self::Unblock => write!(f, "/unblock"),
115 Self::Review => write!(f, "/review"),
116 }
117 }
118}
119
120pub fn parse_command(body: &str) -> Option<OwnerCommand> {
130 let mut fence_state: Option<(char, usize)> = None;
135 for line in body.lines() {
136 let trimmed = line.trim();
137 let fence_ch = if trimmed.starts_with("```") {
138 Some('`')
139 } else if trimmed.starts_with("~~~") {
140 Some('~')
141 } else {
142 None
143 };
144 if let Some(ch) = fence_ch {
145 let run_len = trimmed.chars().take_while(|&c| c == ch).count();
146 match fence_state {
147 None => fence_state = Some((ch, run_len)),
148 Some((c, len)) if c == ch && run_len >= len => fence_state = None,
149 Some(_) => {} }
151 continue;
152 }
153 if fence_state.is_some() {
154 continue;
155 }
156 let command_text = trimmed
157 .strip_prefix("@orchestrator")
158 .or_else(|| trimmed.strip_prefix("@orch"))
159 .map_or(trimmed, str::trim_start);
160
161 if !command_text.starts_with('/') {
162 continue;
163 }
164 let parts: Vec<&str> = command_text.splitn(2, char::is_whitespace).collect();
165 let cmd = parts[0];
166 let args = parts
167 .get(1)
168 .map(|s| s.trim())
169 .filter(|s| !s.is_empty())
170 .map(String::from);
171
172 match cmd {
173 "/retry" => return Some(OwnerCommand::Retry),
174 "/reroute" => return Some(OwnerCommand::Reroute(args)),
175 "/close" => return Some(OwnerCommand::Close),
176 "/block" => return Some(OwnerCommand::Block(args)),
177 "/unblock" => return Some(OwnerCommand::Unblock),
178 "/review" => return Some(OwnerCommand::Review),
179 _ => {}
180 }
181 }
182 None
183}
184
185async fn cmd_reset_counters(store: Option<&Arc<dyn CommandStoreOps>>, repo: &str, task_id: &str) {
188 let Some(s) = store else { return };
189 match s.resolve_task_id(repo, task_id).await {
190 Ok(Some(id)) => {
191 if let Err(e) = s.reset_counters(id).await {
192 tracing::warn!(task_id, err = %e, "store reset_counters failed");
193 }
194 }
195 Ok(None) => {} Err(e) => {
197 tracing::warn!(task_id, err = %e, "resolve_task_id failed in store write helper");
198 }
199 }
200}
201
202async fn refuse_if_done(
207 store: Option<&Arc<dyn CommandStoreOps>>,
208 repo: &str,
209 task_id: &str,
210 command: &OwnerCommand,
211) -> anyhow::Result<()> {
212 let Some(s) = store else { return Ok(()) };
213 if s.status(repo, task_id).await? != Some(TaskStatus::Done) {
214 return Ok(());
215 }
216 anyhow::bail!(
217 "`{command}` — task is done and done is terminal; open a new issue for follow-up work"
218 )
219}
220
221pub async fn execute_command(
223 backend: &Arc<dyn ExternalBackend>,
224 gh: &dyn CommandGh,
225 repo: &str,
226 task_id: &ExternalId,
227 command: &OwnerCommand,
228 store: &Option<Arc<dyn CommandStoreOps>>,
229 task_manager: &Arc<TaskManager>,
230) -> anyhow::Result<String> {
231 match command {
232 OwnerCommand::Retry => {
233 refuse_if_done(store.as_ref(), repo, &task_id.0, command).await?;
234 let task = backend.get_task(task_id).await?;
236 for label in &task.labels {
237 if label.starts_with("agent:") {
238 backend.remove_label(task_id, label).await?;
239 }
240 }
241 cmd_reset_counters(store.as_ref(), repo, &task_id.0).await;
243 task_manager
244 .update_task_status(task_id, Status::New)
245 .await?;
246 Ok("`/retry` — reset attempts, cleared agent, reset to `status:new`".to_string())
247 }
248
249 OwnerCommand::Reroute(agent) => {
250 refuse_if_done(store.as_ref(), repo, &task_id.0, command).await?;
251 let task = backend.get_task(task_id).await?;
253 for label in &task.labels {
254 if label.starts_with("agent:") {
255 backend.remove_label(task_id, label).await?;
256 }
257 }
258 cmd_reset_counters(store.as_ref(), repo, &task_id.0).await;
260 if let Some(agent_name) = agent {
262 let label = format!("agent:{agent_name}");
263 backend.set_labels(task_id, &[label]).await?;
264 }
265 task_manager
266 .update_task_status(task_id, Status::New)
267 .await?;
268 match agent {
269 Some(a) => Ok(format!(
270 "`/reroute {a}` — cleared agent, reset attempts, forced `agent:{a}`, reset to `status:new`"
271 )),
272 None => {
273 Ok("`/reroute` — cleared agent, reset attempts, reset to `status:new`, will re-route".into())
274 }
275 }
276 }
277
278 OwnerCommand::Close => {
279 task_manager
280 .update_task_status(task_id, Status::Done)
281 .await?;
282 gh.close_issue(repo, &task_id.0).await?;
283 Ok("`/close` — marked `status:done` and closed issue".to_string())
284 }
285
286 OwnerCommand::Block(reason) => {
287 if let Some(store) = store {
288 let store_id = store.resolve_task_id(repo, &task_id.0).await?;
289 if let Some(store_id) = store_id {
290 store.set_block_reason(store_id, reason.as_deref()).await?;
291 }
292 }
293 task_manager
294 .update_task_status(task_id, Status::Blocked)
295 .await?;
296 match reason {
297 Some(r) => Ok(format!("`/block` — marked `status:blocked`: {r}")),
298 None => Ok("`/block` — marked `status:blocked`".to_string()),
299 }
300 }
301
302 OwnerCommand::Unblock => {
303 refuse_if_done(store.as_ref(), repo, &task_id.0, command).await?;
304 if let Some(store) = store {
305 let store_id = store.resolve_task_id(repo, &task_id.0).await?;
306 if let Some(store_id) = store_id {
307 store.set_block_reason(store_id, None).await?;
308 }
309 }
310 task_manager
311 .update_task_status(task_id, Status::New)
312 .await?;
313 Ok("`/unblock` — marked `status:new`, will re-dispatch".to_string())
314 }
315
316 OwnerCommand::Review => {
317 task_manager
318 .update_task_status(task_id, Status::NeedsReview)
319 .await?;
320 Ok(
321 "`/review` — set `status:needs_review`, review agent will pick up on next tick"
322 .to_string(),
323 )
324 }
325 }
326}
327
328#[derive(Debug)]
333pub enum CommandOutcome {
334 Executed { is_pr: bool },
336 NotOpen { is_pr: bool },
338 NotCollaborator { is_pr: bool },
340 FetchFailed,
342 CollaboratorCheckFailed,
344}
345
346#[expect(
351 clippy::too_many_arguments,
352 reason = "two scan paths pass their own context to one shared validator"
353)]
354pub async fn validate_and_run_command(
355 backend: &Arc<dyn ExternalBackend>,
356 gh: &dyn CommandGh,
357 repo: &str,
358 issue_number: &str,
359 command: &OwnerCommand,
360 author: &str,
361 store: &Option<Arc<dyn CommandStoreOps>>,
362 task_manager: &Arc<TaskManager>,
363) -> CommandOutcome {
364 let issue_data = match gh.get_issue(repo, issue_number).await {
366 Ok(data) => data,
367 Err(e) => {
368 tracing::warn!(issue = %issue_number, err = %e, "failed to check issue state");
369 return CommandOutcome::FetchFailed;
370 }
371 };
372
373 let is_pr = issue_data.pull_request.is_some();
374
375 if issue_data.state != "open" {
376 tracing::debug!(issue = %issue_number, state = %issue_data.state, command = %command, "ignoring slash command on non-open issue");
377 return CommandOutcome::NotOpen { is_pr };
378 }
379
380 match gh.is_collaborator(repo, author).await {
382 Ok(true) => {}
383 Ok(false) => {
384 tracing::info!(author, command = %command, issue = %issue_number, "ignoring slash command from non-collaborator");
385 return CommandOutcome::NotCollaborator { is_pr };
386 }
387 Err(e) => {
388 tracing::warn!(author, err = %e, "failed to check collaborator status");
389 return CommandOutcome::CollaboratorCheckFailed;
390 }
391 }
392
393 let task_id = ExternalId(issue_number.to_string());
395 let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
396
397 match execute_command(backend, gh, repo, &task_id, command, store, task_manager).await {
398 Ok(msg) => {
399 tracing::info!(issue = %issue_number, author, command = %command, "executed command");
400 let confirmation = format!(
401 "[{now}] {msg} — executed by @{author}{}",
402 engine::orch_footer()
403 );
404 if let Err(e) = backend.post_comment(&task_id, &confirmation).await {
405 tracing::warn!(issue = %issue_number, err = %e, "failed to post confirmation");
406 }
407 }
408 Err(e) => {
409 tracing::warn!(issue = %issue_number, command = %command, err = %e, "failed to execute command");
410 let error_msg = format!(
411 "[{now}] Failed to execute `{command}`: {e}{}",
412 engine::orch_footer()
413 );
414 if let Err(e2) = backend.post_comment(&task_id, &error_msg).await {
415 tracing::warn!(issue = %issue_number, err = %e2, "failed to post error comment");
416 }
417 }
418 }
419
420 CommandOutcome::Executed { is_pr }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use crate::backends::{ExternalBackend, ExternalId, ExternalTask, Mention, Status};
427 use crate::store::{NewTask, TaskStore};
428 use async_trait::async_trait;
429 use std::sync::Arc;
430
431 struct MockBackend {
432 task: ExternalTask,
433 fail_remove_label: bool,
434 }
435
436 impl MockBackend {
437 fn new(id: &str) -> Self {
438 Self {
439 task: ExternalTask {
440 id: ExternalId(id.to_string()),
441 title: "Task".to_string(),
442 body: String::new(),
443 state: "open".to_string(),
444 labels: vec![],
445 author: "bot".to_string(),
446 created_at: "2026-01-01T00:00:00Z".to_string(),
447 updated_at: "2026-01-01T00:00:00Z".to_string(),
448 url: String::new(),
449 },
450 fail_remove_label: false,
451 }
452 }
453
454 fn with_labels(id: &str, labels: Vec<String>) -> Self {
455 let mut b = Self::new(id);
456 b.task.labels = labels;
457 b
458 }
459
460 fn failing_remove_label(id: &str, labels: Vec<String>) -> Self {
461 let mut b = Self::with_labels(id, labels);
462 b.fail_remove_label = true;
463 b
464 }
465 }
466
467 #[async_trait]
468 impl ExternalBackend for MockBackend {
469 fn name(&self) -> &'static str {
470 "mock"
471 }
472
473 async fn create_task(
474 &self,
475 _title: &str,
476 _body: &str,
477 _labels: &[String],
478 ) -> anyhow::Result<ExternalId> {
479 Ok(self.task.id.clone())
480 }
481
482 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
483 Ok(self.task.clone())
484 }
485
486 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
487 Ok(vec![])
488 }
489
490 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
491 Ok(vec![])
492 }
493
494 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
495 Ok(())
496 }
497
498 async fn set_labels(&self, _id: &ExternalId, _labels: &[String]) -> anyhow::Result<()> {
499 Ok(())
500 }
501
502 async fn remove_label(&self, _id: &ExternalId, label: &str) -> anyhow::Result<()> {
503 if self.fail_remove_label {
504 anyhow::bail!("simulated remove_label failure for {label}");
505 }
506 Ok(())
507 }
508
509 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
510 Ok(vec![])
511 }
512
513 async fn create_sub_task(
514 &self,
515 _parent: &ExternalId,
516 _title: &str,
517 _body: &str,
518 _labels: &[String],
519 ) -> anyhow::Result<ExternalId> {
520 Ok(ExternalId("child".to_string()))
521 }
522
523 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
524 Ok(())
525 }
526
527 async fn has_open_issue_with_title(
528 &self,
529 _title: &str,
530 _label: &str,
531 ) -> anyhow::Result<bool> {
532 Ok(false)
533 }
534
535 async fn health_check(&self) -> anyhow::Result<()> {
536 Ok(())
537 }
538
539 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
540 Ok(false)
541 }
542
543 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
544 Ok(Some("testbot".to_string()))
545 }
546
547 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
548 Ok(vec![])
549 }
550
551 async fn update_status(&self, _id: &ExternalId, _status: Status) -> anyhow::Result<()> {
552 Ok(())
553 }
554 }
555
556 struct FailingStore;
558
559 #[async_trait]
560 impl CommandStoreOps for FailingStore {
561 async fn resolve_task_id(
562 &self,
563 _repo: &str,
564 _task_id: &str,
565 ) -> anyhow::Result<Option<i64>> {
566 anyhow::bail!("simulated resolve_task_id DB failure")
567 }
568
569 async fn set_block_reason(&self, _id: i64, _reason: Option<&str>) -> anyhow::Result<()> {
570 anyhow::bail!("simulated set_block_reason DB failure")
571 }
572
573 async fn reset_counters(&self, _id: i64) -> anyhow::Result<()> {
574 Ok(())
575 }
576
577 async fn status(&self, _repo: &str, _task_id: &str) -> anyhow::Result<Option<TaskStatus>> {
578 Ok(None)
579 }
580 }
581
582 #[tokio::test]
583 async fn block_command_persists_reason_and_unblock_clears_it() {
584 let store = Arc::new(TaskStore::open_memory().await.unwrap());
585 let store_id = store
586 .create(&NewTask {
587 external_id: Some("42".to_string()),
588 repo: "owner/repo".to_string(),
589 origin: "github".to_string(),
590 title: "Task".to_string(),
591 body: String::new(),
592 source: "manual".to_string(),
593 source_id: String::new(),
594 author: "bot".to_string(),
595 url: String::new(),
596 labels: vec![],
597 parent_id: None,
598 })
599 .await
600 .unwrap();
601 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new("42"));
602 let backend_for_call = Arc::clone(&backend);
603 let task_manager = Arc::new(TaskManager::with_store(
604 backend,
605 Arc::clone(&store),
606 "owner/repo".to_string(),
607 ));
608 let gh = GhHttp::new().unwrap();
609 let task_id = ExternalId("42".to_string());
610
611 execute_command(
612 &backend_for_call,
613 &gh,
614 "owner/repo",
615 &task_id,
616 &OwnerCommand::Block(Some("waiting on upstream fix".to_string())),
617 &Some(Arc::clone(&store) as Arc<dyn CommandStoreOps>),
618 &task_manager,
619 )
620 .await
621 .unwrap();
622
623 let task = store.get(store_id).await.unwrap();
624 assert_eq!(task.status, TaskStatus::Blocked);
625 assert_eq!(
626 task.block_reason.as_deref(),
627 Some("waiting on upstream fix")
628 );
629
630 execute_command(
631 &backend_for_call,
632 &gh,
633 "owner/repo",
634 &task_id,
635 &OwnerCommand::Unblock,
636 &Some(Arc::clone(&store) as Arc<dyn CommandStoreOps>),
637 &task_manager,
638 )
639 .await
640 .unwrap();
641
642 let task = store.get(store_id).await.unwrap();
643 assert_eq!(task.status, TaskStatus::New);
644 assert!(task.block_reason.is_none());
645 }
646
647 #[tokio::test]
650 async fn done_task_refuses_retry_reroute_and_unblock() {
651 let store = Arc::new(TaskStore::open_memory().await.unwrap());
652 let store_id = store
653 .create(&NewTask {
654 external_id: Some("58".to_string()),
655 repo: "owner/repo".to_string(),
656 origin: "github".to_string(),
657 title: "Task".to_string(),
658 body: String::new(),
659 source: "cron".to_string(),
660 source_id: "code-development".to_string(),
661 author: "bot".to_string(),
662 url: String::new(),
663 labels: vec![],
664 parent_id: None,
665 })
666 .await
667 .unwrap();
668 store
669 .update_status(store_id, TaskStatus::Done)
670 .await
671 .unwrap();
672
673 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new("58"));
674 let task_manager = Arc::new(TaskManager::with_store(
675 Arc::clone(&backend),
676 Arc::clone(&store),
677 "owner/repo".to_string(),
678 ));
679 let gh = GhHttp::new().unwrap();
680 let task_id = ExternalId("58".to_string());
681
682 for command in [
683 OwnerCommand::Retry,
684 OwnerCommand::Reroute(None),
685 OwnerCommand::Unblock,
686 ] {
687 let err = execute_command(
688 &backend,
689 &gh,
690 "owner/repo",
691 &task_id,
692 &command,
693 &Some(Arc::clone(&store) as Arc<dyn CommandStoreOps>),
694 &task_manager,
695 )
696 .await
697 .expect_err("done task must not be revived");
698 assert!(err.to_string().contains("done is terminal"), "got: {err}");
699 assert_eq!(
700 store.get(store_id).await.unwrap().status,
701 TaskStatus::Done,
702 "{command} must leave a done task done"
703 );
704 }
705 }
706
707 #[test]
708 fn parse_retry() {
709 assert_eq!(parse_command("/retry"), Some(OwnerCommand::Retry));
710 }
711
712 #[test]
713 fn parse_retry_with_trailing_whitespace() {
714 assert_eq!(parse_command("/retry "), Some(OwnerCommand::Retry));
715 }
716
717 #[test]
718 fn parse_close() {
719 assert_eq!(parse_command("/close"), Some(OwnerCommand::Close));
720 }
721
722 #[test]
723 fn parse_unblock() {
724 assert_eq!(parse_command("/unblock"), Some(OwnerCommand::Unblock));
725 }
726
727 #[test]
728 fn parse_review() {
729 assert_eq!(parse_command("/review"), Some(OwnerCommand::Review));
730 }
731
732 #[test]
733 fn parse_reroute_no_agent() {
734 assert_eq!(parse_command("/reroute"), Some(OwnerCommand::Reroute(None)));
735 }
736
737 #[test]
738 fn parse_reroute_with_agent() {
739 assert_eq!(
740 parse_command("/reroute codex"),
741 Some(OwnerCommand::Reroute(Some("codex".into())))
742 );
743 }
744
745 #[test]
746 fn parse_block_no_reason() {
747 assert_eq!(parse_command("/block"), Some(OwnerCommand::Block(None)));
748 }
749
750 #[test]
751 fn parse_block_with_reason() {
752 assert_eq!(
753 parse_command("/block waiting on upstream fix"),
754 Some(OwnerCommand::Block(Some("waiting on upstream fix".into())))
755 );
756 }
757
758 #[test]
759 fn parse_ignores_non_command_text() {
760 assert_eq!(parse_command("This is a regular comment"), None);
761 }
762
763 #[test]
764 fn parse_ignores_unknown_commands() {
765 assert_eq!(parse_command("/unknown"), None);
766 }
767
768 #[test]
769 fn parse_command_in_multiline_body() {
770 let body = "Some context here\n\n/retry\n\nMore text";
771 assert_eq!(parse_command(body), Some(OwnerCommand::Retry));
772 }
773
774 #[test]
775 fn parse_first_valid_command_wins() {
776 let body = "/retry\n/close";
777 assert_eq!(parse_command(body), Some(OwnerCommand::Retry));
778 }
779
780 #[test]
781 fn parse_skips_unknown_finds_valid() {
782 let body = "/unknown\n/close";
783 assert_eq!(parse_command(body), Some(OwnerCommand::Close));
784 }
785
786 #[test]
787 fn parse_indented_command() {
788 assert_eq!(parse_command(" /retry"), Some(OwnerCommand::Retry));
790 }
791
792 #[test]
793 fn parse_mention_prefixed_command() {
794 assert_eq!(parse_command("@orch /retry"), Some(OwnerCommand::Retry));
795 }
796
797 #[test]
798 fn parse_legacy_mention_prefixed_command() {
799 assert_eq!(
800 parse_command("@orchestrator /close"),
801 Some(OwnerCommand::Close)
802 );
803 }
804
805 #[test]
806 fn parse_ignores_non_command_after_mention() {
807 assert_eq!(parse_command("@orch please retry this"), None);
808 }
809
810 #[test]
811 fn parse_ignores_command_in_code_fence() {
812 let body = "Some context\n```\n/retry\n```\nMore text";
813 assert_eq!(parse_command(body), None);
814 }
815
816 #[test]
817 fn parse_ignores_mention_prefixed_command_in_code_fence() {
818 let body = "```\n@orch /retry\n```";
819 assert_eq!(parse_command(body), None);
820 }
821
822 #[test]
823 fn parse_ignores_command_in_tilde_fence() {
824 let body = "Some context\n~~~\n/close\n~~~\nMore text";
825 assert_eq!(parse_command(body), None);
826 }
827
828 #[test]
829 fn parse_finds_command_after_code_fence() {
830 let body = "```\n/retry\n```\n/close";
831 assert_eq!(parse_command(body), Some(OwnerCommand::Close));
832 }
833
834 #[test]
835 fn parse_ignores_command_in_code_fence_with_lang() {
836 let body = "```markdown\n/retry\n```";
837 assert_eq!(parse_command(body), None);
838 }
839
840 #[test]
841 fn parse_mixed_fence_does_not_close_backtick_with_tilde() {
842 let body = "```bash\n/retry\n~~~\n/retry\n```";
844 assert_eq!(parse_command(body), None);
845 }
846
847 #[test]
848 fn parse_mixed_fence_does_not_close_tilde_with_backtick() {
849 let body = "~~~\n/close\n```\n/close\n~~~";
851 assert_eq!(parse_command(body), None);
852 }
853
854 #[test]
855 fn parse_command_after_mixed_fence_properly_closed() {
856 let body = "```\n/retry\n~~~\n/retry\n```\n/close";
859 assert_eq!(parse_command(body), Some(OwnerCommand::Close));
860 }
861
862 #[tokio::test]
863 async fn retry_propagates_remove_label_error() {
864 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::failing_remove_label(
865 "42",
866 vec!["agent:claude".to_string()],
867 ));
868 let store = Arc::new(TaskStore::open_memory().await.unwrap());
869 let task_manager = Arc::new(TaskManager::with_store(
870 Arc::clone(&backend),
871 Arc::clone(&store),
872 "owner/repo".to_string(),
873 ));
874 let gh = GhHttp::new().unwrap();
875 let task_id = ExternalId("42".to_string());
876
877 let result = execute_command(
878 &backend,
879 &gh,
880 "owner/repo",
881 &task_id,
882 &OwnerCommand::Retry,
883 &Some(store as Arc<dyn CommandStoreOps>),
884 &task_manager,
885 )
886 .await;
887
888 assert!(result.is_err(), "/retry must fail when remove_label fails");
889 assert!(
890 result.unwrap_err().to_string().contains("remove_label"),
891 "error must mention remove_label"
892 );
893 }
894
895 #[tokio::test]
896 async fn reroute_propagates_remove_label_error() {
897 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::failing_remove_label(
898 "42",
899 vec!["agent:codex".to_string()],
900 ));
901 let store = Arc::new(TaskStore::open_memory().await.unwrap());
902 let task_manager = Arc::new(TaskManager::with_store(
903 Arc::clone(&backend),
904 Arc::clone(&store),
905 "owner/repo".to_string(),
906 ));
907 let gh = GhHttp::new().unwrap();
908 let task_id = ExternalId("42".to_string());
909
910 let result = execute_command(
911 &backend,
912 &gh,
913 "owner/repo",
914 &task_id,
915 &OwnerCommand::Reroute(None),
916 &Some(store as Arc<dyn CommandStoreOps>),
917 &task_manager,
918 )
919 .await;
920
921 assert!(
922 result.is_err(),
923 "/reroute must fail when remove_label fails"
924 );
925 assert!(
926 result.unwrap_err().to_string().contains("remove_label"),
927 "error must mention remove_label"
928 );
929 }
930
931 #[tokio::test]
932 async fn block_propagates_resolve_task_id_error() {
933 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new("42"));
934 let store = Arc::new(TaskStore::open_memory().await.unwrap());
935 let task_manager = Arc::new(TaskManager::with_store(
936 Arc::clone(&backend),
937 store,
938 "owner/repo".to_string(),
939 ));
940 let gh = GhHttp::new().unwrap();
941 let task_id = ExternalId("42".to_string());
942 let failing_store: Arc<dyn CommandStoreOps> = Arc::new(FailingStore);
943
944 let result = execute_command(
945 &backend,
946 &gh,
947 "owner/repo",
948 &task_id,
949 &OwnerCommand::Block(Some("reason".to_string())),
950 &Some(failing_store),
951 &task_manager,
952 )
953 .await;
954
955 assert!(
956 result.is_err(),
957 "/block must fail when resolve_task_id fails"
958 );
959 assert!(
960 result.unwrap_err().to_string().contains("resolve_task_id"),
961 "error must mention resolve_task_id"
962 );
963 }
964
965 #[tokio::test]
966 async fn unblock_propagates_resolve_task_id_error() {
967 let backend: Arc<dyn ExternalBackend> = Arc::new(MockBackend::new("42"));
968 let store = Arc::new(TaskStore::open_memory().await.unwrap());
969 let task_manager = Arc::new(TaskManager::with_store(
970 Arc::clone(&backend),
971 store,
972 "owner/repo".to_string(),
973 ));
974 let gh = GhHttp::new().unwrap();
975 let task_id = ExternalId("42".to_string());
976 let failing_store: Arc<dyn CommandStoreOps> = Arc::new(FailingStore);
977
978 let result = execute_command(
979 &backend,
980 &gh,
981 "owner/repo",
982 &task_id,
983 &OwnerCommand::Unblock,
984 &Some(failing_store),
985 &task_manager,
986 )
987 .await;
988
989 assert!(
990 result.is_err(),
991 "/unblock must fail when resolve_task_id fails"
992 );
993 assert!(
994 result.unwrap_err().to_string().contains("resolve_task_id"),
995 "error must mention resolve_task_id"
996 );
997 }
998
999 #[test]
1000 fn parse_shorter_backtick_does_not_close_longer_backtick_fence() {
1001 let body = "````\n/retry\n```\n/retry\n````";
1003 assert_eq!(parse_command(body), None);
1004 }
1005
1006 #[test]
1007 fn parse_longer_backtick_closes_shorter_backtick_fence() {
1008 let body = "```\n/retry\n````\n/close";
1010 assert_eq!(parse_command(body), Some(OwnerCommand::Close));
1011 }
1012
1013 #[test]
1014 fn parse_shorter_tilde_does_not_close_longer_tilde_fence() {
1015 let body = "~~~~\n/close\n~~~\n/close\n~~~~";
1017 assert_eq!(parse_command(body), None);
1018 }
1019
1020 #[test]
1021 fn parse_longer_tilde_closes_shorter_tilde_fence() {
1022 let body = "~~~\n/close\n~~~~\n/retry";
1024 assert_eq!(parse_command(body), Some(OwnerCommand::Retry));
1025 }
1026
1027 #[test]
1028 fn parse_command_after_longer_backtick_fence_properly_closed() {
1029 let body = "````markdown\n/retry\n```\n/retry\n````\n/close";
1031 assert_eq!(parse_command(body), Some(OwnerCommand::Close));
1032 }
1033
1034 #[test]
1035 fn display_commands() {
1036 assert_eq!(OwnerCommand::Retry.to_string(), "/retry");
1037 assert_eq!(
1038 OwnerCommand::Reroute(Some("codex".into())).to_string(),
1039 "/reroute codex"
1040 );
1041 assert_eq!(OwnerCommand::Reroute(None).to_string(), "/reroute");
1042 assert_eq!(OwnerCommand::Close.to_string(), "/close");
1043 assert_eq!(
1044 OwnerCommand::Block(Some("reason".into())).to_string(),
1045 "/block reason"
1046 );
1047 assert_eq!(OwnerCommand::Block(None).to_string(), "/block");
1048 assert_eq!(OwnerCommand::Unblock.to_string(), "/unblock");
1049 assert_eq!(OwnerCommand::Review.to_string(), "/review");
1050 }
1051}