pub mod github;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalId(pub String);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Mention {
pub id: String,
pub body: String,
pub author: String,
pub created_at: String,
pub issue_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExternalTask {
pub id: ExternalId,
pub title: String,
pub body: String,
pub state: String,
pub labels: Vec<String>,
pub author: String,
pub created_at: String,
pub updated_at: String,
pub url: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Status {
New,
Routed,
InProgress,
Done,
Blocked,
InReview,
NeedsReview,
Waiting,
}
impl Status {
pub fn as_label(&self) -> &'static str {
match self {
Self::New => "status:new",
Self::Routed => "status:routed",
Self::InProgress => "status:in_progress",
Self::Done => "status:done",
Self::Blocked => "status:blocked",
Self::InReview => "status:in_review",
Self::NeedsReview => "status:needs_review",
Self::Waiting => "status:waiting",
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::New => "new",
Self::Routed => "routed",
Self::InProgress => "in_progress",
Self::Done => "done",
Self::Blocked => "blocked",
Self::InReview => "in_review",
Self::NeedsReview => "needs_review",
Self::Waiting => "waiting",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlockedByIssue {
pub github_id: i64,
pub repo: String,
pub number: i64,
pub state: String,
pub state_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PullRequestCompletion {
pub merged: bool,
pub base_ref: String,
pub default_branch: String,
pub approved: bool,
pub closed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BlockedByFetch {
Native(Vec<BlockedByIssue>),
Unavailable,
Failed(String),
}
#[allow(clippy::double_must_use)] #[async_trait]
pub trait ExternalBackend: Send + Sync {
fn name(&self) -> &str;
async fn create_task(
&self,
title: &str,
body: &str,
labels: &[String],
) -> anyhow::Result<ExternalId>;
async fn get_task(&self, id: &ExternalId) -> anyhow::Result<ExternalTask>;
async fn update_status(&self, id: &ExternalId, status: Status) -> anyhow::Result<()> {
let label = status.as_label();
if let Err(e) = self.ensure_status_label(label).await {
tracing::warn!(label, err = %e, "ensure_status_label failed, continuing");
}
const MAX_RETRIES: u32 = 3;
let mut last_err =
anyhow::anyhow!("update_status({label}) failed: all {MAX_RETRIES} attempts exhausted");
for attempt in 0..MAX_RETRIES {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_millis(500 * u64::from(attempt)))
.await;
tracing::debug!(attempt, label, "retrying update_status");
}
let task = match self.get_task(id).await {
Ok(t) => t,
Err(e) => {
last_err = e;
continue;
}
};
let mut remove_failed = false;
for old in task
.labels
.iter()
.filter(|l| l.starts_with("status:") && l.as_str() != label)
{
if let Err(e) = self.remove_label(id, old).await {
tracing::warn!(old_label = %old, err = %e, attempt, "remove_label failed");
last_err = e;
remove_failed = true;
break;
}
}
if remove_failed {
continue;
}
match self.set_labels(id, &[label.to_string()]).await {
Ok(()) => return Ok(()),
Err(e) => {
tracing::warn!(label, err = %e, attempt, "set_labels failed");
last_err = e;
}
}
}
Err(last_err.context(format!(
"update_status({label}) failed after {MAX_RETRIES} attempts"
)))
}
async fn list_by_status(&self, status: Status) -> anyhow::Result<Vec<ExternalTask>>;
async fn list_all_tasks(&self) -> anyhow::Result<Vec<ExternalTask>> {
let statuses = [
Status::New,
Status::Routed,
Status::InProgress,
Status::Done,
Status::Blocked,
Status::InReview,
Status::NeedsReview,
Status::Waiting,
];
let mut all_tasks = Vec::new();
for status in statuses {
let tasks = self.list_by_status(status).await?;
all_tasks.extend(tasks);
}
Ok(all_tasks)
}
async fn list_reconciliation_candidates(&self) -> anyhow::Result<Vec<ExternalTask>> {
Ok(Vec::new())
}
async fn list_routable(&self) -> anyhow::Result<Vec<ExternalTask>> {
self.list_by_status(Status::New).await
}
async fn list_active_open_issues(
&self,
_since: Option<&str>,
) -> anyhow::Result<Vec<(ExternalTask, Option<Status>)>> {
let mut seen = std::collections::HashSet::new();
let mut result = Vec::new();
if let Ok(routable) = self.list_routable().await {
for task in routable {
if seen.insert(task.id.0.clone()) {
result.push((task, None));
}
}
}
let active_non_new = [
Status::Routed,
Status::InProgress,
Status::NeedsReview,
Status::InReview,
Status::Blocked,
Status::Waiting,
];
for status in active_non_new {
if let Ok(tasks) = self.list_by_status(status).await {
for task in tasks {
if seen.insert(task.id.0.clone()) {
result.push((task, Some(status)));
}
}
}
}
Ok(result)
}
async fn post_comment(&self, id: &ExternalId, body: &str) -> anyhow::Result<()>;
async fn set_labels(&self, id: &ExternalId, labels: &[String]) -> anyhow::Result<()>;
async fn remove_label(&self, id: &ExternalId, label: &str) -> anyhow::Result<()>;
async fn get_sub_issues(&self, id: &ExternalId) -> anyhow::Result<Vec<ExternalId>>;
async fn create_sub_task(
&self,
parent_id: &ExternalId,
title: &str,
body: &str,
labels: &[String],
) -> anyhow::Result<ExternalId> {
let mut all_labels = labels.to_vec();
all_labels.push(format!("parent:{}", parent_id.0));
self.create_task(title, body, &all_labels).await
}
async fn ensure_status_label(&self, _label: &str) -> anyhow::Result<()> {
Ok(())
}
async fn sync_to_project(
&self,
_id: &ExternalId,
_status: Status,
) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn reconcile_project_statuses(&self) -> anyhow::Result<()> {
Ok(())
}
async fn sync_estimate_to_project(
&self,
_id: &ExternalId,
_estimate: u8,
) -> anyhow::Result<()> {
Ok(())
}
async fn get_project_item_estimates(
&self,
_issue_node_ids: &[String],
) -> anyhow::Result<std::collections::HashMap<String, u8>> {
Ok(std::collections::HashMap::new())
}
#[allow(dead_code)]
async fn has_open_issue_with_title(&self, _title: &str, _label: &str) -> anyhow::Result<bool> {
Ok(false)
}
async fn health_check(&self) -> anyhow::Result<()>;
async fn is_pr_merged(&self, _branch: &str) -> anyhow::Result<bool> {
Ok(false)
}
async fn batch_is_pr_merged(
&self,
branches: &[String],
) -> anyhow::Result<std::collections::HashMap<String, bool>> {
let mut result = std::collections::HashMap::new();
for branch in branches {
match self.is_pr_merged(branch).await {
Ok(merged) => {
result.insert(branch.clone(), merged);
}
Err(e) => {
tracing::warn!(branch, err = %e, "failed to check PR merge status");
}
}
}
Ok(result)
}
async fn get_authenticated_user(&self) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn get_mentions(&self, _since: &str) -> anyhow::Result<Vec<Mention>> {
Ok(vec![])
}
async fn list_issue_comments(&self, _id: &ExternalId) -> anyhow::Result<Vec<Mention>> {
Ok(vec![])
}
async fn acknowledge_issue(&self, _id: &ExternalId) -> anyhow::Result<()> {
Ok(())
}
async fn acknowledge_mention(&self, _comment_id: &str) -> anyhow::Result<()> {
Ok(())
}
async fn blocked_by(&self, _repo: &str, _issue_number: i64) -> anyhow::Result<BlockedByFetch> {
Ok(BlockedByFetch::Unavailable)
}
async fn issue_close_state(
&self,
_repo: &str,
_issue_number: i64,
) -> anyhow::Result<Option<(String, Option<String>)>> {
Ok(None)
}
async fn post_comment_in_repo(
&self,
_repo: &str,
id: &ExternalId,
body: &str,
) -> anyhow::Result<()> {
self.post_comment(id, body).await
}
async fn pull_request_completion(
&self,
_repo: &str,
_pr_number: i64,
) -> anyhow::Result<Option<PullRequestCompletion>> {
Ok(None)
}
async fn add_dependency(
&self,
_issue_number: i64,
_blocking_repo: &str,
_blocking_number: i64,
) -> anyhow::Result<()> {
anyhow::bail!("this backend cannot write issue dependencies")
}
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::sync::{Arc, Mutex};
struct MockBackend {
labels: Arc<Mutex<Vec<String>>>,
remove_err: Option<String>,
set_labels_fail_count: Arc<Mutex<u32>>,
}
impl MockBackend {
fn with_labels(labels: Vec<&str>) -> Self {
Self {
labels: Arc::new(Mutex::new(labels.into_iter().map(String::from).collect())),
remove_err: None,
set_labels_fail_count: Arc::new(Mutex::new(0)),
}
}
fn current_labels(&self) -> Vec<String> {
self.labels.lock().unwrap().clone()
}
}
#[async_trait]
impl ExternalBackend for MockBackend {
fn name(&self) -> &str {
"mock"
}
async fn create_task(
&self,
_title: &str,
_body: &str,
_labels: &[String],
) -> anyhow::Result<ExternalId> {
Ok(ExternalId("1".into()))
}
async fn get_task(&self, _id: &ExternalId) -> anyhow::Result<ExternalTask> {
Ok(ExternalTask {
id: ExternalId("1".into()),
title: "mock".into(),
body: "".into(),
state: "open".into(),
labels: self.current_labels(),
author: "user".into(),
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
url: "https://example.com".into(),
})
}
async fn list_by_status(&self, _status: Status) -> anyhow::Result<Vec<ExternalTask>> {
Ok(vec![])
}
async fn post_comment(&self, _id: &ExternalId, _body: &str) -> anyhow::Result<()> {
Ok(())
}
async fn set_labels(&self, _id: &ExternalId, labels: &[String]) -> anyhow::Result<()> {
let mut fail_count = self.set_labels_fail_count.lock().unwrap();
if *fail_count > 0 {
*fail_count -= 1;
return Err(anyhow::anyhow!("transient network error"));
}
drop(fail_count);
let mut current = self.labels.lock().unwrap();
for l in labels {
if !current.contains(l) {
current.push(l.clone());
}
}
Ok(())
}
async fn remove_label(&self, _id: &ExternalId, label: &str) -> anyhow::Result<()> {
if let Some(ref err_label) = self.remove_err {
if label == err_label {
return Err(anyhow::anyhow!("rate limit (429)"));
}
}
self.labels.lock().unwrap().retain(|l| l != label);
Ok(())
}
async fn get_sub_issues(&self, _id: &ExternalId) -> anyhow::Result<Vec<ExternalId>> {
Ok(vec![])
}
async fn health_check(&self) -> anyhow::Result<()> {
Ok(())
}
}
#[tokio::test]
async fn update_status_replaces_single_old_label() {
let backend = MockBackend::with_labels(vec!["status:new", "priority:high"]);
let id = ExternalId("1".into());
backend
.update_status(&id, Status::InProgress)
.await
.unwrap();
let labels = backend.current_labels();
assert!(
labels.contains(&"status:in_progress".to_string()),
"new label added: {labels:?}"
);
assert!(
!labels.contains(&"status:new".to_string()),
"old label removed: {labels:?}"
);
assert!(
labels.contains(&"priority:high".to_string()),
"non-status label preserved: {labels:?}"
);
}
#[tokio::test]
async fn update_status_removes_multiple_old_status_labels() {
let backend = MockBackend::with_labels(vec!["status:in_progress", "status:blocked", "bug"]);
let id = ExternalId("1".into());
backend.update_status(&id, Status::Done).await.unwrap();
let labels = backend.current_labels();
let status_labels: Vec<_> = labels.iter().filter(|l| l.starts_with("status:")).collect();
assert_eq!(
status_labels,
vec!["status:done"],
"exactly one status label: {labels:?}"
);
}
#[tokio::test]
async fn update_status_is_idempotent() {
let backend = MockBackend::with_labels(vec!["status:done"]);
let id = ExternalId("1".into());
backend.update_status(&id, Status::Done).await.unwrap();
let labels = backend.current_labels();
let status_count = labels.iter().filter(|l| l.starts_with("status:")).count();
assert_eq!(
status_count, 1,
"idempotent: still exactly one status label: {labels:?}"
);
}
#[tokio::test]
async fn update_status_non_status_labels_are_preserved() {
let backend =
MockBackend::with_labels(vec!["status:routed", "agent:claude", "maintenance"]);
let id = ExternalId("1".into());
backend
.update_status(&id, Status::InProgress)
.await
.unwrap();
let labels = backend.current_labels();
assert!(
labels.contains(&"agent:claude".to_string()),
"agent label preserved: {labels:?}"
);
assert!(
labels.contains(&"maintenance".to_string()),
"other label preserved: {labels:?}"
);
}
#[tokio::test]
async fn lifecycle_transition_preserves_board_label_and_no_agent_hold() {
let backend = MockBackend::with_labels(vec!["status:new", "board:held", "no-agent"]);
backend
.update_status(&ExternalId("1".into()), Status::Routed)
.await
.unwrap();
let mut labels = backend.current_labels();
labels.sort();
assert_eq!(labels, vec!["board:held", "no-agent", "status:routed"]);
}
#[tokio::test]
async fn update_status_retries_on_transient_set_labels_failure() {
let backend = MockBackend::with_labels(vec!["status:new"]);
*backend.set_labels_fail_count.lock().unwrap() = 1;
let id = ExternalId("1".into());
backend
.update_status(&id, Status::InProgress)
.await
.unwrap();
let labels = backend.current_labels();
assert!(
labels.contains(&"status:in_progress".to_string()),
"label added after retry: {labels:?}"
);
assert!(
!labels.contains(&"status:new".to_string()),
"old label removed: {labels:?}"
);
}
#[tokio::test]
async fn update_status_fails_after_max_retries() {
let backend = MockBackend::with_labels(vec!["status:new"]);
*backend.set_labels_fail_count.lock().unwrap() = 10;
let id = ExternalId("1".into());
let result = backend.update_status(&id, Status::Done).await;
assert!(result.is_err(), "should fail after max retries");
assert!(
result
.unwrap_err()
.to_string()
.contains("failed after 3 attempts"),
"error message mentions retry exhaustion"
);
}
#[test]
fn status_as_label() {
assert_eq!(Status::New.as_label(), "status:new");
assert_eq!(Status::Routed.as_label(), "status:routed");
assert_eq!(Status::InProgress.as_label(), "status:in_progress");
assert_eq!(Status::Done.as_label(), "status:done");
assert_eq!(Status::Blocked.as_label(), "status:blocked");
assert_eq!(Status::InReview.as_label(), "status:in_review");
assert_eq!(Status::NeedsReview.as_label(), "status:needs_review");
}
#[test]
fn status_serializes_snake_case() {
let json = serde_json::to_string(&Status::InProgress).unwrap();
assert_eq!(json, "\"in_progress\"");
}
#[test]
fn status_deserializes_snake_case() {
let status: Status = serde_json::from_str("\"needs_review\"").unwrap();
assert_eq!(status, Status::NeedsReview);
}
#[test]
fn external_id_clone() {
let id = ExternalId("42".to_string());
let cloned = id.clone();
assert_eq!(id.0, cloned.0);
}
#[test]
fn external_task_serializes() {
let task = ExternalTask {
id: ExternalId("1".to_string()),
title: "Test".to_string(),
body: "Body".to_string(),
state: "open".to_string(),
labels: vec!["status:new".to_string()],
author: "user".to_string(),
created_at: "2026-01-01T00:00:00Z".to_string(),
updated_at: "2026-01-01T00:00:00Z".to_string(),
url: "https://github.com/test/test/issues/1".to_string(),
};
let json = serde_json::to_string(&task).unwrap();
assert!(json.contains("\"title\":\"Test\""));
assert!(json.contains("status:new"));
}
}
#[cfg(test)]
pub(crate) mod test_helpers;