1pub mod github;
7
8use std::collections::HashMap;
9use std::collections::HashSet;
10use std::time::Duration;
11
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14use tokio::time;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ExternalId(pub String);
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct Mention {
23 pub id: String,
25 pub body: String,
27 pub author: String,
29 pub created_at: String,
31 pub issue_url: Option<String>,
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct ExternalTask {
39 pub id: ExternalId,
40 pub title: String,
41 pub body: String,
42 pub state: String,
43 pub labels: Vec<String>,
44 pub author: String,
45 pub created_at: String,
46 pub updated_at: String,
47 pub url: String,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(rename_all = "snake_case")]
53pub enum Status {
54 New,
55 Routed,
56 InProgress,
57 Done,
58 Blocked,
59 InReview,
60 Approved,
62 NeedsReview,
63 Waiting,
64}
65
66impl Status {
67 pub fn as_label(&self) -> &'static str {
68 match self {
69 Self::New => "status:new",
70 Self::Routed => "status:routed",
71 Self::InProgress => "status:in_progress",
72 Self::Done => "status:done",
73 Self::Blocked => "status:blocked",
74 Self::InReview => "status:in_review",
75 Self::Approved => "status:approved",
76 Self::NeedsReview => "status:needs_review",
77 Self::Waiting => "status:waiting",
78 }
79 }
80
81 pub fn as_str(&self) -> &'static str {
82 match self {
83 Self::New => "new",
84 Self::Routed => "routed",
85 Self::InProgress => "in_progress",
86 Self::Done => "done",
87 Self::Blocked => "blocked",
88 Self::InReview => "in_review",
89 Self::Approved => "approved",
90 Self::NeedsReview => "needs_review",
91 Self::Waiting => "waiting",
92 }
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct BlockedByIssue {
102 pub github_id: i64,
103 pub repo: String,
104 pub number: i64,
105 pub state: String,
106 pub state_reason: Option<String>,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct PullRequestCompletion {
116 pub merged: bool,
117 pub base_ref: String,
118 pub default_branch: String,
119 pub approved: bool,
120 pub closed: bool,
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
129pub enum BlockedByFetch {
130 Native(Vec<BlockedByIssue>),
131 Unavailable,
132 Failed(String),
133}
134
135#[async_trait]
140pub trait ExternalBackend: Send + Sync {
141 fn name(&self) -> &str;
143
144 async fn create_task(
146 &self,
147 title: &str,
148 body: &str,
149 labels: &[String],
150 ) -> anyhow::Result<ExternalId>;
151
152 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask>;
154
155 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
169 const MAX_RETRIES: u32 = 3;
170 let label = status.as_label();
171
172 if let Err(e) = self.ensure_status_label(label).await {
174 tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
175 }
176
177 let mut last_err =
178 anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
179
180 for attempt in 0..MAX_RETRIES {
181 if attempt > 0 {
182 time::sleep(Duration::from_millis(500 * u64::from(attempt))).await;
183 tracing::debug!(attempt, label, "retrying update_status");
184 }
185
186 let task = match self.get_task(id).await {
188 Ok(t) => t,
189 Err(e) => {
190 last_err = e;
191 continue;
192 }
193 };
194
195 let mut remove_failed = false;
197 for old in task
198 .labels
199 .iter()
200 .filter(|l| l.starts_with("status:") && l.as_str() != label)
201 {
202 if let Err(e) = self.remove_label(id, old).await {
203 tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
204 last_err = e;
205 remove_failed = true;
206 break;
207 }
208 }
209 if remove_failed {
210 continue;
211 }
212
213 match self.set_labels(id, &[label.to_string()]).await {
215 Ok(()) => return Ok(()),
216 Err(e) => {
217 tracing::warn!(label, err = %e, attempt, "set_labels failed");
218 last_err = e;
219 }
220 }
221 }
222
223 Err(last_err.context(format!(
224 "update_status({label}) failed after {MAX_RETRIES} attempts"
225 )))
226 }
227
228 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>>;
230
231 async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
236 let statuses = [
239 Status::New,
240 Status::Routed,
241 Status::InProgress,
242 Status::Done,
243 Status::Blocked,
244 Status::InReview,
245 Status::Approved,
246 Status::NeedsReview,
247 Status::Waiting,
248 ];
249 let mut all_tasks = Vec::new();
250 for status in statuses {
251 let tasks = self.list_by_status(status).await?;
252 all_tasks.extend(tasks);
253 }
254 Ok(all_tasks)
255 }
256
257 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
262 Ok(Vec::new())
263 }
264
265 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
269 self.list_by_status(Status::New).await
270 }
271
272 async fn list_active_open_issues(&self) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
280 let mut seen = HashSet::new();
281 let mut result = Vec::new();
282
283 if let Ok(routable) = self.list_routable().await {
285 for task in routable {
286 if seen.insert(task.id.0.clone()) {
287 result.push((task, None));
288 }
289 }
290 }
291
292 let active_non_new = [
294 Status::Routed,
295 Status::InProgress,
296 Status::NeedsReview,
297 Status::InReview,
298 Status::Approved,
299 Status::Blocked,
300 Status::Waiting,
301 ];
302 for status in active_non_new {
303 if let Ok(tasks) = self.list_by_status(status).await {
304 for task in tasks {
305 if seen.insert(task.id.0.clone()) {
306 result.push((task, Some(status)));
307 }
308 }
309 }
310 }
311
312 Ok(result)
313 }
314
315 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()>;
317
318 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()>;
322
323 async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()>;
325
326 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>>;
331
332 async fn create_sub_task(
338 &self,
339 parent_id: &ExternalId,
340 title: &str,
341 body: &str,
342 labels: &[String],
343 ) -> anyhow::Result<ExternalId> {
344 let mut all_labels = labels.to_vec();
346 all_labels.push(format!("parent:{}", parent_id.0));
347 self.create_task(title, body, &all_labels).await
348 }
349
350 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
356 Ok(())
357 }
358
359 async fn sync_to_project(
367 &self,
368 _id: &ExternalId,
369 _status: Status,
370 ) -> anyhow::Result<Option<String>> {
371 Ok(None)
372 }
373
374 async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
378 Ok(())
379 }
380
381 async fn sync_estimate_to_project(
387 &self,
388 _id: &ExternalId,
389 _estimate: u8,
390 ) -> anyhow::Result<()> {
391 Ok(())
392 }
393
394 async fn get_project_item_estimates(
401 &self,
402 _issue_node_ids: &[String],
403 ) -> anyhow::Result<HashMap<String, u8>> {
404 Ok(HashMap::new())
405 }
406
407 async fn has_open_issue_with_title(&self, _title: &str, _label: &str) -> anyhow::Result<bool> {
413 Ok(false)
414 }
415
416 async fn health_check(&self) -> anyhow::Result<()>;
418
419 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
423 Ok(false)
424 }
425
426 async fn batch_is_pr_merged(
432 &self,
433 branches: &[String],
434 ) -> anyhow::Result<HashMap<String, bool>> {
435 let mut result = HashMap::new();
436 for branch in branches {
437 match self.is_pr_merged(branch).await {
438 Ok(merged) => {
439 result.insert(branch.clone(), merged);
440 }
441 Err(e) => {
442 tracing::warn!(branch, err = %e, "failed to check PR merge status");
443 }
444 }
445 }
446 Ok(result)
447 }
448
449 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
453 Ok(None)
454 }
455
456 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
460 Ok(vec![])
461 }
462
463 async fn list_issue_comments(&self, _id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
467 Ok(vec![])
468 }
469
470 async fn acknowledge_issue(&self, _id: &ExternalId) -> anyhow::Result<()> {
475 Ok(())
476 }
477
478 async fn acknowledge_mention(&self, _comment_id: &str) -> anyhow::Result<()> {
483 Ok(())
484 }
485
486 async fn blocked_by(&self, _repo: &str, _issue_number: i64) -> anyhow::Result<BlockedByFetch> {
491 Ok(BlockedByFetch::Unavailable)
492 }
493
494 async fn issue_close_state(
498 &self,
499 _repo: &str,
500 _issue_number: i64,
501 ) -> anyhow::Result<Option<(String, Option<String>)>> {
502 Ok(None)
503 }
504
505 async fn post_comment_in_repo(
509 &self,
510 _repo: &str,
511 id: &ExternalId,
512 body: &str,
513 ) -> anyhow::Result<()> {
514 self.post_comment(id, body).await
515 }
516
517 async fn pull_request_completion(
522 &self,
523 _repo: &str,
524 _pr_number: i64,
525 ) -> anyhow::Result<Option<PullRequestCompletion>> {
526 Ok(None)
527 }
528
529 async fn add_dependency(
534 &self,
535 _issue_number: i64,
536 _blocking_repo: &str,
537 _blocking_number: i64,
538 ) -> anyhow::Result<()> {
539 anyhow::bail!("this backend cannot write issue dependencies")
540 }
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use crate::store::TaskStatus;
547 use async_trait::async_trait;
548 use std::sync::{Arc, Mutex};
549
550 struct MockBackend {
556 labels: Arc<Mutex<Vec<String>>>,
558 remove_err: Option<String>,
560 set_labels_fail_count: Arc<Mutex<u32>>,
562 }
563
564 impl MockBackend {
565 fn with_labels(labels: Vec<&str>) -> Self {
566 Self {
567 labels: Arc::new(Mutex::new(labels.into_iter().map(String::from).collect())),
568 remove_err: None,
569 set_labels_fail_count: Arc::new(Mutex::new(0)),
570 }
571 }
572
573 fn current_labels(&self) -> Vec<String> {
574 self.labels.lock().unwrap().clone()
575 }
576 }
577
578 #[async_trait]
579 impl ExternalBackend for MockBackend {
580 fn name(&self) -> &'static str {
581 "mock"
582 }
583
584 async fn create_task(
585 &self,
586 _title: &str,
587 _body: &str,
588 _labels: &[String],
589 ) -> anyhow::Result<ExternalId> {
590 Ok(ExternalId("1".into()))
591 }
592
593 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
594 Ok(ExternalTask {
595 id: ExternalId("1".into()),
596 title: "mock".into(),
597 body: String::new(),
598 state: "open".into(),
599 labels: self.current_labels(),
600 author: "user".into(),
601 created_at: "2026-01-01T00:00:00Z".into(),
602 updated_at: "2026-01-01T00:00:00Z".into(),
603 url: "https://example.com".into(),
604 })
605 }
606
607 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
611 Ok(vec![])
612 }
613
614 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
615 Ok(())
616 }
617
618 async fn set_labels(&self, _id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
619 let mut fail_count = self.set_labels_fail_count.lock().unwrap();
620 if *fail_count > 0 {
621 *fail_count -= 1;
622 return Err(anyhow::anyhow!("transient network error"));
623 }
624 drop(fail_count);
625 let mut current = self.labels.lock().unwrap();
626 for l in labels {
627 if !current.contains(l) {
628 current.push(l.clone());
629 }
630 }
631 Ok(())
632 }
633
634 async fn remove_label(&self, _id: &ExternalId, label: &str) -> anyhow::Result<()> {
635 if let Some(ref err_label) = self.remove_err
636 && label == err_label
637 {
638 return Err(anyhow::anyhow!("rate limit (429)"));
639 }
640 self.labels.lock().unwrap().retain(|l| l != label);
641 Ok(())
642 }
643
644 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
645 Ok(vec![])
646 }
647
648 async fn health_check(&self) -> anyhow::Result<()> {
649 Ok(())
650 }
651 }
652
653 #[tokio::test]
656 async fn update_status_replaces_single_old_label() {
657 let backend = MockBackend::with_labels(vec!["status:new", "priority:high"]);
658 let id = ExternalId("1".into());
659 backend
660 .update_status(&id, Status::InProgress)
661 .await
662 .unwrap();
663 let labels = backend.current_labels();
664 assert!(
665 labels.contains(&"status:in_progress".to_string()),
666 "new label added: {labels:?}"
667 );
668 assert!(
669 !labels.contains(&"status:new".to_string()),
670 "old label removed: {labels:?}"
671 );
672 assert!(
673 labels.contains(&"priority:high".to_string()),
674 "non-status label preserved: {labels:?}"
675 );
676 }
677
678 #[tokio::test]
679 async fn update_status_removes_multiple_old_status_labels() {
680 let backend = MockBackend::with_labels(vec!["status:in_progress", "status:blocked", "bug"]);
682 let id = ExternalId("1".into());
683 backend.update_status(&id, Status::Done).await.unwrap();
684 let labels = backend.current_labels();
685 let status_labels: Vec<_> = labels.iter().filter(|l| l.starts_with("status:")).collect();
686 assert_eq!(
687 status_labels,
688 vec!["status:done"],
689 "exactly one status label: {labels:?}"
690 );
691 }
692
693 #[tokio::test]
694 async fn update_status_is_idempotent() {
695 let backend = MockBackend::with_labels(vec!["status:done"]);
696 let id = ExternalId("1".into());
697 backend.update_status(&id, Status::Done).await.unwrap();
698 let labels = backend.current_labels();
699 let status_count = labels.iter().filter(|l| l.starts_with("status:")).count();
700 assert_eq!(
701 status_count, 1,
702 "idempotent: still exactly one status label: {labels:?}"
703 );
704 }
705
706 #[tokio::test]
707 async fn update_status_non_status_labels_are_preserved() {
708 let backend =
709 MockBackend::with_labels(vec!["status:routed", "agent:claude", "maintenance"]);
710 let id = ExternalId("1".into());
711 backend
712 .update_status(&id, Status::InProgress)
713 .await
714 .unwrap();
715 let labels = backend.current_labels();
716 assert!(
717 labels.contains(&"agent:claude".to_string()),
718 "agent label preserved: {labels:?}"
719 );
720 assert!(
721 labels.contains(&"maintenance".to_string()),
722 "other label preserved: {labels:?}"
723 );
724 }
725
726 #[tokio::test]
727 async fn lifecycle_transition_preserves_board_label_and_no_agent_hold() {
728 let backend = MockBackend::with_labels(vec!["status:new", "board:held", "no-agent"]);
729 backend
730 .update_status(&ExternalId("1".into()), Status::Routed)
731 .await
732 .unwrap();
733 let mut labels = backend.current_labels();
734 labels.sort();
735 assert_eq!(labels, vec!["board:held", "no-agent", "status:routed"]);
736 }
737
738 #[tokio::test]
739 async fn update_status_retries_on_transient_set_labels_failure() {
740 let backend = MockBackend::with_labels(vec!["status:new"]);
741 *backend.set_labels_fail_count.lock().unwrap() = 1;
743 let id = ExternalId("1".into());
744 backend
745 .update_status(&id, Status::InProgress)
746 .await
747 .unwrap();
748 let labels = backend.current_labels();
749 assert!(
750 labels.contains(&"status:in_progress".to_string()),
751 "label added after retry: {labels:?}"
752 );
753 assert!(
754 !labels.contains(&"status:new".to_string()),
755 "old label removed: {labels:?}"
756 );
757 }
758
759 #[tokio::test]
760 async fn update_status_fails_after_max_retries() {
761 let backend = MockBackend::with_labels(vec!["status:new"]);
762 *backend.set_labels_fail_count.lock().unwrap() = 10;
764 let id = ExternalId("1".into());
765 let result = backend.update_status(&id, Status::Done).await;
766 assert!(result.is_err(), "should fail after max retries");
767 assert!(
768 result
769 .unwrap_err()
770 .to_string()
771 .contains("failed after 3 attempts"),
772 "error message mentions retry exhaustion"
773 );
774 }
775
776 #[test]
777 fn status_as_label() {
778 assert_eq!(Status::New.as_label(), "status:new");
779 assert_eq!(Status::Routed.as_label(), "status:routed");
780 assert_eq!(Status::InProgress.as_label(), "status:in_progress");
781 assert_eq!(Status::Done.as_label(), "status:done");
782 assert_eq!(Status::Blocked.as_label(), "status:blocked");
783 assert_eq!(Status::InReview.as_label(), "status:in_review");
784 assert_eq!(Status::Approved.as_label(), "status:approved");
785 assert_eq!(Status::Approved.as_str(), "approved");
786 assert_eq!(TaskStatus::from_str("approved"), Some(TaskStatus::Approved));
787 assert_eq!(TaskStatus::Approved.as_str(), "approved");
788 assert_eq!(Status::NeedsReview.as_label(), "status:needs_review");
789 }
790
791 #[test]
792 fn status_serializes_snake_case() {
793 let json = serde_json::to_string(&Status::InProgress).unwrap();
794 assert_eq!(json, "\"in_progress\"");
795 }
796
797 #[test]
798 fn status_deserializes_snake_case() {
799 let status: Status = serde_json::from_str("\"needs_review\"").unwrap();
800 assert_eq!(status, Status::NeedsReview);
801 }
802
803 #[test]
804 fn external_id_clone() {
805 let id = ExternalId("42".to_string());
806 let cloned = id.clone();
807 assert_eq!(id.0, cloned.0);
808 }
809
810 #[test]
811 fn external_task_serializes() {
812 let task = ExternalTask {
813 id: ExternalId("1".to_string()),
814 title: "Test".to_string(),
815 body: "Body".to_string(),
816 state: "open".to_string(),
817 labels: vec!["status:new".to_string()],
818 author: "user".to_string(),
819 created_at: "2026-01-01T00:00:00Z".to_string(),
820 updated_at: "2026-01-01T00:00:00Z".to_string(),
821 url: "https://github.com/test/test/issues/1".to_string(),
822 };
823 let json = serde_json::to_string(&task).unwrap();
824 assert!(json.contains("\"title\":\"Test\""));
825 assert!(json.contains("status:new"));
826 }
827}
828
829#[cfg(test)]
830pub(crate) mod test_helpers;