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