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