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