Skip to main content

orch/engine/
commands.rs

1//! Owner slash commands — parse, validate, and execute /commands in issue comments.
2//!
3//! Provides command parsing (`parse_command`), validation + execution
4//! (`validate_and_run_command`), and the individual command implementations.
5//! The scanning loop lives in `sync.rs` (`scan_comments`).
6
7use crate::backends::{ExternalBackend, ExternalId, Status};
8use crate::github::http::GhHttp;
9use async_trait::async_trait;
10use std::sync::Arc;
11
12/// Minimal store interface required by command execution.
13///
14/// Exists so tests can inject a mock that simulates DB failures without
15/// coupling the test harness to a real SQLite pool.
16#[allow(clippy::double_must_use)] // async_trait marks its boxed futures must_use twice
17#[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    /// Reset all per-attempt counters for a task. Fire-and-forget — failures are
24    /// logged but not propagated (same semantics as `store::store_reset_counters`).
25    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/// Parsed owner command from an issue comment.
44#[derive(Debug, Clone, PartialEq)]
45pub enum OwnerCommand {
46    /// Reset to status:new, re-dispatch.
47    Retry,
48    /// Clear agent, re-route (optionally force agent).
49    Reroute(Option<String>),
50    /// Mark status:done, close issue.
51    Close,
52    /// Mark status:blocked with optional reason.
53    Block(Option<String>),
54    /// Mark status:new, re-dispatch.
55    Unblock,
56    /// Trigger review agent on current PR.
57    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
75/// Parse a slash command from a comment body.
76///
77/// Scans each line for a `/command` at the start, optionally prefixed by an
78/// `@orch` or `@orchestrator` mention on the same line. Returns the first
79/// valid command found. Unknown `/something` lines are skipped. Lines inside
80/// markdown fenced code blocks (``` or ~~~) are ignored to prevent accidental
81/// command execution from code examples. Per CommonMark, a backtick fence
82/// closes only a backtick fence and a tilde fence closes only a tilde fence —
83/// mismatched closers are ignored.
84pub fn parse_command(body: &str) -> Option<OwnerCommand> {
85    // Track the opening fence (character, length) so mismatched or shorter
86    // closers are ignored. Per CommonMark: a backtick fence closes only a
87    // backtick fence, a tilde fence closes only a tilde fence, and the closing
88    // fence must be at least as long as the opening fence.
89    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(_) => {} // mismatched closer or shorter — ignore
105            }
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
141/// Reset all attempt/failure counters for a task. Fire-and-forget — errors are
142/// logged as warnings but not returned so `/retry` and `/reroute` still succeed.
143async 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) => {} // task not in store — no-op is expected
152        Err(e) => {
153            tracing::warn!(task_id, err = %e, "resolve_task_id failed in store write helper");
154        }
155    }
156}
157
158/// Execute a single owner command against a task.
159pub 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            // Remove agent labels, reset attempts and all failure counters, reset to new
171            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            // Reset store state (attempts + all failure counters) so the task starts fresh
178            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            // Remove existing agent labels and reset attempts and all failure counters
187            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            // Reset store state (attempts + all failure counters) so the task starts fresh
194            cmd_reset_counters(store, repo, &task_id.0).await;
195            // Optionally set new agent
196            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
262/// Result of attempting to validate and run a slash command.
263///
264/// Variants that successfully fetched issue data include `is_pr` so callers
265/// don't need a second `get_issue` call.
266pub enum CommandOutcome {
267    /// Command executed successfully or failed — either way, it was handled.
268    Executed { is_pr: bool },
269    /// Issue/PR is not open — command was skipped.
270    NotOpen { is_pr: bool },
271    /// Author is not a collaborator — command was skipped.
272    NotCollaborator { is_pr: bool },
273    /// Failed to fetch issue state — should retry.
274    FetchFailed,
275    /// Failed to check collaborator status — should retry.
276    CollaboratorCheckFailed,
277}
278
279/// Validate a slash command (issue open, author is collaborator) and execute it.
280///
281/// Shared by both `scan_commands` (issue comments) and `scan_mentions` (mention
282/// handling). Posts a confirmation or error comment on the issue/PR.
283#[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    // Check issue state
295    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    // Check collaborator status
311    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    // Execute and post result
324    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    /// Mock store whose `resolve_task_id` always returns an error.
487    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        // Commands with leading whitespace should still be detected
655        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        // ~~~ should NOT close a ``` fence — /retry is still inside the fence
709        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        // ``` should NOT close a ~~~ fence — /close is still inside the fence
716        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        // Fence opened with ``` and closed with ``` (tilde in between is ignored)
723        // Command after the real closing fence should be found
724        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        // ```` opens a 4-backtick fence; ``` (3 backticks) must NOT close it
868        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        // ``` opens a 3-backtick fence; ```` (4 backticks) IS allowed to close it
875        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        // ~~~~ opens a 4-tilde fence; ~~~ (3 tildes) must NOT close it
882        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        // ~~~ opens a 3-tilde fence; ~~~~ (4 tildes) IS allowed to close it
889        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        // ```` fence closed with ```` — command after should be found
896        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}