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)]
105pub struct PullRequestCompletion {
106 pub merged: bool,
107 pub base_ref: String,
108 pub default_branch: String,
109 pub approved: bool,
110 pub closed: bool,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum BlockedByFetch {
120 Native(Vec<BlockedByIssue>),
121 Unavailable,
122 Failed(String),
123}
124
125#[allow(clippy::double_must_use)] #[async_trait]
131pub trait ExternalBackend: Send + Sync {
132 fn name(&self) -> &str;
134
135 async fn create_task(
137 &self,
138 title: &str,
139 body: &str,
140 labels: &[String],
141 ) -> anyhow::Result<ExternalId>;
142
143 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask>;
145
146 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
160 let label = status.as_label();
161
162 if let Err(e) = self.ensure_status_label(label).await {
164 tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
165 }
166
167 const MAX_RETRIES: u32 = 3;
168 let mut last_err =
169 anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
170
171 for attempt in 0..MAX_RETRIES {
172 if attempt > 0 {
173 tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
174 .await;
175 tracing::debug!(attempt, label, "retrying update_status");
176 }
177
178 let task = match self.get_task(id).await {
180 Ok(t) => t,
181 Err(e) => {
182 last_err = e;
183 continue;
184 }
185 };
186
187 let mut remove_failed = false;
189 for old in task
190 .labels
191 .iter()
192 .filter(|l| l.starts_with("status:") && l.as_str() != label)
193 {
194 if let Err(e) = self.remove_label(id, old).await {
195 tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
196 last_err = e;
197 remove_failed = true;
198 break;
199 }
200 }
201 if remove_failed {
202 continue;
203 }
204
205 match self.set_labels(id, &[label.to_string()]).await {
207 Ok(()) => return Ok(()),
208 Err(e) => {
209 tracing::warn!(label, err = %e, attempt, "set_labels failed");
210 last_err = e;
211 }
212 }
213 }
214
215 Err(last_err.context(format!(
216 "update_status({label}) failed after {MAX_RETRIES} attempts"
217 )))
218 }
219
220 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>>;
222
223 async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
228 let statuses = [
231 Status::New,
232 Status::Routed,
233 Status::InProgress,
234 Status::Done,
235 Status::Blocked,
236 Status::InReview,
237 Status::NeedsReview,
238 Status::Waiting,
239 ];
240 let mut all_tasks = Vec::new();
241 for status in statuses {
242 let tasks = self.list_by_status(status).await?;
243 all_tasks.extend(tasks);
244 }
245 Ok(all_tasks)
246 }
247
248 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
253 Ok(Vec::new())
254 }
255
256 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
260 self.list_by_status(Status::New).await
261 }
262
263 async fn list_active_open_issues(
274 &self,
275 _since: Option<&str>,
276 ) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
277 let mut seen = std::collections::HashSet::new();
278 let mut result = Vec::new();
279
280 if let Ok(routable) = self.list_routable().await {
282 for task in routable {
283 if seen.insert(task.id.0.clone()) {
284 result.push((task, None));
285 }
286 }
287 }
288
289 let active_non_new = [
291 Status::Routed,
292 Status::InProgress,
293 Status::NeedsReview,
294 Status::InReview,
295 Status::Blocked,
296 Status::Waiting,
297 ];
298 for status in active_non_new {
299 if let Ok(tasks) = self.list_by_status(status).await {
300 for task in tasks {
301 if seen.insert(task.id.0.clone()) {
302 result.push((task, Some(status)));
303 }
304 }
305 }
306 }
307
308 Ok(result)
309 }
310
311 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()>;
313
314 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()>;
318
319 async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()>;
321
322 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>>;
327
328 async fn create_sub_task(
334 &self,
335 parent_id: &ExternalId,
336 title: &str,
337 body: &str,
338 labels: &[String],
339 ) -> anyhow::Result<ExternalId> {
340 let mut all_labels = labels.to_vec();
342 all_labels.push(format!("parent:{}", parent_id.0));
343 self.create_task(title, body, &all_labels).await
344 }
345
346 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
352 Ok(())
353 }
354
355 async fn sync_to_project(
363 &self,
364 _id: &ExternalId,
365 _status: Status,
366 ) -> anyhow::Result<Option<String>> {
367 Ok(None)
368 }
369
370 async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
374 Ok(())
375 }
376
377 async fn sync_estimate_to_project(
383 &self,
384 _id: &ExternalId,
385 _estimate: u8,
386 ) -> anyhow::Result<()> {
387 Ok(())
388 }
389
390 async fn get_project_item_estimates(
397 &self,
398 _issue_node_ids: &[String],
399 ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
400 Ok(std::collections::HashMap::new())
401 }
402
403 #[allow(dead_code)]
409 async fn has_open_issue_with_title(&self, _title: &str, _label: &str) -> anyhow::Result<bool> {
410 Ok(false)
411 }
412
413 async fn health_check(&self) -> anyhow::Result<()>;
415
416 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
420 Ok(false)
421 }
422
423 async fn batch_is_pr_merged(
429 &self,
430 branches: &[String],
431 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
432 let mut result = std::collections::HashMap::new();
433 for branch in branches {
434 match self.is_pr_merged(branch).await {
435 Ok(merged) => {
436 result.insert(branch.clone(), merged);
437 }
438 Err(e) => {
439 tracing::warn!(branch, err = %e, "failed to check PR merge status");
440 }
441 }
442 }
443 Ok(result)
444 }
445
446 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
450 Ok(None)
451 }
452
453 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
457 Ok(vec![])
458 }
459
460 async fn list_issue_comments(&self, _id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
464 Ok(vec![])
465 }
466
467 async fn acknowledge_issue(&self, _id: &ExternalId) -> anyhow::Result<()> {
472 Ok(())
473 }
474
475 async fn acknowledge_mention(&self, _comment_id: &str) -> anyhow::Result<()> {
480 Ok(())
481 }
482
483 async fn blocked_by(&self, _repo: &str, _issue_number: i64) -> anyhow::Result<BlockedByFetch> {
488 Ok(BlockedByFetch::Unavailable)
489 }
490
491 async fn issue_close_state(
495 &self,
496 _repo: &str,
497 _issue_number: i64,
498 ) -> anyhow::Result<Option<(String, Option<String>)>> {
499 Ok(None)
500 }
501
502 async fn post_comment_in_repo(
506 &self,
507 _repo: &str,
508 id: &ExternalId,
509 body: &str,
510 ) -> anyhow::Result<()> {
511 self.post_comment(id, body).await
512 }
513
514 async fn pull_request_completion(
519 &self,
520 _repo: &str,
521 _pr_number: i64,
522 ) -> anyhow::Result<Option<PullRequestCompletion>> {
523 Ok(None)
524 }
525
526 async fn add_dependency(
531 &self,
532 _issue_number: i64,
533 _blocking_repo: &str,
534 _blocking_number: i64,
535 ) -> anyhow::Result<()> {
536 anyhow::bail!("this backend cannot write issue dependencies")
537 }
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543 use async_trait::async_trait;
544 use std::sync::{Arc, Mutex};
545
546 struct MockBackend {
552 labels: Arc<Mutex<Vec<String>>>,
554 remove_err: Option<String>,
556 set_labels_fail_count: Arc<Mutex<u32>>,
558 }
559
560 impl MockBackend {
561 fn with_labels(labels: Vec<&str>) -> Self {
562 Self {
563 labels: Arc::new(Mutex::new(labels.into_iter().map(String::from).collect())),
564 remove_err: None,
565 set_labels_fail_count: Arc::new(Mutex::new(0)),
566 }
567 }
568
569 fn current_labels(&self) -> Vec<String> {
570 self.labels.lock().unwrap().clone()
571 }
572 }
573
574 #[async_trait]
575 impl ExternalBackend for MockBackend {
576 fn name(&self) -> &str {
577 "mock"
578 }
579
580 async fn create_task(
581 &self,
582 _title: &str,
583 _body: &str,
584 _labels: &[String],
585 ) -> anyhow::Result<ExternalId> {
586 Ok(ExternalId("1".into()))
587 }
588
589 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
590 Ok(ExternalTask {
591 id: ExternalId("1".into()),
592 title: "mock".into(),
593 body: "".into(),
594 state: "open".into(),
595 labels: self.current_labels(),
596 author: "user".into(),
597 created_at: "2026-01-01T00:00:00Z".into(),
598 updated_at: "2026-01-01T00:00:00Z".into(),
599 url: "https://example.com".into(),
600 })
601 }
602
603 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
607 Ok(vec![])
608 }
609
610 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
611 Ok(())
612 }
613
614 async fn set_labels(&self, _id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
615 let mut fail_count = self.set_labels_fail_count.lock().unwrap();
616 if *fail_count > 0 {
617 *fail_count -= 1;
618 return Err(anyhow::anyhow!("transient network error"));
619 }
620 drop(fail_count);
621 let mut current = self.labels.lock().unwrap();
622 for l in labels {
623 if !current.contains(l) {
624 current.push(l.clone());
625 }
626 }
627 Ok(())
628 }
629
630 async fn remove_label(&self, _id: &ExternalId, label: &str) -> anyhow::Result<()> {
631 if let Some(ref err_label) = self.remove_err {
632 if label == err_label {
633 return Err(anyhow::anyhow!("rate limit (429)"));
634 }
635 }
636 self.labels.lock().unwrap().retain(|l| l != label);
637 Ok(())
638 }
639
640 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
641 Ok(vec![])
642 }
643
644 async fn health_check(&self) -> anyhow::Result<()> {
645 Ok(())
646 }
647 }
648
649 #[tokio::test]
652 async fn update_status_replaces_single_old_label() {
653 let backend = MockBackend::with_labels(vec!["status:new", "priority:high"]);
654 let id = ExternalId("1".into());
655 backend
656 .update_status(&id, Status::InProgress)
657 .await
658 .unwrap();
659 let labels = backend.current_labels();
660 assert!(
661 labels.contains(&"status:in_progress".to_string()),
662 "new label added: {labels:?}"
663 );
664 assert!(
665 !labels.contains(&"status:new".to_string()),
666 "old label removed: {labels:?}"
667 );
668 assert!(
669 labels.contains(&"priority:high".to_string()),
670 "non-status label preserved: {labels:?}"
671 );
672 }
673
674 #[tokio::test]
675 async fn update_status_removes_multiple_old_status_labels() {
676 let backend = MockBackend::with_labels(vec!["status:in_progress", "status:blocked", "bug"]);
678 let id = ExternalId("1".into());
679 backend.update_status(&id, Status::Done).await.unwrap();
680 let labels = backend.current_labels();
681 let status_labels: Vec<_> = labels.iter().filter(|l| l.starts_with("status:")).collect();
682 assert_eq!(
683 status_labels,
684 vec!["status:done"],
685 "exactly one status label: {labels:?}"
686 );
687 }
688
689 #[tokio::test]
690 async fn update_status_is_idempotent() {
691 let backend = MockBackend::with_labels(vec!["status:done"]);
692 let id = ExternalId("1".into());
693 backend.update_status(&id, Status::Done).await.unwrap();
694 let labels = backend.current_labels();
695 let status_count = labels.iter().filter(|l| l.starts_with("status:")).count();
696 assert_eq!(
697 status_count, 1,
698 "idempotent: still exactly one status label: {labels:?}"
699 );
700 }
701
702 #[tokio::test]
703 async fn update_status_non_status_labels_are_preserved() {
704 let backend =
705 MockBackend::with_labels(vec!["status:routed", "agent:claude", "maintenance"]);
706 let id = ExternalId("1".into());
707 backend
708 .update_status(&id, Status::InProgress)
709 .await
710 .unwrap();
711 let labels = backend.current_labels();
712 assert!(
713 labels.contains(&"agent:claude".to_string()),
714 "agent label preserved: {labels:?}"
715 );
716 assert!(
717 labels.contains(&"maintenance".to_string()),
718 "other label preserved: {labels:?}"
719 );
720 }
721
722 #[tokio::test]
723 async fn lifecycle_transition_preserves_board_label_and_no_agent_hold() {
724 let backend = MockBackend::with_labels(vec!["status:new", "board:held", "no-agent"]);
725 backend
726 .update_status(&ExternalId("1".into()), Status::Routed)
727 .await
728 .unwrap();
729 let mut labels = backend.current_labels();
730 labels.sort();
731 assert_eq!(labels, vec!["board:held", "no-agent", "status:routed"]);
732 }
733
734 #[tokio::test]
735 async fn update_status_retries_on_transient_set_labels_failure() {
736 let backend = MockBackend::with_labels(vec!["status:new"]);
737 *backend.set_labels_fail_count.lock().unwrap() = 1;
739 let id = ExternalId("1".into());
740 backend
741 .update_status(&id, Status::InProgress)
742 .await
743 .unwrap();
744 let labels = backend.current_labels();
745 assert!(
746 labels.contains(&"status:in_progress".to_string()),
747 "label added after retry: {labels:?}"
748 );
749 assert!(
750 !labels.contains(&"status:new".to_string()),
751 "old label removed: {labels:?}"
752 );
753 }
754
755 #[tokio::test]
756 async fn update_status_fails_after_max_retries() {
757 let backend = MockBackend::with_labels(vec!["status:new"]);
758 *backend.set_labels_fail_count.lock().unwrap() = 10;
760 let id = ExternalId("1".into());
761 let result = backend.update_status(&id, Status::Done).await;
762 assert!(result.is_err(), "should fail after max retries");
763 assert!(
764 result
765 .unwrap_err()
766 .to_string()
767 .contains("failed after 3 attempts"),
768 "error message mentions retry exhaustion"
769 );
770 }
771
772 #[test]
773 fn status_as_label() {
774 assert_eq!(Status::New.as_label(), "status:new");
775 assert_eq!(Status::Routed.as_label(), "status:routed");
776 assert_eq!(Status::InProgress.as_label(), "status:in_progress");
777 assert_eq!(Status::Done.as_label(), "status:done");
778 assert_eq!(Status::Blocked.as_label(), "status:blocked");
779 assert_eq!(Status::InReview.as_label(), "status:in_review");
780 assert_eq!(Status::NeedsReview.as_label(), "status:needs_review");
781 }
782
783 #[test]
784 fn status_serializes_snake_case() {
785 let json = serde_json::to_string(&Status::InProgress).unwrap();
786 assert_eq!(json, "\"in_progress\"");
787 }
788
789 #[test]
790 fn status_deserializes_snake_case() {
791 let status: Status = serde_json::from_str("\"needs_review\"").unwrap();
792 assert_eq!(status, Status::NeedsReview);
793 }
794
795 #[test]
796 fn external_id_clone() {
797 let id = ExternalId("42".to_string());
798 let cloned = id.clone();
799 assert_eq!(id.0, cloned.0);
800 }
801
802 #[test]
803 fn external_task_serializes() {
804 let task = ExternalTask {
805 id: ExternalId("1".to_string()),
806 title: "Test".to_string(),
807 body: "Body".to_string(),
808 state: "open".to_string(),
809 labels: vec!["status:new".to_string()],
810 author: "user".to_string(),
811 created_at: "2026-01-01T00:00:00Z".to_string(),
812 updated_at: "2026-01-01T00:00:00Z".to_string(),
813 url: "https://github.com/test/test/issues/1".to_string(),
814 };
815 let json = serde_json::to_string(&task).unwrap();
816 assert!(json.contains("\"title\":\"Test\""));
817 assert!(json.contains("status:new"));
818 }
819}
820
821#[cfg(test)]
822pub(crate) mod test_helpers;