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)]
130pub enum BlockedByFetch {
131 Native(Vec<BlockedByIssue>),
132 Unavailable,
133 Failed(String),
134 Unreachable(String),
135}
136
137#[async_trait]
142pub trait ExternalBackend: Send + Sync {
143 fn name(&self) -> &str;
145
146 async fn create_task(
148 &self,
149 title: &str,
150 body: &str,
151 labels: &[String],
152 ) -> anyhow::Result<ExternalId>;
153
154 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask>;
156
157 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
171 const MAX_RETRIES: u32 = 3;
172 let label = status.as_label();
173
174 if let Err(e) = self.ensure_status_label(label).await {
176 tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
177 }
178
179 let mut last_err =
180 anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
181
182 for attempt in 0..MAX_RETRIES {
183 if attempt > 0 {
184 time::sleep(Duration::from_millis(500 * u64::from(attempt))).await;
185 tracing::debug!(attempt, label, "retrying update_status");
186 }
187
188 let task = match self.get_task(id).await {
190 Ok(t) => t,
191 Err(e) => {
192 last_err = e;
193 continue;
194 }
195 };
196
197 let mut remove_failed = false;
199 for old in task
200 .labels
201 .iter()
202 .filter(|l| l.starts_with("status:") && l.as_str() != label)
203 {
204 if let Err(e) = self.remove_label(id, old).await {
205 tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
206 last_err = e;
207 remove_failed = true;
208 break;
209 }
210 }
211 if remove_failed {
212 continue;
213 }
214
215 match self.set_labels(id, &[label.to_string()]).await {
217 Ok(()) => return Ok(()),
218 Err(e) => {
219 tracing::warn!(label, err = %e, attempt, "set_labels failed");
220 last_err = e;
221 }
222 }
223 }
224
225 Err(last_err.context(format!(
226 "update_status({label}) failed after {MAX_RETRIES} attempts"
227 )))
228 }
229
230 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>>;
232
233 async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
238 let statuses = [
241 Status::New,
242 Status::Routed,
243 Status::InProgress,
244 Status::Done,
245 Status::Blocked,
246 Status::InReview,
247 Status::Approved,
248 Status::NeedsReview,
249 Status::Waiting,
250 ];
251 let mut all_tasks = Vec::new();
252 for status in statuses {
253 let tasks = self.list_by_status(status).await?;
254 all_tasks.extend(tasks);
255 }
256 Ok(all_tasks)
257 }
258
259 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
264 Ok(Vec::new())
265 }
266
267 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
271 self.list_by_status(Status::New).await
272 }
273
274 async fn list_active_open_issues(&self) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
282 let mut seen = HashSet::new();
283 let mut result = Vec::new();
284
285 if let Ok(routable) = self.list_routable().await {
287 for task in routable {
288 if seen.insert(task.id.0.clone()) {
289 result.push((task, None));
290 }
291 }
292 }
293
294 let active_non_new = [
296 Status::Routed,
297 Status::InProgress,
298 Status::NeedsReview,
299 Status::InReview,
300 Status::Approved,
301 Status::Blocked,
302 Status::Waiting,
303 ];
304 for status in active_non_new {
305 if let Ok(tasks) = self.list_by_status(status).await {
306 for task in tasks {
307 if seen.insert(task.id.0.clone()) {
308 result.push((task, Some(status)));
309 }
310 }
311 }
312 }
313
314 Ok(result)
315 }
316
317 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()>;
319
320 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()>;
324
325 async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()>;
327
328 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>>;
333
334 async fn create_sub_task(
340 &self,
341 parent_id: &ExternalId,
342 title: &str,
343 body: &str,
344 labels: &[String],
345 ) -> anyhow::Result<ExternalId> {
346 let mut all_labels = labels.to_vec();
348 all_labels.push(format!("parent:{}", parent_id.0));
349 self.create_task(title, body, &all_labels).await
350 }
351
352 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
358 Ok(())
359 }
360
361 async fn sync_to_project(
369 &self,
370 _id: &ExternalId,
371 _status: Status,
372 ) -> anyhow::Result<Option<String>> {
373 Ok(None)
374 }
375
376 async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
380 Ok(())
381 }
382
383 async fn sync_estimate_to_project(
389 &self,
390 _id: &ExternalId,
391 _estimate: u8,
392 ) -> anyhow::Result<()> {
393 Ok(())
394 }
395
396 async fn get_project_item_estimates(
403 &self,
404 _issue_node_ids: &[String],
405 ) -> anyhow::Result<HashMap<String, u8>> {
406 Ok(HashMap::new())
407 }
408
409 async fn has_open_issue_with_title(&self, _title: &str, _label: &str) -> anyhow::Result<bool> {
415 Ok(false)
416 }
417
418 async fn health_check(&self) -> anyhow::Result<()>;
420
421 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
425 Ok(false)
426 }
427
428 async fn batch_is_pr_merged(
434 &self,
435 branches: &[String],
436 ) -> anyhow::Result<HashMap<String, bool>> {
437 let mut result = HashMap::new();
438 for branch in branches {
439 match self.is_pr_merged(branch).await {
440 Ok(merged) => {
441 result.insert(branch.clone(), merged);
442 }
443 Err(e) => {
444 tracing::warn!(branch, err = %e, "failed to check PR merge status");
445 }
446 }
447 }
448 Ok(result)
449 }
450
451 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
455 Ok(None)
456 }
457
458 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
462 Ok(vec![])
463 }
464
465 async fn list_issue_comments(&self, _id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
469 Ok(vec![])
470 }
471
472 async fn acknowledge_issue(&self, _id: &ExternalId) -> anyhow::Result<()> {
477 Ok(())
478 }
479
480 async fn acknowledge_mention(&self, _comment_id: &str) -> anyhow::Result<()> {
485 Ok(())
486 }
487
488 async fn blocked_by(&self, _repo: &str, _issue_number: i64) -> anyhow::Result<BlockedByFetch> {
493 Ok(BlockedByFetch::Unavailable)
494 }
495
496 async fn issue_close_state(
500 &self,
501 _repo: &str,
502 _issue_number: i64,
503 ) -> anyhow::Result<Option<(String, Option<String>)>> {
504 Ok(None)
505 }
506
507 async fn post_comment_in_repo(
511 &self,
512 _repo: &str,
513 id: &ExternalId,
514 body: &str,
515 ) -> anyhow::Result<()> {
516 self.post_comment(id, body).await
517 }
518
519 async fn pull_request_completion(
524 &self,
525 _repo: &str,
526 _pr_number: i64,
527 ) -> anyhow::Result<Option<PullRequestCompletion>> {
528 Ok(None)
529 }
530
531 async fn add_dependency(
536 &self,
537 _issue_number: i64,
538 _blocking_repo: &str,
539 _blocking_number: i64,
540 ) -> anyhow::Result<()> {
541 anyhow::bail!("this backend cannot write issue dependencies")
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548 use crate::store::TaskStatus;
549 use async_trait::async_trait;
550 use std::sync::{Arc, Mutex};
551
552 struct MockBackend {
558 labels: Arc<Mutex<Vec<String>>>,
560 remove_err: Option<String>,
562 set_labels_fail_count: Arc<Mutex<u32>>,
564 }
565
566 impl MockBackend {
567 fn with_labels(labels: Vec<&str>) -> Self {
568 Self {
569 labels: Arc::new(Mutex::new(labels.into_iter().map(String::from).collect())),
570 remove_err: None,
571 set_labels_fail_count: Arc::new(Mutex::new(0)),
572 }
573 }
574
575 fn current_labels(&self) -> Vec<String> {
576 self.labels.lock().unwrap().clone()
577 }
578 }
579
580 #[async_trait]
581 impl ExternalBackend for MockBackend {
582 fn name(&self) -> &'static str {
583 "mock"
584 }
585
586 async fn create_task(
587 &self,
588 _title: &str,
589 _body: &str,
590 _labels: &[String],
591 ) -> anyhow::Result<ExternalId> {
592 Ok(ExternalId("1".into()))
593 }
594
595 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
596 Ok(ExternalTask {
597 id: ExternalId("1".into()),
598 title: "mock".into(),
599 body: String::new(),
600 state: "open".into(),
601 labels: self.current_labels(),
602 author: "user".into(),
603 created_at: "2026-01-01T00:00:00Z".into(),
604 updated_at: "2026-01-01T00:00:00Z".into(),
605 url: "https://example.com".into(),
606 })
607 }
608
609 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
613 Ok(vec![])
614 }
615
616 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
617 Ok(())
618 }
619
620 async fn set_labels(&self, _id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
621 let mut fail_count = self.set_labels_fail_count.lock().unwrap();
622 if *fail_count > 0 {
623 *fail_count -= 1;
624 return Err(anyhow::anyhow!("transient network error"));
625 }
626 drop(fail_count);
627 let mut current = self.labels.lock().unwrap();
628 for l in labels {
629 if !current.contains(l) {
630 current.push(l.clone());
631 }
632 }
633 Ok(())
634 }
635
636 async fn remove_label(&self, _id: &ExternalId, label: &str) -> anyhow::Result<()> {
637 if let Some(ref err_label) = self.remove_err
638 && label == err_label
639 {
640 return Err(anyhow::anyhow!("rate limit (429)"));
641 }
642 self.labels.lock().unwrap().retain(|l| l != label);
643 Ok(())
644 }
645
646 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
647 Ok(vec![])
648 }
649
650 async fn health_check(&self) -> anyhow::Result<()> {
651 Ok(())
652 }
653 }
654
655 #[tokio::test]
658 async fn update_status_replaces_single_old_label() {
659 let backend = MockBackend::with_labels(vec!["status:new", "priority:high"]);
660 let id = ExternalId("1".into());
661 backend
662 .update_status(&id, Status::InProgress)
663 .await
664 .unwrap();
665 let labels = backend.current_labels();
666 assert!(
667 labels.contains(&"status:in_progress".to_string()),
668 "new label added: {labels:?}"
669 );
670 assert!(
671 !labels.contains(&"status:new".to_string()),
672 "old label removed: {labels:?}"
673 );
674 assert!(
675 labels.contains(&"priority:high".to_string()),
676 "non-status label preserved: {labels:?}"
677 );
678 }
679
680 #[tokio::test]
681 async fn update_status_removes_multiple_old_status_labels() {
682 let backend = MockBackend::with_labels(vec!["status:in_progress", "status:blocked", "bug"]);
684 let id = ExternalId("1".into());
685 backend.update_status(&id, Status::Done).await.unwrap();
686 let labels = backend.current_labels();
687 let status_labels: Vec<_> = labels.iter().filter(|l| l.starts_with("status:")).collect();
688 assert_eq!(
689 status_labels,
690 vec!["status:done"],
691 "exactly one status label: {labels:?}"
692 );
693 }
694
695 #[tokio::test]
696 async fn update_status_is_idempotent() {
697 let backend = MockBackend::with_labels(vec!["status:done"]);
698 let id = ExternalId("1".into());
699 backend.update_status(&id, Status::Done).await.unwrap();
700 let labels = backend.current_labels();
701 let status_count = labels.iter().filter(|l| l.starts_with("status:")).count();
702 assert_eq!(
703 status_count, 1,
704 "idempotent: still exactly one status label: {labels:?}"
705 );
706 }
707
708 #[tokio::test]
709 async fn update_status_non_status_labels_are_preserved() {
710 let backend =
711 MockBackend::with_labels(vec!["status:routed", "agent:claude", "maintenance"]);
712 let id = ExternalId("1".into());
713 backend
714 .update_status(&id, Status::InProgress)
715 .await
716 .unwrap();
717 let labels = backend.current_labels();
718 assert!(
719 labels.contains(&"agent:claude".to_string()),
720 "agent label preserved: {labels:?}"
721 );
722 assert!(
723 labels.contains(&"maintenance".to_string()),
724 "other label preserved: {labels:?}"
725 );
726 }
727
728 #[tokio::test]
729 async fn lifecycle_transition_preserves_board_label_and_no_agent_hold() {
730 let backend = MockBackend::with_labels(vec!["status:new", "board:held", "no-agent"]);
731 backend
732 .update_status(&ExternalId("1".into()), Status::Routed)
733 .await
734 .unwrap();
735 let mut labels = backend.current_labels();
736 labels.sort();
737 assert_eq!(labels, vec!["board:held", "no-agent", "status:routed"]);
738 }
739
740 #[tokio::test]
741 async fn update_status_retries_on_transient_set_labels_failure() {
742 let backend = MockBackend::with_labels(vec!["status:new"]);
743 *backend.set_labels_fail_count.lock().unwrap() = 1;
745 let id = ExternalId("1".into());
746 backend
747 .update_status(&id, Status::InProgress)
748 .await
749 .unwrap();
750 let labels = backend.current_labels();
751 assert!(
752 labels.contains(&"status:in_progress".to_string()),
753 "label added after retry: {labels:?}"
754 );
755 assert!(
756 !labels.contains(&"status:new".to_string()),
757 "old label removed: {labels:?}"
758 );
759 }
760
761 #[tokio::test]
762 async fn update_status_fails_after_max_retries() {
763 let backend = MockBackend::with_labels(vec!["status:new"]);
764 *backend.set_labels_fail_count.lock().unwrap() = 10;
766 let id = ExternalId("1".into());
767 let result = backend.update_status(&id, Status::Done).await;
768 assert!(result.is_err(), "should fail after max retries");
769 assert!(
770 result
771 .unwrap_err()
772 .to_string()
773 .contains("failed after 3 attempts"),
774 "error message mentions retry exhaustion"
775 );
776 }
777
778 #[test]
779 fn status_as_label() {
780 assert_eq!(Status::New.as_label(), "status:new");
781 assert_eq!(Status::Routed.as_label(), "status:routed");
782 assert_eq!(Status::InProgress.as_label(), "status:in_progress");
783 assert_eq!(Status::Done.as_label(), "status:done");
784 assert_eq!(Status::Blocked.as_label(), "status:blocked");
785 assert_eq!(Status::InReview.as_label(), "status:in_review");
786 assert_eq!(Status::Approved.as_label(), "status:approved");
787 assert_eq!(Status::Approved.as_str(), "approved");
788 assert_eq!(TaskStatus::from_str("approved"), Some(TaskStatus::Approved));
789 assert_eq!(TaskStatus::Approved.as_str(), "approved");
790 assert_eq!(Status::NeedsReview.as_label(), "status:needs_review");
791 }
792
793 #[test]
794 fn status_serializes_snake_case() {
795 let json = serde_json::to_string(&Status::InProgress).unwrap();
796 assert_eq!(json, "\"in_progress\"");
797 }
798
799 #[test]
800 fn status_deserializes_snake_case() {
801 let status: Status = serde_json::from_str("\"needs_review\"").unwrap();
802 assert_eq!(status, Status::NeedsReview);
803 }
804
805 #[test]
806 fn external_id_clone() {
807 let id = ExternalId("42".to_string());
808 let cloned = id.clone();
809 assert_eq!(id.0, cloned.0);
810 }
811
812 #[test]
813 fn external_task_serializes() {
814 let task = ExternalTask {
815 id: ExternalId("1".to_string()),
816 title: "Test".to_string(),
817 body: "Body".to_string(),
818 state: "open".to_string(),
819 labels: vec!["status:new".to_string()],
820 author: "user".to_string(),
821 created_at: "2026-01-01T00:00:00Z".to_string(),
822 updated_at: "2026-01-01T00:00:00Z".to_string(),
823 url: "https://github.com/test/test/issues/1".to_string(),
824 };
825 let json = serde_json::to_string(&task).unwrap();
826 assert!(json.contains("\"title\":\"Test\""));
827 assert!(json.contains("status:new"));
828 }
829}
830
831#[cfg(test)]
832pub(crate) mod test_helpers;