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