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}
57
58impl Status {
59 pub fn as_label(&self) -> &'static str {
60 match self {
61 Self::New => "status:new",
62 Self::Routed => "status:routed",
63 Self::InProgress => "status:in_progress",
64 Self::Done => "status:done",
65 Self::Blocked => "status:blocked",
66 Self::InReview => "status:in_review",
67 Self::NeedsReview => "status:needs_review",
68 }
69 }
70
71 pub fn as_str(&self) -> &'static str {
72 match self {
73 Self::New => "new",
74 Self::Routed => "routed",
75 Self::InProgress => "in_progress",
76 Self::Done => "done",
77 Self::Blocked => "blocked",
78 Self::InReview => "in_review",
79 Self::NeedsReview => "needs_review",
80 }
81 }
82}
83
84#[allow(clippy::double_must_use)] #[async_trait]
90pub trait ExternalBackend: Send + Sync {
91 fn name(&self) -> &str;
93
94 async fn create_task(
96 &self,
97 title: &str,
98 body: &str,
99 labels: &[String],
100 ) -> anyhow::Result<ExternalId>;
101
102 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask>;
104
105 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
119 let label = status.as_label();
120
121 if let Err(e) = self.ensure_status_label(label).await {
123 tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
124 }
125
126 const MAX_RETRIES: u32 = 3;
127 let mut last_err =
128 anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
129
130 for attempt in 0..MAX_RETRIES {
131 if attempt > 0 {
132 tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
133 .await;
134 tracing::debug!(attempt, label, "retrying update_status");
135 }
136
137 let task = match self.get_task(id).await {
139 Ok(t) => t,
140 Err(e) => {
141 last_err = e;
142 continue;
143 }
144 };
145
146 let mut remove_failed = false;
148 for old in task
149 .labels
150 .iter()
151 .filter(|l| l.starts_with("status:") && l.as_str() != label)
152 {
153 if let Err(e) = self.remove_label(id, old).await {
154 tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
155 last_err = e;
156 remove_failed = true;
157 break;
158 }
159 }
160 if remove_failed {
161 continue;
162 }
163
164 match self.set_labels(id, &[label.to_string()]).await {
166 Ok(()) => return Ok(()),
167 Err(e) => {
168 tracing::warn!(label, err = %e, attempt, "set_labels failed");
169 last_err = e;
170 }
171 }
172 }
173
174 Err(last_err.context(format!(
175 "update_status({label}) failed after {MAX_RETRIES} attempts"
176 )))
177 }
178
179 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>>;
181
182 async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
187 let statuses = [
190 Status::New,
191 Status::Routed,
192 Status::InProgress,
193 Status::Done,
194 Status::Blocked,
195 Status::InReview,
196 Status::NeedsReview,
197 ];
198 let mut all_tasks = Vec::new();
199 for status in statuses {
200 let tasks = self.list_by_status(status).await?;
201 all_tasks.extend(tasks);
202 }
203 Ok(all_tasks)
204 }
205
206 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
211 Ok(Vec::new())
212 }
213
214 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
218 self.list_by_status(Status::New).await
219 }
220
221 async fn list_active_open_issues(
232 &self,
233 _since: Option<&str>,
234 ) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
235 let mut seen = std::collections::HashSet::new();
236 let mut result = Vec::new();
237
238 if let Ok(routable) = self.list_routable().await {
240 for task in routable {
241 if seen.insert(task.id.0.clone()) {
242 result.push((task, None));
243 }
244 }
245 }
246
247 let active_non_new = [
249 Status::Routed,
250 Status::InProgress,
251 Status::NeedsReview,
252 Status::InReview,
253 Status::Blocked,
254 ];
255 for status in active_non_new {
256 if let Ok(tasks) = self.list_by_status(status).await {
257 for task in tasks {
258 if seen.insert(task.id.0.clone()) {
259 result.push((task, Some(status)));
260 }
261 }
262 }
263 }
264
265 Ok(result)
266 }
267
268 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()>;
270
271 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()>;
275
276 async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()>;
278
279 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>>;
284
285 async fn create_sub_task(
291 &self,
292 parent_id: &ExternalId,
293 title: &str,
294 body: &str,
295 labels: &[String],
296 ) -> anyhow::Result<ExternalId> {
297 let mut all_labels = labels.to_vec();
299 all_labels.push(format!("parent:{}", parent_id.0));
300 self.create_task(title, body, &all_labels).await
301 }
302
303 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
309 Ok(())
310 }
311
312 async fn sync_to_project(
320 &self,
321 _id: &ExternalId,
322 _status: Status,
323 ) -> anyhow::Result<Option<String>> {
324 Ok(None)
325 }
326
327 async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
331 Ok(())
332 }
333
334 async fn sync_estimate_to_project(
340 &self,
341 _id: &ExternalId,
342 _estimate: u8,
343 ) -> anyhow::Result<()> {
344 Ok(())
345 }
346
347 async fn get_project_item_estimates(
354 &self,
355 _issue_node_ids: &[String],
356 ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
357 Ok(std::collections::HashMap::new())
358 }
359
360 #[allow(dead_code)]
366 async fn has_open_issue_with_title(&self, _title: &str, _label: &str) -> anyhow::Result<bool> {
367 Ok(false)
368 }
369
370 async fn health_check(&self) -> anyhow::Result<()>;
372
373 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
377 Ok(false)
378 }
379
380 async fn batch_is_pr_merged(
386 &self,
387 branches: &[String],
388 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
389 let mut result = std::collections::HashMap::new();
390 for branch in branches {
391 match self.is_pr_merged(branch).await {
392 Ok(merged) => {
393 result.insert(branch.clone(), merged);
394 }
395 Err(e) => {
396 tracing::warn!(branch, err = %e, "failed to check PR merge status");
397 }
398 }
399 }
400 Ok(result)
401 }
402
403 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
407 Ok(None)
408 }
409
410 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
414 Ok(vec![])
415 }
416
417 async fn list_issue_comments(&self, _id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
421 Ok(vec![])
422 }
423
424 async fn acknowledge_issue(&self, _id: &ExternalId) -> anyhow::Result<()> {
429 Ok(())
430 }
431
432 async fn acknowledge_mention(&self, _comment_id: &str) -> anyhow::Result<()> {
437 Ok(())
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use async_trait::async_trait;
445 use std::sync::{Arc, Mutex};
446
447 struct MockBackend {
453 labels: Arc<Mutex<Vec<String>>>,
455 remove_err: Option<String>,
457 set_labels_fail_count: Arc<Mutex<u32>>,
459 }
460
461 impl MockBackend {
462 fn with_labels(labels: Vec<&str>) -> Self {
463 Self {
464 labels: Arc::new(Mutex::new(labels.into_iter().map(String::from).collect())),
465 remove_err: None,
466 set_labels_fail_count: Arc::new(Mutex::new(0)),
467 }
468 }
469
470 fn current_labels(&self) -> Vec<String> {
471 self.labels.lock().unwrap().clone()
472 }
473 }
474
475 #[async_trait]
476 impl ExternalBackend for MockBackend {
477 fn name(&self) -> &str {
478 "mock"
479 }
480
481 async fn create_task(
482 &self,
483 _title: &str,
484 _body: &str,
485 _labels: &[String],
486 ) -> anyhow::Result<ExternalId> {
487 Ok(ExternalId("1".into()))
488 }
489
490 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
491 Ok(ExternalTask {
492 id: ExternalId("1".into()),
493 title: "mock".into(),
494 body: "".into(),
495 state: "open".into(),
496 labels: self.current_labels(),
497 author: "user".into(),
498 created_at: "2026-01-01T00:00:00Z".into(),
499 updated_at: "2026-01-01T00:00:00Z".into(),
500 url: "https://example.com".into(),
501 })
502 }
503
504 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
508 Ok(vec![])
509 }
510
511 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
512 Ok(())
513 }
514
515 async fn set_labels(&self, _id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
516 let mut fail_count = self.set_labels_fail_count.lock().unwrap();
517 if *fail_count > 0 {
518 *fail_count -= 1;
519 return Err(anyhow::anyhow!("transient network error"));
520 }
521 drop(fail_count);
522 let mut current = self.labels.lock().unwrap();
523 for l in labels {
524 if !current.contains(l) {
525 current.push(l.clone());
526 }
527 }
528 Ok(())
529 }
530
531 async fn remove_label(&self, _id: &ExternalId, label: &str) -> anyhow::Result<()> {
532 if let Some(ref err_label) = self.remove_err {
533 if label == err_label {
534 return Err(anyhow::anyhow!("rate limit (429)"));
535 }
536 }
537 self.labels.lock().unwrap().retain(|l| l != label);
538 Ok(())
539 }
540
541 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
542 Ok(vec![])
543 }
544
545 async fn health_check(&self) -> anyhow::Result<()> {
546 Ok(())
547 }
548 }
549
550 #[tokio::test]
553 async fn update_status_replaces_single_old_label() {
554 let backend = MockBackend::with_labels(vec!["status:new", "priority:high"]);
555 let id = ExternalId("1".into());
556 backend
557 .update_status(&id, Status::InProgress)
558 .await
559 .unwrap();
560 let labels = backend.current_labels();
561 assert!(
562 labels.contains(&"status:in_progress".to_string()),
563 "new label added: {labels:?}"
564 );
565 assert!(
566 !labels.contains(&"status:new".to_string()),
567 "old label removed: {labels:?}"
568 );
569 assert!(
570 labels.contains(&"priority:high".to_string()),
571 "non-status label preserved: {labels:?}"
572 );
573 }
574
575 #[tokio::test]
576 async fn update_status_removes_multiple_old_status_labels() {
577 let backend = MockBackend::with_labels(vec!["status:in_progress", "status:blocked", "bug"]);
579 let id = ExternalId("1".into());
580 backend.update_status(&id, Status::Done).await.unwrap();
581 let labels = backend.current_labels();
582 let status_labels: Vec<_> = labels.iter().filter(|l| l.starts_with("status:")).collect();
583 assert_eq!(
584 status_labels,
585 vec!["status:done"],
586 "exactly one status label: {labels:?}"
587 );
588 }
589
590 #[tokio::test]
591 async fn update_status_is_idempotent() {
592 let backend = MockBackend::with_labels(vec!["status:done"]);
593 let id = ExternalId("1".into());
594 backend.update_status(&id, Status::Done).await.unwrap();
595 let labels = backend.current_labels();
596 let status_count = labels.iter().filter(|l| l.starts_with("status:")).count();
597 assert_eq!(
598 status_count, 1,
599 "idempotent: still exactly one status label: {labels:?}"
600 );
601 }
602
603 #[tokio::test]
604 async fn update_status_non_status_labels_are_preserved() {
605 let backend =
606 MockBackend::with_labels(vec!["status:routed", "agent:claude", "maintenance"]);
607 let id = ExternalId("1".into());
608 backend
609 .update_status(&id, Status::InProgress)
610 .await
611 .unwrap();
612 let labels = backend.current_labels();
613 assert!(
614 labels.contains(&"agent:claude".to_string()),
615 "agent label preserved: {labels:?}"
616 );
617 assert!(
618 labels.contains(&"maintenance".to_string()),
619 "other label preserved: {labels:?}"
620 );
621 }
622
623 #[tokio::test]
624 async fn lifecycle_transition_preserves_board_label_and_no_agent_hold() {
625 let backend = MockBackend::with_labels(vec!["status:new", "board:held", "no-agent"]);
626 backend
627 .update_status(&ExternalId("1".into()), Status::Routed)
628 .await
629 .unwrap();
630 let mut labels = backend.current_labels();
631 labels.sort();
632 assert_eq!(labels, vec!["board:held", "no-agent", "status:routed"]);
633 }
634
635 #[tokio::test]
636 async fn update_status_retries_on_transient_set_labels_failure() {
637 let backend = MockBackend::with_labels(vec!["status:new"]);
638 *backend.set_labels_fail_count.lock().unwrap() = 1;
640 let id = ExternalId("1".into());
641 backend
642 .update_status(&id, Status::InProgress)
643 .await
644 .unwrap();
645 let labels = backend.current_labels();
646 assert!(
647 labels.contains(&"status:in_progress".to_string()),
648 "label added after retry: {labels:?}"
649 );
650 assert!(
651 !labels.contains(&"status:new".to_string()),
652 "old label removed: {labels:?}"
653 );
654 }
655
656 #[tokio::test]
657 async fn update_status_fails_after_max_retries() {
658 let backend = MockBackend::with_labels(vec!["status:new"]);
659 *backend.set_labels_fail_count.lock().unwrap() = 10;
661 let id = ExternalId("1".into());
662 let result = backend.update_status(&id, Status::Done).await;
663 assert!(result.is_err(), "should fail after max retries");
664 assert!(
665 result
666 .unwrap_err()
667 .to_string()
668 .contains("failed after 3 attempts"),
669 "error message mentions retry exhaustion"
670 );
671 }
672
673 #[test]
674 fn status_as_label() {
675 assert_eq!(Status::New.as_label(), "status:new");
676 assert_eq!(Status::Routed.as_label(), "status:routed");
677 assert_eq!(Status::InProgress.as_label(), "status:in_progress");
678 assert_eq!(Status::Done.as_label(), "status:done");
679 assert_eq!(Status::Blocked.as_label(), "status:blocked");
680 assert_eq!(Status::InReview.as_label(), "status:in_review");
681 assert_eq!(Status::NeedsReview.as_label(), "status:needs_review");
682 }
683
684 #[test]
685 fn status_serializes_snake_case() {
686 let json = serde_json::to_string(&Status::InProgress).unwrap();
687 assert_eq!(json, "\"in_progress\"");
688 }
689
690 #[test]
691 fn status_deserializes_snake_case() {
692 let status: Status = serde_json::from_str("\"needs_review\"").unwrap();
693 assert_eq!(status, Status::NeedsReview);
694 }
695
696 #[test]
697 fn external_id_clone() {
698 let id = ExternalId("42".to_string());
699 let cloned = id.clone();
700 assert_eq!(id.0, cloned.0);
701 }
702
703 #[test]
704 fn external_task_serializes() {
705 let task = ExternalTask {
706 id: ExternalId("1".to_string()),
707 title: "Test".to_string(),
708 body: "Body".to_string(),
709 state: "open".to_string(),
710 labels: vec!["status:new".to_string()],
711 author: "user".to_string(),
712 created_at: "2026-01-01T00:00:00Z".to_string(),
713 updated_at: "2026-01-01T00:00:00Z".to_string(),
714 url: "https://github.com/test/test/issues/1".to_string(),
715 };
716 let json = serde_json::to_string(&task).unwrap();
717 assert!(json.contains("\"title\":\"Test\""));
718 assert!(json.contains("status:new"));
719 }
720}
721
722#[cfg(test)]
723pub(crate) mod test_helpers;