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#[async_trait]
89pub trait ExternalBackend: Send + Sync {
90 fn name(&self) -> &str;
92
93 async fn create_task(
95 &self,
96 title: &str,
97 body: &str,
98 labels: &[String],
99 ) -> anyhow::Result<ExternalId>;
100
101 async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask>;
103
104 async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
118 let label = status.as_label();
119
120 if let Err(e) = self.ensure_status_label(label).await {
122 tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
123 }
124
125 const MAX_RETRIES: u32 = 3;
126 let mut last_err =
127 anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
128
129 for attempt in 0..MAX_RETRIES {
130 if attempt > 0 {
131 tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
132 .await;
133 tracing::debug!(attempt, label, "retrying update_status");
134 }
135
136 let task = match self.get_task(id).await {
138 Ok(t) => t,
139 Err(e) => {
140 last_err = e;
141 continue;
142 }
143 };
144
145 let mut remove_failed = false;
147 for old in task
148 .labels
149 .iter()
150 .filter(|l| l.starts_with("status:") && l.as_str() != label)
151 {
152 if let Err(e) = self.remove_label(id, old).await {
153 tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
154 last_err = e;
155 remove_failed = true;
156 break;
157 }
158 }
159 if remove_failed {
160 continue;
161 }
162
163 match self.set_labels(id, &[label.to_string()]).await {
165 Ok(()) => return Ok(()),
166 Err(e) => {
167 tracing::warn!(label, err = %e, attempt, "set_labels failed");
168 last_err = e;
169 }
170 }
171 }
172
173 Err(last_err.context(format!(
174 "update_status({label}) failed after {MAX_RETRIES} attempts"
175 )))
176 }
177
178 async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>>;
180
181 async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
186 let statuses = [
189 Status::New,
190 Status::Routed,
191 Status::InProgress,
192 Status::Done,
193 Status::Blocked,
194 Status::InReview,
195 Status::NeedsReview,
196 ];
197 let mut all_tasks = Vec::new();
198 for status in statuses {
199 let tasks = self.list_by_status(status).await?;
200 all_tasks.extend(tasks);
201 }
202 Ok(all_tasks)
203 }
204
205 async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
210 Ok(Vec::new())
211 }
212
213 async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
217 self.list_by_status(Status::New).await
218 }
219
220 async fn list_active_open_issues(
231 &self,
232 _since: Option<&str>,
233 ) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
234 let mut seen = std::collections::HashSet::new();
235 let mut result = Vec::new();
236
237 if let Ok(routable) = self.list_routable().await {
239 for task in routable {
240 if seen.insert(task.id.0.clone()) {
241 result.push((task, None));
242 }
243 }
244 }
245
246 let active_non_new = [
248 Status::Routed,
249 Status::InProgress,
250 Status::NeedsReview,
251 Status::InReview,
252 Status::Blocked,
253 ];
254 for status in active_non_new {
255 if let Ok(tasks) = self.list_by_status(status).await {
256 for task in tasks {
257 if seen.insert(task.id.0.clone()) {
258 result.push((task, Some(status)));
259 }
260 }
261 }
262 }
263
264 Ok(result)
265 }
266
267 async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()>;
269
270 async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()>;
274
275 async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()>;
277
278 async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>>;
283
284 async fn create_sub_task(
290 &self,
291 parent_id: &ExternalId,
292 title: &str,
293 body: &str,
294 labels: &[String],
295 ) -> anyhow::Result<ExternalId> {
296 let mut all_labels = labels.to_vec();
298 all_labels.push(format!("parent:{}", parent_id.0));
299 self.create_task(title, body, &all_labels).await
300 }
301
302 async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
308 Ok(())
309 }
310
311 async fn sync_to_project(
319 &self,
320 _id: &ExternalId,
321 _status: Status,
322 ) -> anyhow::Result<Option<String>> {
323 Ok(None)
324 }
325
326 async fn sync_estimate_to_project(
332 &self,
333 _id: &ExternalId,
334 _estimate: u8,
335 ) -> anyhow::Result<()> {
336 Ok(())
337 }
338
339 async fn get_project_item_estimates(
346 &self,
347 _issue_node_ids: &[String],
348 ) -> anyhow::Result<std::collections::HashMap<String, u8>> {
349 Ok(std::collections::HashMap::new())
350 }
351
352 #[allow(dead_code)]
358 async fn has_open_issue_with_title(&self, _title: &str, _label: &str) -> anyhow::Result<bool> {
359 Ok(false)
360 }
361
362 async fn health_check(&self) -> anyhow::Result<()>;
364
365 async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
369 Ok(false)
370 }
371
372 async fn batch_is_pr_merged(
378 &self,
379 branches: &[String],
380 ) -> anyhow::Result<std::collections::HashMap<String, bool>> {
381 let mut result = std::collections::HashMap::new();
382 for branch in branches {
383 match self.is_pr_merged(branch).await {
384 Ok(merged) => {
385 result.insert(branch.clone(), merged);
386 }
387 Err(e) => {
388 tracing::warn!(branch, err = %e, "failed to check PR merge status");
389 }
390 }
391 }
392 Ok(result)
393 }
394
395 async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
399 Ok(None)
400 }
401
402 async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
406 Ok(vec![])
407 }
408
409 async fn list_issue_comments(&self, _id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
413 Ok(vec![])
414 }
415
416 async fn acknowledge_issue(&self, _id: &ExternalId) -> anyhow::Result<()> {
421 Ok(())
422 }
423
424 async fn acknowledge_mention(&self, _comment_id: &str) -> anyhow::Result<()> {
429 Ok(())
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use async_trait::async_trait;
437 use std::sync::{Arc, Mutex};
438
439 struct MockBackend {
445 labels: Arc<Mutex<Vec<String>>>,
447 remove_err: Option<String>,
449 set_labels_fail_count: Arc<Mutex<u32>>,
451 }
452
453 impl MockBackend {
454 fn with_labels(labels: Vec<&str>) -> Self {
455 Self {
456 labels: Arc::new(Mutex::new(labels.into_iter().map(String::from).collect())),
457 remove_err: None,
458 set_labels_fail_count: Arc::new(Mutex::new(0)),
459 }
460 }
461
462 fn current_labels(&self) -> Vec<String> {
463 self.labels.lock().unwrap().clone()
464 }
465 }
466
467 #[async_trait]
468 impl ExternalBackend for MockBackend {
469 fn name(&self) -> &str {
470 "mock"
471 }
472
473 async fn create_task(
474 &self,
475 _title: &str,
476 _body: &str,
477 _labels: &[String],
478 ) -> anyhow::Result<ExternalId> {
479 Ok(ExternalId("1".into()))
480 }
481
482 async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
483 Ok(ExternalTask {
484 id: ExternalId("1".into()),
485 title: "mock".into(),
486 body: "".into(),
487 state: "open".into(),
488 labels: self.current_labels(),
489 author: "user".into(),
490 created_at: "2026-01-01T00:00:00Z".into(),
491 updated_at: "2026-01-01T00:00:00Z".into(),
492 url: "https://example.com".into(),
493 })
494 }
495
496 async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
500 Ok(vec![])
501 }
502
503 async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
504 Ok(())
505 }
506
507 async fn set_labels(&self, _id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
508 let mut fail_count = self.set_labels_fail_count.lock().unwrap();
509 if *fail_count > 0 {
510 *fail_count -= 1;
511 return Err(anyhow::anyhow!("transient network error"));
512 }
513 drop(fail_count);
514 let mut current = self.labels.lock().unwrap();
515 for l in labels {
516 if !current.contains(l) {
517 current.push(l.clone());
518 }
519 }
520 Ok(())
521 }
522
523 async fn remove_label(&self, _id: &ExternalId, label: &str) -> anyhow::Result<()> {
524 if let Some(ref err_label) = self.remove_err {
525 if label == err_label {
526 return Err(anyhow::anyhow!("rate limit (429)"));
527 }
528 }
529 self.labels.lock().unwrap().retain(|l| l != label);
530 Ok(())
531 }
532
533 async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
534 Ok(vec![])
535 }
536
537 async fn health_check(&self) -> anyhow::Result<()> {
538 Ok(())
539 }
540 }
541
542 #[tokio::test]
545 async fn update_status_replaces_single_old_label() {
546 let backend = MockBackend::with_labels(vec!["status:new", "priority:high"]);
547 let id = ExternalId("1".into());
548 backend
549 .update_status(&id, Status::InProgress)
550 .await
551 .unwrap();
552 let labels = backend.current_labels();
553 assert!(
554 labels.contains(&"status:in_progress".to_string()),
555 "new label added: {labels:?}"
556 );
557 assert!(
558 !labels.contains(&"status:new".to_string()),
559 "old label removed: {labels:?}"
560 );
561 assert!(
562 labels.contains(&"priority:high".to_string()),
563 "non-status label preserved: {labels:?}"
564 );
565 }
566
567 #[tokio::test]
568 async fn update_status_removes_multiple_old_status_labels() {
569 let backend = MockBackend::with_labels(vec!["status:in_progress", "status:blocked", "bug"]);
571 let id = ExternalId("1".into());
572 backend.update_status(&id, Status::Done).await.unwrap();
573 let labels = backend.current_labels();
574 let status_labels: Vec<_> = labels.iter().filter(|l| l.starts_with("status:")).collect();
575 assert_eq!(
576 status_labels,
577 vec!["status:done"],
578 "exactly one status label: {labels:?}"
579 );
580 }
581
582 #[tokio::test]
583 async fn update_status_is_idempotent() {
584 let backend = MockBackend::with_labels(vec!["status:done"]);
585 let id = ExternalId("1".into());
586 backend.update_status(&id, Status::Done).await.unwrap();
587 let labels = backend.current_labels();
588 let status_count = labels.iter().filter(|l| l.starts_with("status:")).count();
589 assert_eq!(
590 status_count, 1,
591 "idempotent: still exactly one status label: {labels:?}"
592 );
593 }
594
595 #[tokio::test]
596 async fn update_status_non_status_labels_are_preserved() {
597 let backend =
598 MockBackend::with_labels(vec!["status:routed", "agent:claude", "maintenance"]);
599 let id = ExternalId("1".into());
600 backend
601 .update_status(&id, Status::InProgress)
602 .await
603 .unwrap();
604 let labels = backend.current_labels();
605 assert!(
606 labels.contains(&"agent:claude".to_string()),
607 "agent label preserved: {labels:?}"
608 );
609 assert!(
610 labels.contains(&"maintenance".to_string()),
611 "other label preserved: {labels:?}"
612 );
613 }
614
615 #[tokio::test]
616 async fn update_status_retries_on_transient_set_labels_failure() {
617 let backend = MockBackend::with_labels(vec!["status:new"]);
618 *backend.set_labels_fail_count.lock().unwrap() = 1;
620 let id = ExternalId("1".into());
621 backend
622 .update_status(&id, Status::InProgress)
623 .await
624 .unwrap();
625 let labels = backend.current_labels();
626 assert!(
627 labels.contains(&"status:in_progress".to_string()),
628 "label added after retry: {labels:?}"
629 );
630 assert!(
631 !labels.contains(&"status:new".to_string()),
632 "old label removed: {labels:?}"
633 );
634 }
635
636 #[tokio::test]
637 async fn update_status_fails_after_max_retries() {
638 let backend = MockBackend::with_labels(vec!["status:new"]);
639 *backend.set_labels_fail_count.lock().unwrap() = 10;
641 let id = ExternalId("1".into());
642 let result = backend.update_status(&id, Status::Done).await;
643 assert!(result.is_err(), "should fail after max retries");
644 assert!(
645 result
646 .unwrap_err()
647 .to_string()
648 .contains("failed after 3 attempts"),
649 "error message mentions retry exhaustion"
650 );
651 }
652
653 #[test]
654 fn status_as_label() {
655 assert_eq!(Status::New.as_label(), "status:new");
656 assert_eq!(Status::Routed.as_label(), "status:routed");
657 assert_eq!(Status::InProgress.as_label(), "status:in_progress");
658 assert_eq!(Status::Done.as_label(), "status:done");
659 assert_eq!(Status::Blocked.as_label(), "status:blocked");
660 assert_eq!(Status::InReview.as_label(), "status:in_review");
661 assert_eq!(Status::NeedsReview.as_label(), "status:needs_review");
662 }
663
664 #[test]
665 fn status_serializes_snake_case() {
666 let json = serde_json::to_string(&Status::InProgress).unwrap();
667 assert_eq!(json, "\"in_progress\"");
668 }
669
670 #[test]
671 fn status_deserializes_snake_case() {
672 let status: Status = serde_json::from_str("\"needs_review\"").unwrap();
673 assert_eq!(status, Status::NeedsReview);
674 }
675
676 #[test]
677 fn external_id_clone() {
678 let id = ExternalId("42".to_string());
679 let cloned = id.clone();
680 assert_eq!(id.0, cloned.0);
681 }
682
683 #[test]
684 fn external_task_serializes() {
685 let task = ExternalTask {
686 id: ExternalId("1".to_string()),
687 title: "Test".to_string(),
688 body: "Body".to_string(),
689 state: "open".to_string(),
690 labels: vec!["status:new".to_string()],
691 author: "user".to_string(),
692 created_at: "2026-01-01T00:00:00Z".to_string(),
693 updated_at: "2026-01-01T00:00:00Z".to_string(),
694 url: "https://github.com/test/test/issues/1".to_string(),
695 };
696 let json = serde_json::to_string(&task).unwrap();
697 assert!(json.contains("\"title\":\"Test\""));
698 assert!(json.contains("status:new"));
699 }
700}
701
702#[cfg(test)]
703pub(crate) mod test_helpers;