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#[allow(clippy::double_must_use)] #[async_trait]
136pub trait ExternalBackend: Send + Sync {
137 fn name(&self) -> &str;
139
140 async fn create_task(
142 &self,
143 title: &str,
144 body: &str,
145 labels: &[String],
146 ) -> anyhow::Result<ExternalId>;
147
148 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask>;
150
151 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
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 const MAX_RETRIES: u32 = 3;
173 let mut last_err =
174 anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
175
176 for attempt in 0..MAX_RETRIES {
177 if attempt > 0 {
178 tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
179 .await;
180 tracing::debug!(attempt, label, "retrying update_status");
181 }
182
183 let task = match self.get_task(id).await {
185 Ok(t) => t,
186 Err(e) => {
187 last_err = e;
188 continue;
189 }
190 };
191
192 let mut remove_failed = false;
194 for old in task
195 .labels
196 .iter()
197 .filter(|l| l.starts_with("status:") && l.as_str() != label)
198 {
199 if let Err(e) = self.remove_label(id, old).await {
200 tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
201 last_err = e;
202 remove_failed = true;
203 break;
204 }
205 }
206 if remove_failed {
207 continue;
208 }
209
210 match self.set_labels(id, &[label.to_string()]).await {
212 Ok(()) => return Ok(()),
213 Err(e) => {
214 tracing::warn!(label, err = %e, attempt, "set_labels failed");
215 last_err = e;
216 }
217 }
218 }
219
220 Err(last_err.context(format!(
221 "update_status({label}) failed after {MAX_RETRIES} attempts"
222 )))
223 }
224
225 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>>;
227
228 async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
233 let statuses = [
236 Status::New,
237 Status::Routed,
238 Status::InProgress,
239 Status::Done,
240 Status::Blocked,
241 Status::InReview,
242 Status::Approved,
243 Status::NeedsReview,
244 Status::Waiting,
245 ];
246 let mut all_tasks = Vec::new();
247 for status in statuses {
248 let tasks = self.list_by_status(status).await?;
249 all_tasks.extend(tasks);
250 }
251 Ok(all_tasks)
252 }
253
254 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
259 Ok(Vec::new())
260 }
261
262 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
266 self.list_by_status(Status::New).await
267 }
268
269 async fn list_active_open_issues(&self) -> 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::Approved,
296 Status::Blocked,
297 Status::Waiting,
298 ];
299 for status in active_non_new {
300 if let Ok(tasks) = self.list_by_status(status).await {
301 for task in tasks {
302 if seen.insert(task.id.0.clone()) {
303 result.push((task, Some(status)));
304 }
305 }
306 }
307 }
308
309 Ok(result)
310 }
311
312 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()>;
314
315 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()>;
319
320 async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()>;
322
323 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>>;
328
329 async fn create_sub_task(
335 &self,
336 parent_id: &ExternalId,
337 title: &str,
338 body: &str,
339 labels: &[String],
340 ) -> anyhow::Result<ExternalId> {
341 let mut all_labels = labels.to_vec();
343 all_labels.push(format!("parent:{}", parent_id.0));
344 self.create_task(title, body, &all_labels).await
345 }
346
347 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
353 Ok(())
354 }
355
356 async fn sync_to_project(
364 &self,
365 _id: &ExternalId,
366 _status: Status,
367 ) -> anyhow::Result<Option<String>> {
368 Ok(None)
369 }
370
371 async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
375 Ok(())
376 }
377
378 async fn sync_estimate_to_project(
384 &self,
385 _id: &ExternalId,
386 _estimate: u8,
387 ) -> anyhow::Result<()> {
388 Ok(())
389 }
390
391 async fn get_project_item_estimates(
398 &self,
399 _issue_node_ids: &[String],
400 ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
401 Ok(std::collections::HashMap::new())
402 }
403
404 #[allow(dead_code)]
410 async fn has_open_issue_with_title(&self, _title: &str, _label: &str) -> anyhow::Result<bool> {
411 Ok(false)
412 }
413
414 async fn health_check(&self) -> anyhow::Result<()>;
416
417 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
421 Ok(false)
422 }
423
424 async fn batch_is_pr_merged(
430 &self,
431 branches: &[String],
432 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
433 let mut result = std::collections::HashMap::new();
434 for branch in branches {
435 match self.is_pr_merged(branch).await {
436 Ok(merged) => {
437 result.insert(branch.clone(), merged);
438 }
439 Err(e) => {
440 tracing::warn!(branch, err = %e, "failed to check PR merge status");
441 }
442 }
443 }
444 Ok(result)
445 }
446
447 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
451 Ok(None)
452 }
453
454 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
458 Ok(vec![])
459 }
460
461 async fn list_issue_comments(&self, _id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
465 Ok(vec![])
466 }
467
468 async fn acknowledge_issue(&self, _id: &ExternalId) -> anyhow::Result<()> {
473 Ok(())
474 }
475
476 async fn acknowledge_mention(&self, _comment_id: &str) -> anyhow::Result<()> {
481 Ok(())
482 }
483
484 async fn blocked_by(&self, _repo: &str, _issue_number: i64) -> anyhow::Result<BlockedByFetch> {
489 Ok(BlockedByFetch::Unavailable)
490 }
491
492 async fn issue_close_state(
496 &self,
497 _repo: &str,
498 _issue_number: i64,
499 ) -> anyhow::Result<Option<(String, Option<String>)>> {
500 Ok(None)
501 }
502
503 async fn post_comment_in_repo(
507 &self,
508 _repo: &str,
509 id: &ExternalId,
510 body: &str,
511 ) -> anyhow::Result<()> {
512 self.post_comment(id, body).await
513 }
514
515 async fn pull_request_completion(
520 &self,
521 _repo: &str,
522 _pr_number: i64,
523 ) -> anyhow::Result<Option<PullRequestCompletion>> {
524 Ok(None)
525 }
526
527 async fn add_dependency(
532 &self,
533 _issue_number: i64,
534 _blocking_repo: &str,
535 _blocking_number: i64,
536 ) -> anyhow::Result<()> {
537 anyhow::bail!("this backend cannot write issue dependencies")
538 }
539}
540
541#[cfg(test)]
542mod tests {
543 use super::*;
544 use async_trait::async_trait;
545 use std::sync::{Arc, Mutex};
546
547 struct MockBackend {
553 labels: Arc<Mutex<Vec<String>>>,
555 remove_err: Option<String>,
557 set_labels_fail_count: Arc<Mutex<u32>>,
559 }
560
561 impl MockBackend {
562 fn with_labels(labels: Vec<&str>) -> Self {
563 Self {
564 labels: Arc::new(Mutex::new(labels.into_iter().map(String::from).collect())),
565 remove_err: None,
566 set_labels_fail_count: Arc::new(Mutex::new(0)),
567 }
568 }
569
570 fn current_labels(&self) -> Vec<String> {
571 self.labels.lock().unwrap().clone()
572 }
573 }
574
575 #[async_trait]
576 impl ExternalBackend for MockBackend {
577 fn name(&self) -> &str {
578 "mock"
579 }
580
581 async fn create_task(
582 &self,
583 _title: &str,
584 _body: &str,
585 _labels: &[String],
586 ) -> anyhow::Result<ExternalId> {
587 Ok(ExternalId("1".into()))
588 }
589
590 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
591 Ok(ExternalTask {
592 id: ExternalId("1".into()),
593 title: "mock".into(),
594 body: "".into(),
595 state: "open".into(),
596 labels: self.current_labels(),
597 author: "user".into(),
598 created_at: "2026-01-01T00:00:00Z".into(),
599 updated_at: "2026-01-01T00:00:00Z".into(),
600 url: "https://example.com".into(),
601 })
602 }
603
604 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
608 Ok(vec![])
609 }
610
611 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
612 Ok(())
613 }
614
615 async fn set_labels(&self, _id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
616 let mut fail_count = self.set_labels_fail_count.lock().unwrap();
617 if *fail_count > 0 {
618 *fail_count -= 1;
619 return Err(anyhow::anyhow!("transient network error"));
620 }
621 drop(fail_count);
622 let mut current = self.labels.lock().unwrap();
623 for l in labels {
624 if !current.contains(l) {
625 current.push(l.clone());
626 }
627 }
628 Ok(())
629 }
630
631 async fn remove_label(&self, _id: &ExternalId, label: &str) -> anyhow::Result<()> {
632 if let Some(ref err_label) = self.remove_err {
633 if label == err_label {
634 return Err(anyhow::anyhow!("rate limit (429)"));
635 }
636 }
637 self.labels.lock().unwrap().retain(|l| l != label);
638 Ok(())
639 }
640
641 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
642 Ok(vec![])
643 }
644
645 async fn health_check(&self) -> anyhow::Result<()> {
646 Ok(())
647 }
648 }
649
650 #[tokio::test]
653 async fn update_status_replaces_single_old_label() {
654 let backend = MockBackend::with_labels(vec!["status:new", "priority:high"]);
655 let id = ExternalId("1".into());
656 backend
657 .update_status(&id, Status::InProgress)
658 .await
659 .unwrap();
660 let labels = backend.current_labels();
661 assert!(
662 labels.contains(&"status:in_progress".to_string()),
663 "new label added: {labels:?}"
664 );
665 assert!(
666 !labels.contains(&"status:new".to_string()),
667 "old label removed: {labels:?}"
668 );
669 assert!(
670 labels.contains(&"priority:high".to_string()),
671 "non-status label preserved: {labels:?}"
672 );
673 }
674
675 #[tokio::test]
676 async fn update_status_removes_multiple_old_status_labels() {
677 let backend = MockBackend::with_labels(vec!["status:in_progress", "status:blocked", "bug"]);
679 let id = ExternalId("1".into());
680 backend.update_status(&id, Status::Done).await.unwrap();
681 let labels = backend.current_labels();
682 let status_labels: Vec<_> = labels.iter().filter(|l| l.starts_with("status:")).collect();
683 assert_eq!(
684 status_labels,
685 vec!["status:done"],
686 "exactly one status label: {labels:?}"
687 );
688 }
689
690 #[tokio::test]
691 async fn update_status_is_idempotent() {
692 let backend = MockBackend::with_labels(vec!["status:done"]);
693 let id = ExternalId("1".into());
694 backend.update_status(&id, Status::Done).await.unwrap();
695 let labels = backend.current_labels();
696 let status_count = labels.iter().filter(|l| l.starts_with("status:")).count();
697 assert_eq!(
698 status_count, 1,
699 "idempotent: still exactly one status label: {labels:?}"
700 );
701 }
702
703 #[tokio::test]
704 async fn update_status_non_status_labels_are_preserved() {
705 let backend =
706 MockBackend::with_labels(vec!["status:routed", "agent:claude", "maintenance"]);
707 let id = ExternalId("1".into());
708 backend
709 .update_status(&id, Status::InProgress)
710 .await
711 .unwrap();
712 let labels = backend.current_labels();
713 assert!(
714 labels.contains(&"agent:claude".to_string()),
715 "agent label preserved: {labels:?}"
716 );
717 assert!(
718 labels.contains(&"maintenance".to_string()),
719 "other label preserved: {labels:?}"
720 );
721 }
722
723 #[tokio::test]
724 async fn lifecycle_transition_preserves_board_label_and_no_agent_hold() {
725 let backend = MockBackend::with_labels(vec!["status:new", "board:held", "no-agent"]);
726 backend
727 .update_status(&ExternalId("1".into()), Status::Routed)
728 .await
729 .unwrap();
730 let mut labels = backend.current_labels();
731 labels.sort();
732 assert_eq!(labels, vec!["board:held", "no-agent", "status:routed"]);
733 }
734
735 #[tokio::test]
736 async fn update_status_retries_on_transient_set_labels_failure() {
737 let backend = MockBackend::with_labels(vec!["status:new"]);
738 *backend.set_labels_fail_count.lock().unwrap() = 1;
740 let id = ExternalId("1".into());
741 backend
742 .update_status(&id, Status::InProgress)
743 .await
744 .unwrap();
745 let labels = backend.current_labels();
746 assert!(
747 labels.contains(&"status:in_progress".to_string()),
748 "label added after retry: {labels:?}"
749 );
750 assert!(
751 !labels.contains(&"status:new".to_string()),
752 "old label removed: {labels:?}"
753 );
754 }
755
756 #[tokio::test]
757 async fn update_status_fails_after_max_retries() {
758 let backend = MockBackend::with_labels(vec!["status:new"]);
759 *backend.set_labels_fail_count.lock().unwrap() = 10;
761 let id = ExternalId("1".into());
762 let result = backend.update_status(&id, Status::Done).await;
763 assert!(result.is_err(), "should fail after max retries");
764 assert!(
765 result
766 .unwrap_err()
767 .to_string()
768 .contains("failed after 3 attempts"),
769 "error message mentions retry exhaustion"
770 );
771 }
772
773 #[test]
774 fn status_as_label() {
775 assert_eq!(Status::New.as_label(), "status:new");
776 assert_eq!(Status::Routed.as_label(), "status:routed");
777 assert_eq!(Status::InProgress.as_label(), "status:in_progress");
778 assert_eq!(Status::Done.as_label(), "status:done");
779 assert_eq!(Status::Blocked.as_label(), "status:blocked");
780 assert_eq!(Status::InReview.as_label(), "status:in_review");
781 assert_eq!(Status::Approved.as_label(), "status:approved");
782 assert_eq!(Status::Approved.as_str(), "approved");
783 assert_eq!(
784 crate::store::TaskStatus::from_str("approved"),
785 Some(crate::store::TaskStatus::Approved)
786 );
787 assert_eq!(crate::store::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;