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