tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! GitHub Issue types and fetching.

use serde::Deserialize;

use crate::error::GitHubError;

/// GitHub Issue representation
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubIssue {
    /// Issue number
    pub number: u32,
    /// Issue title
    pub title: String,
    /// Issue body (may be empty)
    #[serde(default)]
    pub body: String,
    /// Labels attached to the issue
    #[serde(default)]
    pub labels: Vec<GitHubLabel>,
    /// Issue state (open/closed)
    #[serde(default)]
    pub state: IssueState,
}

/// GitHub label
#[derive(Debug, Clone, Deserialize)]
pub struct GitHubLabel {
    /// Label name
    pub name: String,
}

/// Issue state
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "UPPERCASE")]
pub enum IssueState {
    /// Open issue
    #[default]
    Open,
    /// Closed issue
    Closed,
}

impl GitHubIssue {
    /// Returns label names as a vector of strings
    #[must_use]
    pub fn label_names(&self) -> Vec<&str> {
        self.labels.iter().map(|l| l.name.as_str()).collect()
    }

    /// Formats labels for display (e.g., "[bug, enhancement]")
    #[must_use]
    pub fn labels_display(&self) -> String {
        if self.labels.is_empty() {
            String::new()
        } else {
            let names: Vec<_> = self.labels.iter().map(|l| l.name.as_str()).collect();
            format!("[{}]", names.join(", "))
        }
    }
}

/// Fetches open issues from GitHub using `gh` CLI.
///
/// # Errors
///
/// Returns error if `gh` command fails or output parsing fails.
pub async fn fetch_issue_list() -> Result<Vec<GitHubIssue>, GitHubError> {
    super::run_command(
        "gh",
        &[
            "issue",
            "list",
            "--state",
            "open",
            "--json",
            "number,title,labels,state",
            "--limit",
            "50",
        ],
        "gh issue list",
    )
    .await
}

/// Fetches a single issue with full details.
///
/// # Errors
///
/// Returns error if `gh` command fails or output parsing fails.
pub async fn fetch_issue(number: u32) -> Result<GitHubIssue, GitHubError> {
    let num_str = number.to_string();
    super::run_command(
        "gh",
        &[
            "issue",
            "view",
            &num_str,
            "--json",
            "number,title,body,labels,state",
        ],
        &format!("gh issue view {number}"),
    )
    .await
}

#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
    use super::*;
    use rstest::rstest;

    #[test]
    fn github_issue_deserialize() {
        let json = r#"{
            "number": 22,
            "title": "GitHub Issue Integration",
            "body": "Add issue integration",
            "labels": [{"name": "enhancement"}],
            "state": "OPEN"
        }"#;

        let issue: GitHubIssue = serde_json::from_str(json).unwrap();
        assert_eq!(issue.number, 22);
        assert_eq!(issue.title, "GitHub Issue Integration");
        assert_eq!(issue.labels.len(), 1);
        assert_eq!(issue.labels[0].name, "enhancement");
        assert_eq!(issue.state, IssueState::Open);
    }

    #[test]
    fn github_issue_label_methods() {
        let issue = GitHubIssue {
            number: 1,
            title: "Test".to_string(),
            body: String::new(),
            labels: vec![
                GitHubLabel {
                    name: "bug".to_string(),
                },
                GitHubLabel {
                    name: "enhancement".to_string(),
                },
            ],
            state: IssueState::Open,
        };

        assert_eq!(issue.labels_display(), "[bug, enhancement]");
        assert_eq!(issue.label_names(), vec!["bug", "enhancement"]);
    }

    #[test]
    fn github_issue_empty_labels_display() {
        let json = r#"{"number": 1, "title": "Test", "labels": []}"#;
        let issue: GitHubIssue = serde_json::from_str(json).unwrap();
        assert!(issue.labels.is_empty());
        assert_eq!(issue.labels_display(), "");
    }

    #[rstest]
    #[case(IssueState::Open, "OPEN")]
    #[case(IssueState::Closed, "CLOSED")]
    fn issue_state_deserialize(#[case] expected: IssueState, #[case] json_val: &str) {
        let json = format!(r#"{{"number": 1, "title": "T", "state": "{json_val}"}}"#);
        let issue: GitHubIssue = serde_json::from_str(&json).unwrap();
        assert_eq!(issue.state, expected);
    }

    #[test]
    fn issue_state_default_is_open() {
        assert_eq!(IssueState::default(), IssueState::Open);
    }

    #[test]
    fn github_issue_optional_fields_default() {
        // All optional fields (body, labels, state) default correctly
        let json = r#"{"number": 1, "title": "Test"}"#;
        let issue: GitHubIssue = serde_json::from_str(json).unwrap();
        assert_eq!(issue.body, "");
        assert!(issue.labels.is_empty());
        assert_eq!(issue.state, IssueState::Open);
    }
}