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