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