ccswarm 0.9.1

AI Agent Workflow DevOps toolchain complementing Claude Code Agent Teams
Documentation
pub mod github;
pub mod linear;

use anyhow::Result;
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct NormalizedIssue {
    pub id: String,
    pub identifier: String,
    pub title: String,
    #[serde(default)]
    pub description: Option<String>,
    pub state: String,
    #[serde(default)]
    pub labels: Vec<String>,
    #[serde(default)]
    pub blocked_by: Vec<BlockedBy>,
    pub url: String,
    #[serde(default)]
    pub priority: Option<u8>,
    #[serde(default)]
    pub updated_at_ms: Option<u64>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct BlockedBy {
    pub identifier: String,
    pub state: String,
}

#[async_trait::async_trait]
pub trait TrackerAdapter: Send + Sync {
    fn name(&self) -> &'static str;
    async fn fetch_issue(&self, identifier: &str) -> Result<NormalizedIssue>;
    async fn list_active(&self, limit: usize) -> Result<Vec<NormalizedIssue>>;
}

pub fn resolve_tracker(name: &str) -> Result<Box<dyn TrackerAdapter>> {
    match name {
        "github" => Ok(Box::new(github::GitHubAdapter)),
        "linear" => Ok(Box::new(linear::LinearAdapter)),
        other => Err(anyhow::anyhow!(
            "unknown tracker: {} (supported: github, linear)",
            other
        )),
    }
}

pub fn default_tracker_name() -> String {
    std::env::var("CCSWARM_TRACKER").unwrap_or_else(|_| "github".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_normalized_issue_round_trip() -> Result<()> {
        let issue = NormalizedIssue {
            id: "issue-id".to_string(),
            identifier: "ENG-123".to_string(),
            title: "Add tracker normalization".to_string(),
            description: Some("Normalize issue data across trackers.".to_string()),
            state: "OPEN".to_string(),
            labels: vec!["backend".to_string(), "tracker".to_string()],
            blocked_by: vec![BlockedBy {
                identifier: "ENG-100".to_string(),
                state: "OPEN".to_string(),
            }],
            url: "https://linear.app/example/issue/ENG-123".to_string(),
            priority: Some(2),
            updated_at_ms: Some(1_700_000_000_000),
        };

        let serialized = serde_json::to_string(&issue)?;
        let round_tripped: NormalizedIssue = serde_json::from_str(&serialized)?;

        assert_eq!(round_tripped, issue);
        Ok(())
    }

    #[test]
    fn test_resolve_tracker_default_is_github() -> Result<()> {
        let tracker = resolve_tracker("github")?;

        assert_eq!(tracker.name(), "github");
        Ok(())
    }

    #[test]
    fn test_resolve_tracker_unknown_returns_error() {
        let err = match resolve_tracker("unknown") {
            Ok(tracker) => panic!("unknown tracker should fail, got {}", tracker.name()),
            Err(err) => err,
        };

        assert_eq!(
            err.to_string(),
            "unknown tracker: unknown (supported: github, linear)"
        );
    }

    #[tokio::test]
    async fn test_linear_adapter_returns_not_implemented() {
        let tracker = linear::LinearAdapter;
        let err = tracker
            .fetch_issue("ENG-123")
            .await
            .expect_err("linear fetch should not be implemented yet");

        assert_eq!(
            err.to_string(),
            "LinearAdapter not yet implemented; set CCSWARM_TRACKER=github"
        );
    }
}