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