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 crate::store::TaskStatus;
10use async_trait::async_trait;
11use std::sync::Arc;
12
13/// Minimal store interface required by command execution.
14///
15/// Exists so tests can inject a mock that simulates DB failures without
16/// coupling the test harness to a real SQLite pool.
17#[allow(clippy::double_must_use)] // async_trait marks its boxed futures must_use twice
18#[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    /// Reset all per-attempt counters for a task. Fire-and-forget — failures are
25    /// logged but not propagated (same semantics as `store::store_reset_counters`).
26    async fn reset_counters(&self, id: i64) -> anyhow::Result<()>;
27
28    /// Current status of a task, or `None` when the store has no such task.
29    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/// Parsed owner command from an issue comment.
55#[derive(Debug, Clone, PartialEq)]
56pub enum OwnerCommand {
57    /// Reset to status:new, re-dispatch.
58    Retry,
59    /// Clear agent, re-route (optionally force agent).
60    Reroute(Option<String>),
61    /// Mark status:done, close issue.
62    Close,
63    /// Mark status:blocked with optional reason.
64    Block(Option<String>),
65    /// Mark status:new, re-dispatch.
66    Unblock,
67    /// Trigger review agent on current PR.
68    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
86/// Parse a slash command from a comment body.
87///
88/// Scans each line for a `/command` at the start, optionally prefixed by an
89/// `@orch` or `@orchestrator` mention on the same line. Returns the first
90/// valid command found. Unknown `/something` lines are skipped. Lines inside
91/// markdown fenced code blocks (``` or ~~~) are ignored to prevent accidental
92/// command execution from code examples. Per CommonMark, a backtick fence
93/// closes only a backtick fence and a tilde fence closes only a tilde fence —
94/// mismatched closers are ignored.
95pub fn parse_command(body: &str) -> Option<OwnerCommand> {
96    // Track the opening fence (character, length) so mismatched or shorter
97    // closers are ignored. Per CommonMark: a backtick fence closes only a
98    // backtick fence, a tilde fence closes only a tilde fence, and the closing
99    // fence must be at least as long as the opening fence.
100    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(_) => {} // mismatched closer or shorter — ignore
116            }
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
152/// Reset all attempt/failure counters for a task. Fire-and-forget — errors are
153/// logged as warnings but not returned so `/retry` and `/reroute` still succeed.
154async 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) => {} // task not in store — no-op is expected
163        Err(e) => {
164            tracing::warn!(task_id, err = %e, "resolve_task_id failed in store write helper");
165        }
166    }
167}
168
169/// Refuse a command that would move a done task back into the pipeline.
170///
171/// Done is terminal: a revived task re-runs the body it was created with, which
172/// may be months stale and duplicate work that already has its own task (#3780)
173async 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
188/// Execute a single owner command against a task.
189pub 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            // Remove agent labels, reset attempts and all failure counters, reset to new
202            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            // Reset store state (attempts + all failure counters) so the task starts fresh
209            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            // Remove existing agent labels and reset attempts and all failure counters
219            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            // Reset store state (attempts + all failure counters) so the task starts fresh
226            cmd_reset_counters(store, repo, &task_id.0).await;
227            // Optionally set new agent
228            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
295/// Result of attempting to validate and run a slash command.
296///
297/// Variants that successfully fetched issue data include `is_pr` so callers
298/// don't need a second `get_issue` call.
299pub enum CommandOutcome {
300    /// Command executed successfully or failed — either way, it was handled.
301    Executed { is_pr: bool },
302    /// Issue/PR is not open — command was skipped.
303    NotOpen { is_pr: bool },
304    /// Author is not a collaborator — command was skipped.
305    NotCollaborator { is_pr: bool },
306    /// Failed to fetch issue state — should retry.
307    FetchFailed,
308    /// Failed to check collaborator status — should retry.
309    CollaboratorCheckFailed,
310}
311
312/// Validate a slash command (issue open, author is collaborator) and execute it.
313///
314/// Shared by both `scan_commands` (issue comments) and `scan_mentions` (mention
315/// handling). Posts a confirmation or error comment on the issue/PR.
316#[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    // Check issue state
328    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    // Check collaborator status
344    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    // Execute and post result
357    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    /// Mock store whose `resolve_task_id` always returns an error.
520    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    /// Done is terminal: `/retry`, `/reroute` and `/unblock` must refuse a done
615    /// task instead of sending its stale body back to an agent (#3780).
616    #[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        // Commands with leading whitespace should still be detected
756        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        // ~~~ should NOT close a ``` fence — /retry is still inside the fence
810        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        // ``` should NOT close a ~~~ fence — /close is still inside the fence
817        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        // Fence opened with ``` and closed with ``` (tilde in between is ignored)
824        // Command after the real closing fence should be found
825        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        // ```` opens a 4-backtick fence; ``` (3 backticks) must NOT close it
969        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        // ``` opens a 3-backtick fence; ```` (4 backticks) IS allowed to close it
976        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        // ~~~~ opens a 4-tilde fence; ~~~ (3 tildes) must NOT close it
983        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        // ~~~ opens a 3-tilde fence; ~~~~ (4 tildes) IS allowed to close it
990        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        // ```` fence closed with ```` — command after should be found
997        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}