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