1pub mod github;
7
8use async_trait::async_trait;
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct ExternalId(pub String);
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Mention {
18 pub id: String,
20 pub body: String,
22 pub author: String,
24 pub created_at: String,
26 pub issue_url: Option<String>,
29}
30
31#[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#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum BlockedByFetch {
121 Native(Vec<BlockedByIssue>),
122 Unavailable,
123 Failed(String),
124}
125
126#[allow(clippy::double_must_use)] #[async_trait]
132pub trait ExternalBackend: Send + Sync {
133 fn name(&self) -> &str;
135
136 async fn create_task(
138 &self,
139 title: &str,
140 body: &str,
141 labels: &[String],
142 ) -> anyhow::Result<ExternalId>;
143
144 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask>;
146
147 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
161 let label = status.as_label();
162
163 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 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 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 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 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>>;
223
224 async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
229 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 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
254 Ok(Vec::new())
255 }
256
257 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
261 self.list_by_status(Status::New).await
262 }
263
264 async fn list_active_open_issues(&self) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
272 let mut seen = std::collections::HashSet::new();
273 let mut result = Vec::new();
274
275 if let Ok(routable) = self.list_routable().await {
277 for task in routable {
278 if seen.insert(task.id.0.clone()) {
279 result.push((task, None));
280 }
281 }
282 }
283
284 let active_non_new = [
286 Status::Routed,
287 Status::InProgress,
288 Status::NeedsReview,
289 Status::InReview,
290 Status::Blocked,
291 Status::Waiting,
292 ];
293 for status in active_non_new {
294 if let Ok(tasks) = self.list_by_status(status).await {
295 for task in tasks {
296 if seen.insert(task.id.0.clone()) {
297 result.push((task, Some(status)));
298 }
299 }
300 }
301 }
302
303 Ok(result)
304 }
305
306 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()>;
308
309 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()>;
313
314 async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()>;
316
317 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>>;
322
323 async fn create_sub_task(
329 &self,
330 parent_id: &ExternalId,
331 title: &str,
332 body: &str,
333 labels: &[String],
334 ) -> anyhow::Result<ExternalId> {
335 let mut all_labels = labels.to_vec();
337 all_labels.push(format!("parent:{}", parent_id.0));
338 self.create_task(title, body, &all_labels).await
339 }
340
341 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
347 Ok(())
348 }
349
350 async fn sync_to_project(
358 &self,
359 _id: &ExternalId,
360 _status: Status,
361 ) -> anyhow::Result<Option<String>> {
362 Ok(None)
363 }
364
365 async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
369 Ok(())
370 }
371
372 async fn sync_estimate_to_project(
378 &self,
379 _id: &ExternalId,
380 _estimate: u8,
381 ) -> anyhow::Result<()> {
382 Ok(())
383 }
384
385 async fn get_project_item_estimates(
392 &self,
393 _issue_node_ids: &[String],
394 ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
395 Ok(std::collections::HashMap::new())
396 }
397
398 #[allow(dead_code)]
404 async fn has_open_issue_with_title(&self, _title: &str, _label: &str) -> anyhow::Result<bool> {
405 Ok(false)
406 }
407
408 async fn health_check(&self) -> anyhow::Result<()>;
410
411 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
415 Ok(false)
416 }
417
418 async fn batch_is_pr_merged(
424 &self,
425 branches: &[String],
426 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
427 let mut result = std::collections::HashMap::new();
428 for branch in branches {
429 match self.is_pr_merged(branch).await {
430 Ok(merged) => {
431 result.insert(branch.clone(), merged);
432 }
433 Err(e) => {
434 tracing::warn!(branch, err = %e, "failed to check PR merge status");
435 }
436 }
437 }
438 Ok(result)
439 }
440
441 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
445 Ok(None)
446 }
447
448 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
452 Ok(vec![])
453 }
454
455 async fn list_issue_comments(&self, _id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
459 Ok(vec![])
460 }
461
462 async fn acknowledge_issue(&self, _id: &ExternalId) -> anyhow::Result<()> {
467 Ok(())
468 }
469
470 async fn acknowledge_mention(&self, _comment_id: &str) -> anyhow::Result<()> {
475 Ok(())
476 }
477
478 async fn blocked_by(&self, _repo: &str, _issue_number: i64) -> anyhow::Result<BlockedByFetch> {
483 Ok(BlockedByFetch::Unavailable)
484 }
485
486 async fn issue_close_state(
490 &self,
491 _repo: &str,
492 _issue_number: i64,
493 ) -> anyhow::Result<Option<(String, Option<String>)>> {
494 Ok(None)
495 }
496
497 async fn post_comment_in_repo(
501 &self,
502 _repo: &str,
503 id: &ExternalId,
504 body: &str,
505 ) -> anyhow::Result<()> {
506 self.post_comment(id, body).await
507 }
508
509 async fn pull_request_completion(
514 &self,
515 _repo: &str,
516 _pr_number: i64,
517 ) -> anyhow::Result<Option<PullRequestCompletion>> {
518 Ok(None)
519 }
520
521 async fn add_dependency(
526 &self,
527 _issue_number: i64,
528 _blocking_repo: &str,
529 _blocking_number: i64,
530 ) -> anyhow::Result<()> {
531 anyhow::bail!("this backend cannot write issue dependencies")
532 }
533}
534
535#[cfg(test)]
536mod tests {
537 use super::*;
538 use async_trait::async_trait;
539 use std::sync::{Arc, Mutex};
540
541 struct MockBackend {
547 labels: Arc<Mutex<Vec<String>>>,
549 remove_err: Option<String>,
551 set_labels_fail_count: Arc<Mutex<u32>>,
553 }
554
555 impl MockBackend {
556 fn with_labels(labels: Vec<&str>) -> Self {
557 Self {
558 labels: Arc::new(Mutex::new(labels.into_iter().map(String::from).collect())),
559 remove_err: None,
560 set_labels_fail_count: Arc::new(Mutex::new(0)),
561 }
562 }
563
564 fn current_labels(&self) -> Vec<String> {
565 self.labels.lock().unwrap().clone()
566 }
567 }
568
569 #[async_trait]
570 impl ExternalBackend for MockBackend {
571 fn name(&self) -> &str {
572 "mock"
573 }
574
575 async fn create_task(
576 &self,
577 _title: &str,
578 _body: &str,
579 _labels: &[String],
580 ) -> anyhow::Result<ExternalId> {
581 Ok(ExternalId("1".into()))
582 }
583
584 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
585 Ok(ExternalTask {
586 id: ExternalId("1".into()),
587 title: "mock".into(),
588 body: "".into(),
589 state: "open".into(),
590 labels: self.current_labels(),
591 author: "user".into(),
592 created_at: "2026-01-01T00:00:00Z".into(),
593 updated_at: "2026-01-01T00:00:00Z".into(),
594 url: "https://example.com".into(),
595 })
596 }
597
598 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
602 Ok(vec![])
603 }
604
605 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
606 Ok(())
607 }
608
609 async fn set_labels(&self, _id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
610 let mut fail_count = self.set_labels_fail_count.lock().unwrap();
611 if *fail_count > 0 {
612 *fail_count -= 1;
613 return Err(anyhow::anyhow!("transient network error"));
614 }
615 drop(fail_count);
616 let mut current = self.labels.lock().unwrap();
617 for l in labels {
618 if !current.contains(l) {
619 current.push(l.clone());
620 }
621 }
622 Ok(())
623 }
624
625 async fn remove_label(&self, _id: &ExternalId, label: &str) -> anyhow::Result<()> {
626 if let Some(ref err_label) = self.remove_err {
627 if label == err_label {
628 return Err(anyhow::anyhow!("rate limit (429)"));
629 }
630 }
631 self.labels.lock().unwrap().retain(|l| l != label);
632 Ok(())
633 }
634
635 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
636 Ok(vec![])
637 }
638
639 async fn health_check(&self) -> anyhow::Result<()> {
640 Ok(())
641 }
642 }
643
644 #[tokio::test]
647 async fn update_status_replaces_single_old_label() {
648 let backend = MockBackend::with_labels(vec!["status:new", "priority:high"]);
649 let id = ExternalId("1".into());
650 backend
651 .update_status(&id, Status::InProgress)
652 .await
653 .unwrap();
654 let labels = backend.current_labels();
655 assert!(
656 labels.contains(&"status:in_progress".to_string()),
657 "new label added: {labels:?}"
658 );
659 assert!(
660 !labels.contains(&"status:new".to_string()),
661 "old label removed: {labels:?}"
662 );
663 assert!(
664 labels.contains(&"priority:high".to_string()),
665 "non-status label preserved: {labels:?}"
666 );
667 }
668
669 #[tokio::test]
670 async fn update_status_removes_multiple_old_status_labels() {
671 let backend = MockBackend::with_labels(vec!["status:in_progress", "status:blocked", "bug"]);
673 let id = ExternalId("1".into());
674 backend.update_status(&id, Status::Done).await.unwrap();
675 let labels = backend.current_labels();
676 let status_labels: Vec<_> = labels.iter().filter(|l| l.starts_with("status:")).collect();
677 assert_eq!(
678 status_labels,
679 vec!["status:done"],
680 "exactly one status label: {labels:?}"
681 );
682 }
683
684 #[tokio::test]
685 async fn update_status_is_idempotent() {
686 let backend = MockBackend::with_labels(vec!["status:done"]);
687 let id = ExternalId("1".into());
688 backend.update_status(&id, Status::Done).await.unwrap();
689 let labels = backend.current_labels();
690 let status_count = labels.iter().filter(|l| l.starts_with("status:")).count();
691 assert_eq!(
692 status_count, 1,
693 "idempotent: still exactly one status label: {labels:?}"
694 );
695 }
696
697 #[tokio::test]
698 async fn update_status_non_status_labels_are_preserved() {
699 let backend =
700 MockBackend::with_labels(vec!["status:routed", "agent:claude", "maintenance"]);
701 let id = ExternalId("1".into());
702 backend
703 .update_status(&id, Status::InProgress)
704 .await
705 .unwrap();
706 let labels = backend.current_labels();
707 assert!(
708 labels.contains(&"agent:claude".to_string()),
709 "agent label preserved: {labels:?}"
710 );
711 assert!(
712 labels.contains(&"maintenance".to_string()),
713 "other label preserved: {labels:?}"
714 );
715 }
716
717 #[tokio::test]
718 async fn lifecycle_transition_preserves_board_label_and_no_agent_hold() {
719 let backend = MockBackend::with_labels(vec!["status:new", "board:held", "no-agent"]);
720 backend
721 .update_status(&ExternalId("1".into()), Status::Routed)
722 .await
723 .unwrap();
724 let mut labels = backend.current_labels();
725 labels.sort();
726 assert_eq!(labels, vec!["board:held", "no-agent", "status:routed"]);
727 }
728
729 #[tokio::test]
730 async fn update_status_retries_on_transient_set_labels_failure() {
731 let backend = MockBackend::with_labels(vec!["status:new"]);
732 *backend.set_labels_fail_count.lock().unwrap() = 1;
734 let id = ExternalId("1".into());
735 backend
736 .update_status(&id, Status::InProgress)
737 .await
738 .unwrap();
739 let labels = backend.current_labels();
740 assert!(
741 labels.contains(&"status:in_progress".to_string()),
742 "label added after retry: {labels:?}"
743 );
744 assert!(
745 !labels.contains(&"status:new".to_string()),
746 "old label removed: {labels:?}"
747 );
748 }
749
750 #[tokio::test]
751 async fn update_status_fails_after_max_retries() {
752 let backend = MockBackend::with_labels(vec!["status:new"]);
753 *backend.set_labels_fail_count.lock().unwrap() = 10;
755 let id = ExternalId("1".into());
756 let result = backend.update_status(&id, Status::Done).await;
757 assert!(result.is_err(), "should fail after max retries");
758 assert!(
759 result
760 .unwrap_err()
761 .to_string()
762 .contains("failed after 3 attempts"),
763 "error message mentions retry exhaustion"
764 );
765 }
766
767 #[test]
768 fn status_as_label() {
769 assert_eq!(Status::New.as_label(), "status:new");
770 assert_eq!(Status::Routed.as_label(), "status:routed");
771 assert_eq!(Status::InProgress.as_label(), "status:in_progress");
772 assert_eq!(Status::Done.as_label(), "status:done");
773 assert_eq!(Status::Blocked.as_label(), "status:blocked");
774 assert_eq!(Status::InReview.as_label(), "status:in_review");
775 assert_eq!(Status::NeedsReview.as_label(), "status:needs_review");
776 }
777
778 #[test]
779 fn status_serializes_snake_case() {
780 let json = serde_json::to_string(&Status::InProgress).unwrap();
781 assert_eq!(json, "\"in_progress\"");
782 }
783
784 #[test]
785 fn status_deserializes_snake_case() {
786 let status: Status = serde_json::from_str("\"needs_review\"").unwrap();
787 assert_eq!(status, Status::NeedsReview);
788 }
789
790 #[test]
791 fn external_id_clone() {
792 let id = ExternalId("42".to_string());
793 let cloned = id.clone();
794 assert_eq!(id.0, cloned.0);
795 }
796
797 #[test]
798 fn external_task_serializes() {
799 let task = ExternalTask {
800 id: ExternalId("1".to_string()),
801 title: "Test".to_string(),
802 body: "Body".to_string(),
803 state: "open".to_string(),
804 labels: vec!["status:new".to_string()],
805 author: "user".to_string(),
806 created_at: "2026-01-01T00:00:00Z".to_string(),
807 updated_at: "2026-01-01T00:00:00Z".to_string(),
808 url: "https://github.com/test/test/issues/1".to_string(),
809 };
810 let json = serde_json::to_string(&task).unwrap();
811 assert!(json.contains("\"title\":\"Test\""));
812 assert!(json.contains("status:new"));
813 }
814}
815
816#[cfg(test)]
817pub(crate) mod test_helpers;