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