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 Approved,
57 NeedsReview,
58 Waiting,
59}
60
61impl Status {
62 pub fn as_label(&self) -> &'static str {
63 match self {
64 Self::New => "status:new",
65 Self::Routed => "status:routed",
66 Self::InProgress => "status:in_progress",
67 Self::Done => "status:done",
68 Self::Blocked => "status:blocked",
69 Self::InReview => "status:in_review",
70 Self::Approved => "status:approved",
71 Self::NeedsReview => "status:needs_review",
72 Self::Waiting => "status:waiting",
73 }
74 }
75
76 pub fn as_str(&self) -> &'static str {
77 match self {
78 Self::New => "new",
79 Self::Routed => "routed",
80 Self::InProgress => "in_progress",
81 Self::Done => "done",
82 Self::Blocked => "blocked",
83 Self::InReview => "in_review",
84 Self::Approved => "approved",
85 Self::NeedsReview => "needs_review",
86 Self::Waiting => "waiting",
87 }
88 }
89}
90
91#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct BlockedByIssue {
97 pub github_id: i64,
98 pub repo: String,
99 pub number: i64,
100 pub state: String,
101 pub state_reason: Option<String>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct PullRequestCompletion {
111 pub merged: bool,
112 pub base_ref: String,
113 pub default_branch: String,
114 pub approved: bool,
115 pub closed: bool,
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum BlockedByFetch {
125 Native(Vec<BlockedByIssue>),
126 Unavailable,
127 Failed(String),
128}
129
130#[async_trait]
135pub trait ExternalBackend: Send + Sync {
136 fn name(&self) -> &str;
138
139 async fn create_task(
141 &self,
142 title: &str,
143 body: &str,
144 labels: &[String],
145 ) -> anyhow::Result<ExternalId>;
146
147 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask>;
149
150 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
164 const MAX_RETRIES: u32 = 3;
165 let label = status.as_label();
166
167 if let Err(e) = self.ensure_status_label(label).await {
169 tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
170 }
171
172 let mut last_err =
173 anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
174
175 for attempt in 0..MAX_RETRIES {
176 if attempt > 0 {
177 tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
178 .await;
179 tracing::debug!(attempt, label, "retrying update_status");
180 }
181
182 let task = match self.get_task(id).await {
184 Ok(t) => t,
185 Err(e) => {
186 last_err = e;
187 continue;
188 }
189 };
190
191 let mut remove_failed = false;
193 for old in task
194 .labels
195 .iter()
196 .filter(|l| l.starts_with("status:") && l.as_str() != label)
197 {
198 if let Err(e) = self.remove_label(id, old).await {
199 tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
200 last_err = e;
201 remove_failed = true;
202 break;
203 }
204 }
205 if remove_failed {
206 continue;
207 }
208
209 match self.set_labels(id, &[label.to_string()]).await {
211 Ok(()) => return Ok(()),
212 Err(e) => {
213 tracing::warn!(label, err = %e, attempt, "set_labels failed");
214 last_err = e;
215 }
216 }
217 }
218
219 Err(last_err.context(format!(
220 "update_status({label}) failed after {MAX_RETRIES} attempts"
221 )))
222 }
223
224 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>>;
226
227 async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
232 let statuses = [
235 Status::New,
236 Status::Routed,
237 Status::InProgress,
238 Status::Done,
239 Status::Blocked,
240 Status::InReview,
241 Status::Approved,
242 Status::NeedsReview,
243 Status::Waiting,
244 ];
245 let mut all_tasks = Vec::new();
246 for status in statuses {
247 let tasks = self.list_by_status(status).await?;
248 all_tasks.extend(tasks);
249 }
250 Ok(all_tasks)
251 }
252
253 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
258 Ok(Vec::new())
259 }
260
261 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
265 self.list_by_status(Status::New).await
266 }
267
268 async fn list_active_open_issues(&self) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
276 let mut seen = std::collections::HashSet::new();
277 let mut result = Vec::new();
278
279 if let Ok(routable) = self.list_routable().await {
281 for task in routable {
282 if seen.insert(task.id.0.clone()) {
283 result.push((task, None));
284 }
285 }
286 }
287
288 let active_non_new = [
290 Status::Routed,
291 Status::InProgress,
292 Status::NeedsReview,
293 Status::InReview,
294 Status::Approved,
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 async fn has_open_issue_with_title(&self, _title: &str, _label: &str) -> anyhow::Result<bool> {
409 Ok(false)
410 }
411
412 async fn health_check(&self) -> anyhow::Result<()>;
414
415 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
419 Ok(false)
420 }
421
422 async fn batch_is_pr_merged(
428 &self,
429 branches: &[String],
430 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
431 let mut result = std::collections::HashMap::new();
432 for branch in branches {
433 match self.is_pr_merged(branch).await {
434 Ok(merged) => {
435 result.insert(branch.clone(), merged);
436 }
437 Err(e) => {
438 tracing::warn!(branch, err = %e, "failed to check PR merge status");
439 }
440 }
441 }
442 Ok(result)
443 }
444
445 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
449 Ok(None)
450 }
451
452 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
456 Ok(vec![])
457 }
458
459 async fn list_issue_comments(&self, _id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
463 Ok(vec![])
464 }
465
466 async fn acknowledge_issue(&self, _id: &ExternalId) -> anyhow::Result<()> {
471 Ok(())
472 }
473
474 async fn acknowledge_mention(&self, _comment_id: &str) -> anyhow::Result<()> {
479 Ok(())
480 }
481
482 async fn blocked_by(&self, _repo: &str, _issue_number: i64) -> anyhow::Result<BlockedByFetch> {
487 Ok(BlockedByFetch::Unavailable)
488 }
489
490 async fn issue_close_state(
494 &self,
495 _repo: &str,
496 _issue_number: i64,
497 ) -> anyhow::Result<Option<(String, Option<String>)>> {
498 Ok(None)
499 }
500
501 async fn post_comment_in_repo(
505 &self,
506 _repo: &str,
507 id: &ExternalId,
508 body: &str,
509 ) -> anyhow::Result<()> {
510 self.post_comment(id, body).await
511 }
512
513 async fn pull_request_completion(
518 &self,
519 _repo: &str,
520 _pr_number: i64,
521 ) -> anyhow::Result<Option<PullRequestCompletion>> {
522 Ok(None)
523 }
524
525 async fn add_dependency(
530 &self,
531 _issue_number: i64,
532 _blocking_repo: &str,
533 _blocking_number: i64,
534 ) -> anyhow::Result<()> {
535 anyhow::bail!("this backend cannot write issue dependencies")
536 }
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use async_trait::async_trait;
543 use std::sync::{Arc, Mutex};
544
545 struct MockBackend {
551 labels: Arc<Mutex<Vec<String>>>,
553 remove_err: Option<String>,
555 set_labels_fail_count: Arc<Mutex<u32>>,
557 }
558
559 impl MockBackend {
560 fn with_labels(labels: Vec<&str>) -> Self {
561 Self {
562 labels: Arc::new(Mutex::new(labels.into_iter().map(String::from).collect())),
563 remove_err: None,
564 set_labels_fail_count: Arc::new(Mutex::new(0)),
565 }
566 }
567
568 fn current_labels(&self) -> Vec<String> {
569 self.labels.lock().unwrap().clone()
570 }
571 }
572
573 #[async_trait]
574 impl ExternalBackend for MockBackend {
575 fn name(&self) -> &'static str {
576 "mock"
577 }
578
579 async fn create_task(
580 &self,
581 _title: &str,
582 _body: &str,
583 _labels: &[String],
584 ) -> anyhow::Result<ExternalId> {
585 Ok(ExternalId("1".into()))
586 }
587
588 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
589 Ok(ExternalTask {
590 id: ExternalId("1".into()),
591 title: "mock".into(),
592 body: String::new(),
593 state: "open".into(),
594 labels: self.current_labels(),
595 author: "user".into(),
596 created_at: "2026-01-01T00:00:00Z".into(),
597 updated_at: "2026-01-01T00:00:00Z".into(),
598 url: "https://example.com".into(),
599 })
600 }
601
602 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
606 Ok(vec![])
607 }
608
609 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
610 Ok(())
611 }
612
613 async fn set_labels(&self, _id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
614 let mut fail_count = self.set_labels_fail_count.lock().unwrap();
615 if *fail_count > 0 {
616 *fail_count -= 1;
617 return Err(anyhow::anyhow!("transient network error"));
618 }
619 drop(fail_count);
620 let mut current = self.labels.lock().unwrap();
621 for l in labels {
622 if !current.contains(l) {
623 current.push(l.clone());
624 }
625 }
626 Ok(())
627 }
628
629 async fn remove_label(&self, _id: &ExternalId, label: &str) -> anyhow::Result<()> {
630 if let Some(ref err_label) = self.remove_err
631 && label == err_label
632 {
633 return Err(anyhow::anyhow!("rate limit (429)"));
634 }
635 self.labels.lock().unwrap().retain(|l| l != label);
636 Ok(())
637 }
638
639 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
640 Ok(vec![])
641 }
642
643 async fn health_check(&self) -> anyhow::Result<()> {
644 Ok(())
645 }
646 }
647
648 #[tokio::test]
651 async fn update_status_replaces_single_old_label() {
652 let backend = MockBackend::with_labels(vec!["status:new", "priority:high"]);
653 let id = ExternalId("1".into());
654 backend
655 .update_status(&id, Status::InProgress)
656 .await
657 .unwrap();
658 let labels = backend.current_labels();
659 assert!(
660 labels.contains(&"status:in_progress".to_string()),
661 "new label added: {labels:?}"
662 );
663 assert!(
664 !labels.contains(&"status:new".to_string()),
665 "old label removed: {labels:?}"
666 );
667 assert!(
668 labels.contains(&"priority:high".to_string()),
669 "non-status label preserved: {labels:?}"
670 );
671 }
672
673 #[tokio::test]
674 async fn update_status_removes_multiple_old_status_labels() {
675 let backend = MockBackend::with_labels(vec!["status:in_progress", "status:blocked", "bug"]);
677 let id = ExternalId("1".into());
678 backend.update_status(&id, Status::Done).await.unwrap();
679 let labels = backend.current_labels();
680 let status_labels: Vec<_> = labels.iter().filter(|l| l.starts_with("status:")).collect();
681 assert_eq!(
682 status_labels,
683 vec!["status:done"],
684 "exactly one status label: {labels:?}"
685 );
686 }
687
688 #[tokio::test]
689 async fn update_status_is_idempotent() {
690 let backend = MockBackend::with_labels(vec!["status:done"]);
691 let id = ExternalId("1".into());
692 backend.update_status(&id, Status::Done).await.unwrap();
693 let labels = backend.current_labels();
694 let status_count = labels.iter().filter(|l| l.starts_with("status:")).count();
695 assert_eq!(
696 status_count, 1,
697 "idempotent: still exactly one status label: {labels:?}"
698 );
699 }
700
701 #[tokio::test]
702 async fn update_status_non_status_labels_are_preserved() {
703 let backend =
704 MockBackend::with_labels(vec!["status:routed", "agent:claude", "maintenance"]);
705 let id = ExternalId("1".into());
706 backend
707 .update_status(&id, Status::InProgress)
708 .await
709 .unwrap();
710 let labels = backend.current_labels();
711 assert!(
712 labels.contains(&"agent:claude".to_string()),
713 "agent label preserved: {labels:?}"
714 );
715 assert!(
716 labels.contains(&"maintenance".to_string()),
717 "other label preserved: {labels:?}"
718 );
719 }
720
721 #[tokio::test]
722 async fn lifecycle_transition_preserves_board_label_and_no_agent_hold() {
723 let backend = MockBackend::with_labels(vec!["status:new", "board:held", "no-agent"]);
724 backend
725 .update_status(&ExternalId("1".into()), Status::Routed)
726 .await
727 .unwrap();
728 let mut labels = backend.current_labels();
729 labels.sort();
730 assert_eq!(labels, vec!["board:held", "no-agent", "status:routed"]);
731 }
732
733 #[tokio::test]
734 async fn update_status_retries_on_transient_set_labels_failure() {
735 let backend = MockBackend::with_labels(vec!["status:new"]);
736 *backend.set_labels_fail_count.lock().unwrap() = 1;
738 let id = ExternalId("1".into());
739 backend
740 .update_status(&id, Status::InProgress)
741 .await
742 .unwrap();
743 let labels = backend.current_labels();
744 assert!(
745 labels.contains(&"status:in_progress".to_string()),
746 "label added after retry: {labels:?}"
747 );
748 assert!(
749 !labels.contains(&"status:new".to_string()),
750 "old label removed: {labels:?}"
751 );
752 }
753
754 #[tokio::test]
755 async fn update_status_fails_after_max_retries() {
756 let backend = MockBackend::with_labels(vec!["status:new"]);
757 *backend.set_labels_fail_count.lock().unwrap() = 10;
759 let id = ExternalId("1".into());
760 let result = backend.update_status(&id, Status::Done).await;
761 assert!(result.is_err(), "should fail after max retries");
762 assert!(
763 result
764 .unwrap_err()
765 .to_string()
766 .contains("failed after 3 attempts"),
767 "error message mentions retry exhaustion"
768 );
769 }
770
771 #[test]
772 fn status_as_label() {
773 assert_eq!(Status::New.as_label(), "status:new");
774 assert_eq!(Status::Routed.as_label(), "status:routed");
775 assert_eq!(Status::InProgress.as_label(), "status:in_progress");
776 assert_eq!(Status::Done.as_label(), "status:done");
777 assert_eq!(Status::Blocked.as_label(), "status:blocked");
778 assert_eq!(Status::InReview.as_label(), "status:in_review");
779 assert_eq!(Status::Approved.as_label(), "status:approved");
780 assert_eq!(Status::Approved.as_str(), "approved");
781 assert_eq!(
782 crate::store::TaskStatus::from_str("approved"),
783 Some(crate::store::TaskStatus::Approved)
784 );
785 assert_eq!(crate::store::TaskStatus::Approved.as_str(), "approved");
786 assert_eq!(Status::NeedsReview.as_label(), "status:needs_review");
787 }
788
789 #[test]
790 fn status_serializes_snake_case() {
791 let json = serde_json::to_string(&Status::InProgress).unwrap();
792 assert_eq!(json, "\"in_progress\"");
793 }
794
795 #[test]
796 fn status_deserializes_snake_case() {
797 let status: Status = serde_json::from_str("\"needs_review\"").unwrap();
798 assert_eq!(status, Status::NeedsReview);
799 }
800
801 #[test]
802 fn external_id_clone() {
803 let id = ExternalId("42".to_string());
804 let cloned = id.clone();
805 assert_eq!(id.0, cloned.0);
806 }
807
808 #[test]
809 fn external_task_serializes() {
810 let task = ExternalTask {
811 id: ExternalId("1".to_string()),
812 title: "Test".to_string(),
813 body: "Body".to_string(),
814 state: "open".to_string(),
815 labels: vec!["status:new".to_string()],
816 author: "user".to_string(),
817 created_at: "2026-01-01T00:00:00Z".to_string(),
818 updated_at: "2026-01-01T00:00:00Z".to_string(),
819 url: "https://github.com/test/test/issues/1".to_string(),
820 };
821 let json = serde_json::to_string(&task).unwrap();
822 assert!(json.contains("\"title\":\"Test\""));
823 assert!(json.contains("status:new"));
824 }
825}
826
827#[cfg(test)]
828pub(crate) mod test_helpers;