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