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