Skip to main content

orch/backends/
mod.rs

1//! External backend abstraction — trait for issue trackers.
2//!
3//! GitHub Issues is the first implementation. The trait is designed so
4//! Linear, Jira, GitLab, or any issue tracker can be swapped in later.
5
6pub mod github;
7
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10
11/// Opaque identifier from the external system (issue number, Linear ID, etc.)
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ExternalId(pub String);
14
15/// A mention/comment from the external system (backend-agnostic).
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Mention {
18    /// Unique identifier for deduplication.
19    pub id: String,
20    /// The comment/mention body text.
21    pub body: String,
22    /// Author of the mention.
23    pub author: String,
24    /// When the mention was created (RFC 3339).
25    pub created_at: String,
26    /// API URL of the issue/PR this comment belongs to.
27    /// Format: https://api.github.com/repos/owner/repo/issues/123
28    pub issue_url: Option<String>,
29}
30
31/// A task as represented in the external system.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct ExternalTask {
34    pub id: ExternalId,
35    pub title: String,
36    pub body: String,
37    pub state: String,
38    pub labels: Vec<String>,
39    pub author: String,
40    pub created_at: String,
41    pub updated_at: String,
42    pub url: String,
43}
44
45/// Task status values understood by all backends.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
47#[serde(rename_all = "snake_case")]
48pub enum Status {
49    New,
50    Routed,
51    InProgress,
52    Done,
53    Blocked,
54    InReview,
55    // A review approved the PR, orch waits for CI and merges, no agent runs
56    Approved,
57    NeedsReview,
58    Waiting,
59}
60
61impl Status {
62    pub fn as_label(&self) -> &'static str {
63        match self {
64            Self::New => "status:new",
65            Self::Routed => "status:routed",
66            Self::InProgress => "status:in_progress",
67            Self::Done => "status:done",
68            Self::Blocked => "status:blocked",
69            Self::InReview => "status:in_review",
70            Self::Approved => "status:approved",
71            Self::NeedsReview => "status:needs_review",
72            Self::Waiting => "status:waiting",
73        }
74    }
75
76    pub fn as_str(&self) -> &'static str {
77        match self {
78            Self::New => "new",
79            Self::Routed => "routed",
80            Self::InProgress => "in_progress",
81            Self::Done => "done",
82            Self::Blocked => "blocked",
83            Self::InReview => "in_review",
84            Self::Approved => "approved",
85            Self::NeedsReview => "needs_review",
86            Self::Waiting => "waiting",
87        }
88    }
89}
90
91/// One issue that blocks another, from `GET .../dependencies/blocked_by`.
92///
93/// The REST endpoint is GA since 2025-08-21. No preview media type.
94/// Callers send `X-GitHub-Api-Version: 2026-03-10`
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct BlockedByIssue {
97    pub github_id: i64,
98    pub repo: String,
99    pub number: i64,
100    pub state: String,
101    pub state_reason: Option<String>,
102}
103
104/// Merge and review state of one pull request, read from GitHub.
105///
106/// `approved` is the latest substantive review (`APPROVED` or
107/// `CHANGES_REQUESTED`), or an approving `## Automated Review` comment from
108/// the orch reviewer account when GitHub's review list is empty
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct PullRequestCompletion {
111    pub merged: bool,
112    pub base_ref: String,
113    pub default_branch: String,
114    pub approved: bool,
115    pub closed: bool,
116}
117
118/// Result of reading native blocked-by links.
119///
120/// `Native` is the GitHub blocked-by list, including when it is empty.
121/// `Unavailable` means the API is missing or forbidden, so the native list is empty.
122/// `Failed` means the read did not succeed, so stored rows stay as they are
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum BlockedByFetch {
125    Native(Vec<BlockedByIssue>),
126    Unavailable,
127    Failed(String),
128}
129
130/// The core trait for external task backends.
131///
132/// Each implementation talks to a different issue tracker.
133/// The engine calls these methods without knowing which backend is active.
134#[async_trait]
135pub trait ExternalBackend: Send + Sync {
136    /// Human-readable name (e.g. "github", "linear", "jira")
137    fn name(&self) -> &str;
138
139    /// Create a task in the external system.
140    async fn create_task(
141        &self,
142        title: &str,
143        body: &str,
144        labels: &[String],
145    ) -> anyhow::Result<ExternalId>;
146
147    /// Fetch a task by its external ID.
148    async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask>;
149
150    /// Update task status using idempotent remove + add operations.
151    ///
152    /// Default implementation retries up to 3 times with a fresh snapshot on
153    /// each attempt to narrow the TOCTOU window. Backends that provide a native
154    /// atomic status-update endpoint can override this.
155    ///
156    /// The retry loop:
157    ///   1. Fetches a fresh label snapshot via `get_task`.
158    ///   2. Removes all `status:*` labels except the target via `remove_label`.
159    ///   3. Adds the target label via `set_labels`.
160    ///
161    /// Note: labels removed in a previous attempt are already gone — the retry
162    /// re-evaluates the current state rather than undoing prior work.
163    async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
164        const MAX_RETRIES: u32 = 3;
165        let label = status.as_label();
166
167        // Hook for backends that need to auto-create labels (e.g. GitHub).
168        if let Err(e) = self.ensure_status_label(label).await {
169            tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
170        }
171
172        let mut last_err =
173            anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
174
175        for attempt in 0..MAX_RETRIES {
176            if attempt > 0 {
177                tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
178                    .await;
179                tracing::debug!(attempt, label, "retrying update_status");
180            }
181
182            // Fresh snapshot on every attempt.
183            let task = match self.get_task(id).await {
184                Ok(t) => t,
185                Err(e) => {
186                    last_err = e;
187                    continue;
188                }
189            };
190
191            // Remove all existing status labels except the target.
192            let mut remove_failed = false;
193            for old in task
194                .labels
195                .iter()
196                .filter(|l| l.starts_with("status:") && l.as_str() != label)
197            {
198                if let Err(e) = self.remove_label(id, old).await {
199                    tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
200                    last_err = e;
201                    remove_failed = true;
202                    break;
203                }
204            }
205            if remove_failed {
206                continue;
207            }
208
209            // Add the target status label.
210            match self.set_labels(id, &[label.to_string()]).await {
211                Ok(()) => return Ok(()),
212                Err(e) => {
213                    tracing::warn!(label, err = %e, attempt, "set_labels failed");
214                    last_err = e;
215                }
216            }
217        }
218
219        Err(last_err.context(format!(
220            "update_status({label}) failed after {MAX_RETRIES} attempts"
221        )))
222    }
223
224    /// List tasks by status.
225    async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>>;
226
227    /// List all tasks (open and closed) in a single API call.
228    ///
229    /// This is more efficient than calling `list_by_status` for each status
230    /// when you need counts or details for all statuses.
231    async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
232        // Default implementation falls back to calling list_by_status for each status
233        // This will be overridden by GitHub backend for efficiency
234        let statuses = [
235            Status::New,
236            Status::Routed,
237            Status::InProgress,
238            Status::Done,
239            Status::Blocked,
240            Status::InReview,
241            Status::Approved,
242            Status::NeedsReview,
243            Status::Waiting,
244        ];
245        let mut all_tasks = Vec::new();
246        for status in statuses {
247            let tasks = self.list_by_status(status).await?;
248            all_tasks.extend(tasks);
249        }
250        Ok(all_tasks)
251    }
252
253    /// List tasks for closed-issue reconciliation fallback.
254    ///
255    /// Used when `list_all_tasks` fails (e.g., transient API issues).
256    /// Default implementation returns an empty list, meaning no fallback data.
257    async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
258        Ok(Vec::new())
259    }
260
261    /// List open tasks that are routable — no `status:*` label yet, or `status:new`.
262    ///
263    /// Default implementation falls back to `list_by_status(New)`.
264    async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
265        self.list_by_status(Status::New).await
266    }
267
268    /// Fetch all open issues for ingest in a single logical call, returning each issue paired
269    /// with its detected status (`None` = unlabeled/routable, `Some(s)` = has status label `s`).
270    ///
271    /// The default implementation calls `list_routable()` followed by `list_by_status()` for
272    /// each active non-new status, deduplicating by ID.  Backends that support bulk listing
273    /// (e.g. GitHub) should override this to fetch all open issues in one API call and
274    /// partition locally, eliminating the 1 + N fan-out.
275    async fn list_active_open_issues(&self) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
276        let mut seen = std::collections::HashSet::new();
277        let mut result = Vec::new();
278
279        // Routable tasks: unlabeled issues + status:new → no status sync needed.
280        if let Ok(routable) = self.list_routable().await {
281            for task in routable {
282                if seen.insert(task.id.0.clone()) {
283                    result.push((task, None));
284                }
285            }
286        }
287
288        // Remaining active statuses (non-new) that require status sync on first ingest.
289        let active_non_new = [
290            Status::Routed,
291            Status::InProgress,
292            Status::NeedsReview,
293            Status::InReview,
294            Status::Approved,
295            Status::Blocked,
296            Status::Waiting,
297        ];
298        for status in active_non_new {
299            if let Ok(tasks) = self.list_by_status(status).await {
300                for task in tasks {
301                    if seen.insert(task.id.0.clone()) {
302                        result.push((task, Some(status)));
303                    }
304                }
305            }
306        }
307
308        Ok(result)
309    }
310
311    /// Post a comment / activity note.
312    async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()>;
313
314    /// Add metadata labels / tags (additive — does not remove existing labels).
315    /// The GitHub backend uses POST /repos/{repo}/issues/{number}/labels,
316    /// which appends to the existing label set.
317    async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()>;
318
319    /// Remove a label / tag.
320    async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()>;
321
322    /// Get sub-issues (children) of a task.
323    ///
324    /// Returns the list of external IDs for sub-issues.
325    /// Empty list means no sub-issues or sub-issues not supported.
326    async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>>;
327
328    /// Create a child task and link it as a sub-issue of the parent.
329    ///
330    /// Creates the task via `create_task`, then attempts to establish
331    /// a native sub-issue relationship. Falls back to `parent:{id}` label
332    /// if the native link fails.
333    async fn create_sub_task(
334        &self,
335        parent_id: &ExternalId,
336        title: &str,
337        body: &str,
338        labels: &[String],
339    ) -> anyhow::Result<ExternalId> {
340        // Default: create task with parent label, no native sub-issue link
341        let mut all_labels = labels.to_vec();
342        all_labels.push(format!("parent:{}", parent_id.0));
343        self.create_task(title, body, &all_labels).await
344    }
345
346    /// Ensure a status label exists in the backend system.
347    ///
348    /// Called by the default `update_status` before applying labels.
349    /// GitHub overrides this to auto-create labels on the repo.
350    /// Default is a no-op.
351    async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
352        Ok(())
353    }
354
355    /// Sync a task to the project board (if configured).
356    ///
357    /// Called when a task is first ingested to ensure it appears on the
358    /// project board immediately, not just when status changes later.
359    /// Returns `Some(node_id)` if the item was added/updated on the project board,
360    /// or `None` if project sync is not configured or failed.
361    /// Default is a no-op returning `None`; GitHub backend implements project board sync.
362    async fn sync_to_project(
363        &self,
364        _id: &ExternalId,
365        _status: Status,
366    ) -> anyhow::Result<Option<String>> {
367        Ok(None)
368    }
369
370    /// Repair project display state from authoritative external labels.
371    /// Called by periodic and webhook-triggered sync, independent of ingestion.
372    /// Does not transition tasks or dispatch agents. Default is a no-op.
373    async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
374        Ok(())
375    }
376
377    /// Sync a task's estimate to the project board Estimate field.
378    ///
379    /// Called after the router assigns an estimate to a task (via `store_route`).
380    /// Writes the estimate value to GitHub Projects V2 so humans can see it on the board.
381    /// Default is a no-op; GitHub backend implements estimate project sync.
382    async fn sync_estimate_to_project(
383        &self,
384        _id: &ExternalId,
385        _estimate: u8,
386    ) -> anyhow::Result<()> {
387        Ok(())
388    }
389
390    /// Batch-fetch project item estimates for a list of issue node IDs.
391    ///
392    /// Returns a map from issue node ID → estimate value (0 if not set).
393    /// Used during ingestion to populate `tasks.estimate` from GitHub Projects
394    /// when the orch estimate is still 0.
395    /// Default returns an empty map; GitHub backend implements batch estimate fetch.
396    async fn get_project_item_estimates(
397        &self,
398        _issue_node_ids: &[String],
399    ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
400        Ok(std::collections::HashMap::new())
401    }
402
403    /// Check if an open issue with the given title already exists.
404    ///
405    /// Used for deduplication before creating issues. The `label` parameter
406    /// narrows the search to issues with a specific label.
407    /// Default returns `false` (no dedup for backends that don't support it).
408    async fn has_open_issue_with_title(&self, _title: &str, _label: &str) -> anyhow::Result<bool> {
409        Ok(false)
410    }
411
412    /// Check if connected and authenticated.
413    async fn health_check(&self) -> anyhow::Result<()>;
414
415    /// Check if a PR/merge request for the given branch has been merged.
416    ///
417    /// Returns false by default (backends that don't track PRs).
418    async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
419        Ok(false)
420    }
421
422    /// Check if PRs for the given branches have been merged, in a single batch.
423    ///
424    /// Returns a map of branch → merged. Backends that support batch queries
425    /// (e.g. via GraphQL) should override this; the default falls back to
426    /// serial `is_pr_merged` calls.
427    async fn batch_is_pr_merged(
428        &self,
429        branches: &[String],
430    ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
431        let mut result = std::collections::HashMap::new();
432        for branch in branches {
433            match self.is_pr_merged(branch).await {
434                Ok(merged) => {
435                    result.insert(branch.clone(), merged);
436                }
437                Err(e) => {
438                    tracing::warn!(branch, err = %e, "failed to check PR merge status");
439                }
440            }
441        }
442        Ok(result)
443    }
444
445    /// Get the authenticated user's username.
446    ///
447    /// Returns None by default (backends that don't support user identity).
448    async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
449        Ok(None)
450    }
451
452    /// Get recent mentions/comments since a given timestamp.
453    ///
454    /// Returns empty by default (backends that don't support mention scanning).
455    async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
456        Ok(vec![])
457    }
458
459    /// Get all comments for a specific task/issue.
460    ///
461    /// Returns empty by default (backends that don't support per-issue comment listing).
462    async fn list_issue_comments(&self, _id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
463        Ok(vec![])
464    }
465
466    /// Acknowledge a newly detected issue (e.g., add a reaction emoji).
467    ///
468    /// Called when an issue is first ingested into the store.
469    /// Default is a no-op; GitHub backend adds an eyes emoji reaction.
470    async fn acknowledge_issue(&self, _id: &ExternalId) -> anyhow::Result<()> {
471        Ok(())
472    }
473
474    /// Acknowledge a mention (comment) by reacting with the eyes emoji.
475    ///
476    /// Called when a new @mention is detected in an issue or PR comment.
477    /// Default is a no-op; GitHub backend adds an eyes emoji reaction to the comment.
478    async fn acknowledge_mention(&self, _comment_id: &str) -> anyhow::Result<()> {
479        Ok(())
480    }
481
482    /// Native blocked-by issues for `issue_number` in `repo`.
483    ///
484    /// Default is `Unavailable`, an empty native list. A 200 with an empty list
485    /// is `Native`, not this default
486    async fn blocked_by(&self, _repo: &str, _issue_number: i64) -> anyhow::Result<BlockedByFetch> {
487        Ok(BlockedByFetch::Unavailable)
488    }
489
490    /// Close state of an issue orch may not track.
491    ///
492    /// `Ok(None)` means unknown. Callers treat unknown as still open
493    async fn issue_close_state(
494        &self,
495        _repo: &str,
496        _issue_number: i64,
497    ) -> anyhow::Result<Option<(String, Option<String>)>> {
498        Ok(None)
499    }
500
501    /// Post a comment on an issue in `repo`, which may differ from this backend's repo.
502    ///
503    /// The default posts through `post_comment`, which uses the backend repo
504    async fn post_comment_in_repo(
505        &self,
506        _repo: &str,
507        id: &ExternalId,
508        body: &str,
509    ) -> anyhow::Result<()> {
510        self.post_comment(id, body).await
511    }
512
513    /// Review and merge state of pull request `pr_number` in `repo`.
514    ///
515    /// `Ok(None)` means the pull request does not exist. Callers that already
516    /// know a PR number treat that as not merged
517    async fn pull_request_completion(
518        &self,
519        _repo: &str,
520        _pr_number: i64,
521    ) -> anyhow::Result<Option<PullRequestCompletion>> {
522        Ok(None)
523    }
524
525    /// Record that `issue_number` in this backend's repo is blocked by another issue.
526    ///
527    /// The blocking issue is named by repository and number. The write uses that
528    /// issue's database id, which is valid across repositories
529    async fn add_dependency(
530        &self,
531        _issue_number: i64,
532        _blocking_repo: &str,
533        _blocking_number: i64,
534    ) -> anyhow::Result<()> {
535        anyhow::bail!("this backend cannot write issue dependencies")
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use async_trait::async_trait;
543    use std::sync::{Arc, Mutex};
544
545    /// Mock backend that exercises the default trait `update_status` implementation.
546    ///
547    /// Does NOT override `update_status` — the trait default (retry loop with
548    /// get_task → remove_label → set_labels) is what runs. This means tests
549    /// verify the actual production orchestration logic, not a simplified mock.
550    struct MockBackend {
551        /// Current labels on the (single) tracked issue.
552        labels: Arc<Mutex<Vec<String>>>,
553        /// If set, `remove_label` returns this error for the matching label.
554        remove_err: Option<String>,
555        /// Counter for `set_labels` failures. Each call decrements; fails while > 0.
556        set_labels_fail_count: Arc<Mutex<u32>>,
557    }
558
559    impl MockBackend {
560        fn with_labels(labels: Vec<&str>) -> Self {
561            Self {
562                labels: Arc::new(Mutex::new(labels.into_iter().map(String::from).collect())),
563                remove_err: None,
564                set_labels_fail_count: Arc::new(Mutex::new(0)),
565            }
566        }
567
568        fn current_labels(&self) -> Vec<String> {
569            self.labels.lock().unwrap().clone()
570        }
571    }
572
573    #[async_trait]
574    impl ExternalBackend for MockBackend {
575        fn name(&self) -> &'static str {
576            "mock"
577        }
578
579        async fn create_task(
580            &self,
581            _title: &str,
582            _body: &str,
583            _labels: &[String],
584        ) -> anyhow::Result<ExternalId> {
585            Ok(ExternalId("1".into()))
586        }
587
588        async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
589            Ok(ExternalTask {
590                id: ExternalId("1".into()),
591                title: "mock".into(),
592                body: String::new(),
593                state: "open".into(),
594                labels: self.current_labels(),
595                author: "user".into(),
596                created_at: "2026-01-01T00:00:00Z".into(),
597                updated_at: "2026-01-01T00:00:00Z".into(),
598                url: "https://example.com".into(),
599            })
600        }
601
602        // update_status is NOT overridden — uses the default trait implementation
603        // which calls get_task, remove_label, set_labels with retry logic.
604
605        async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
606            Ok(vec![])
607        }
608
609        async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
610            Ok(())
611        }
612
613        async fn set_labels(&self, _id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
614            let mut fail_count = self.set_labels_fail_count.lock().unwrap();
615            if *fail_count > 0 {
616                *fail_count -= 1;
617                return Err(anyhow::anyhow!("transient network error"));
618            }
619            drop(fail_count);
620            let mut current = self.labels.lock().unwrap();
621            for l in labels {
622                if !current.contains(l) {
623                    current.push(l.clone());
624                }
625            }
626            Ok(())
627        }
628
629        async fn remove_label(&self, _id: &ExternalId, label: &str) -> anyhow::Result<()> {
630            if let Some(ref err_label) = self.remove_err
631                && label == err_label
632            {
633                return Err(anyhow::anyhow!("rate limit (429)"));
634            }
635            self.labels.lock().unwrap().retain(|l| l != label);
636            Ok(())
637        }
638
639        async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
640            Ok(vec![])
641        }
642
643        async fn health_check(&self) -> anyhow::Result<()> {
644            Ok(())
645        }
646    }
647
648    // --- Tests exercise the default trait update_status (retry + remove + add) ---
649
650    #[tokio::test]
651    async fn update_status_replaces_single_old_label() {
652        let backend = MockBackend::with_labels(vec!["status:new", "priority:high"]);
653        let id = ExternalId("1".into());
654        backend
655            .update_status(&id, Status::InProgress)
656            .await
657            .unwrap();
658        let labels = backend.current_labels();
659        assert!(
660            labels.contains(&"status:in_progress".to_string()),
661            "new label added: {labels:?}"
662        );
663        assert!(
664            !labels.contains(&"status:new".to_string()),
665            "old label removed: {labels:?}"
666        );
667        assert!(
668            labels.contains(&"priority:high".to_string()),
669            "non-status label preserved: {labels:?}"
670        );
671    }
672
673    #[tokio::test]
674    async fn update_status_removes_multiple_old_status_labels() {
675        // Simulate state left by a previous partial failure: two status labels
676        let backend = MockBackend::with_labels(vec!["status:in_progress", "status:blocked", "bug"]);
677        let id = ExternalId("1".into());
678        backend.update_status(&id, Status::Done).await.unwrap();
679        let labels = backend.current_labels();
680        let status_labels: Vec<_> = labels.iter().filter(|l| l.starts_with("status:")).collect();
681        assert_eq!(
682            status_labels,
683            vec!["status:done"],
684            "exactly one status label: {labels:?}"
685        );
686    }
687
688    #[tokio::test]
689    async fn update_status_is_idempotent() {
690        let backend = MockBackend::with_labels(vec!["status:done"]);
691        let id = ExternalId("1".into());
692        backend.update_status(&id, Status::Done).await.unwrap();
693        let labels = backend.current_labels();
694        let status_count = labels.iter().filter(|l| l.starts_with("status:")).count();
695        assert_eq!(
696            status_count, 1,
697            "idempotent: still exactly one status label: {labels:?}"
698        );
699    }
700
701    #[tokio::test]
702    async fn update_status_non_status_labels_are_preserved() {
703        let backend =
704            MockBackend::with_labels(vec!["status:routed", "agent:claude", "maintenance"]);
705        let id = ExternalId("1".into());
706        backend
707            .update_status(&id, Status::InProgress)
708            .await
709            .unwrap();
710        let labels = backend.current_labels();
711        assert!(
712            labels.contains(&"agent:claude".to_string()),
713            "agent label preserved: {labels:?}"
714        );
715        assert!(
716            labels.contains(&"maintenance".to_string()),
717            "other label preserved: {labels:?}"
718        );
719    }
720
721    #[tokio::test]
722    async fn lifecycle_transition_preserves_board_label_and_no_agent_hold() {
723        let backend = MockBackend::with_labels(vec!["status:new", "board:held", "no-agent"]);
724        backend
725            .update_status(&ExternalId("1".into()), Status::Routed)
726            .await
727            .unwrap();
728        let mut labels = backend.current_labels();
729        labels.sort();
730        assert_eq!(labels, vec!["board:held", "no-agent", "status:routed"]);
731    }
732
733    #[tokio::test]
734    async fn update_status_retries_on_transient_set_labels_failure() {
735        let backend = MockBackend::with_labels(vec!["status:new"]);
736        // set_labels fails once, then succeeds on retry
737        *backend.set_labels_fail_count.lock().unwrap() = 1;
738        let id = ExternalId("1".into());
739        backend
740            .update_status(&id, Status::InProgress)
741            .await
742            .unwrap();
743        let labels = backend.current_labels();
744        assert!(
745            labels.contains(&"status:in_progress".to_string()),
746            "label added after retry: {labels:?}"
747        );
748        assert!(
749            !labels.contains(&"status:new".to_string()),
750            "old label removed: {labels:?}"
751        );
752    }
753
754    #[tokio::test]
755    async fn update_status_fails_after_max_retries() {
756        let backend = MockBackend::with_labels(vec!["status:new"]);
757        // set_labels always fails (more failures than retries)
758        *backend.set_labels_fail_count.lock().unwrap() = 10;
759        let id = ExternalId("1".into());
760        let result = backend.update_status(&id, Status::Done).await;
761        assert!(result.is_err(), "should fail after max retries");
762        assert!(
763            result
764                .unwrap_err()
765                .to_string()
766                .contains("failed after 3 attempts"),
767            "error message mentions retry exhaustion"
768        );
769    }
770
771    #[test]
772    fn status_as_label() {
773        assert_eq!(Status::New.as_label(), "status:new");
774        assert_eq!(Status::Routed.as_label(), "status:routed");
775        assert_eq!(Status::InProgress.as_label(), "status:in_progress");
776        assert_eq!(Status::Done.as_label(), "status:done");
777        assert_eq!(Status::Blocked.as_label(), "status:blocked");
778        assert_eq!(Status::InReview.as_label(), "status:in_review");
779        assert_eq!(Status::Approved.as_label(), "status:approved");
780        assert_eq!(Status::Approved.as_str(), "approved");
781        assert_eq!(
782            crate::store::TaskStatus::from_str("approved"),
783            Some(crate::store::TaskStatus::Approved)
784        );
785        assert_eq!(crate::store::TaskStatus::Approved.as_str(), "approved");
786        assert_eq!(Status::NeedsReview.as_label(), "status:needs_review");
787    }
788
789    #[test]
790    fn status_serializes_snake_case() {
791        let json = serde_json::to_string(&Status::InProgress).unwrap();
792        assert_eq!(json, "\"in_progress\"");
793    }
794
795    #[test]
796    fn status_deserializes_snake_case() {
797        let status: Status = serde_json::from_str("\"needs_review\"").unwrap();
798        assert_eq!(status, Status::NeedsReview);
799    }
800
801    #[test]
802    fn external_id_clone() {
803        let id = ExternalId("42".to_string());
804        let cloned = id.clone();
805        assert_eq!(id.0, cloned.0);
806    }
807
808    #[test]
809    fn external_task_serializes() {
810        let task = ExternalTask {
811            id: ExternalId("1".to_string()),
812            title: "Test".to_string(),
813            body: "Body".to_string(),
814            state: "open".to_string(),
815            labels: vec!["status:new".to_string()],
816            author: "user".to_string(),
817            created_at: "2026-01-01T00:00:00Z".to_string(),
818            updated_at: "2026-01-01T00:00:00Z".to_string(),
819            url: "https://github.com/test/test/issues/1".to_string(),
820        };
821        let json = serde_json::to_string(&task).unwrap();
822        assert!(json.contains("\"title\":\"Test\""));
823        assert!(json.contains("status:new"));
824    }
825}
826
827#[cfg(test)]
828pub(crate) mod test_helpers;