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