sara-tasks 2.1.0

Sara — folder-aware task manager
use anyhow::{Context, Result};
use chrono::Utc;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
pub(super) struct GhUser {
    pub(super) login: String,
}

#[derive(Debug, Deserialize)]
pub(super) struct GhIssueAssignee {
    pub(super) login: String,
}

#[derive(Debug, Deserialize)]
pub(super) struct GhIssue {
    pub(super) id: i64,
    pub(super) node_id: Option<String>,
    pub(super) number: i64,
    pub(super) title: String,
    pub(super) body: Option<String>,
    pub(super) html_url: String,
    pub(super) state: String,
    pub(super) updated_at: chrono::DateTime<Utc>,
    pub(super) user: GhUser,
    #[serde(default)]
    pub(super) assignees: Vec<GhIssueAssignee>,
    pub(super) pull_request: Option<serde_json::Value>,
}

#[derive(Debug, Deserialize)]
pub(super) struct GhComment {
    pub(super) id: i64,
    pub(super) body: Option<String>,
    pub(super) html_url: String,
    pub(super) created_at: chrono::DateTime<Utc>,
    pub(super) updated_at: chrono::DateTime<Utc>,
    pub(super) user: GhUser,
}

pub(super) fn gh_auth_token() -> Option<String> {
    let out = std::process::Command::new("gh")
        .args(["auth", "token"])
        .output()
        .ok()?;
    if !out.status.success() {
        return None;
    }
    let token = String::from_utf8_lossy(&out.stdout).trim().to_string();
    if token.is_empty() { None } else { Some(token) }
}

pub(super) fn github_login(token: &str) -> Result<String> {
    let out = std::process::Command::new("gh")
        .env("GH_TOKEN", token)
        .args(["api", "/user", "--jq", ".login"])
        .output()
        .context("failed to run 'gh api /user' — is 'gh' installed and authenticated?")?;

    if out.status.success() {
        let login = String::from_utf8_lossy(&out.stdout).trim().to_string();
        if !login.is_empty() {
            return Ok(login);
        }
    }

    let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
    anyhow::bail!("Could not resolve GitHub login: {stderr}. Run 'gh auth login' first.")
}

pub(super) fn fetch_assigned_issues(
    token: &str,
    owner: &str,
    repo: &str,
    login: &str,
) -> Result<Vec<GhIssue>> {
    let endpoint = format!("/repos/{owner}/{repo}/issues?state=open&assignee={login}&per_page=100");

    let out = std::process::Command::new("gh")
        .env("GH_TOKEN", token)
        .args(["api", "--paginate", &endpoint])
        .output()
        .with_context(|| format!("failed to call gh api for {owner}/{repo}"))?;

    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
        anyhow::bail!("GitHub API call failed: {stderr}");
    }

    let stdout = String::from_utf8_lossy(&out.stdout);
    let mut issues: Vec<GhIssue> = Vec::new();
    for line in stdout.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let batch: Vec<GhIssue> =
            serde_json::from_str(line).with_context(|| "failed to parse GitHub API response")?;
        issues.extend(batch);
    }

    Ok(issues
        .into_iter()
        .filter(|i| i.pull_request.is_none())
        .collect())
}

pub(super) fn fetch_issue_comments(
    token: &str,
    owner: &str,
    repo: &str,
    issue_number: i64,
) -> Result<Vec<GhComment>> {
    let endpoint = format!("/repos/{owner}/{repo}/issues/{issue_number}/comments?per_page=100");

    let out = std::process::Command::new("gh")
        .env("GH_TOKEN", token)
        .args(["api", "--paginate", &endpoint])
        .output()
        .with_context(|| {
            format!("failed to call gh api for comments on {owner}/{repo}#{issue_number}")
        })?;

    if !out.status.success() {
        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();
        anyhow::bail!("GitHub API call failed for comments: {stderr}");
    }

    let stdout = String::from_utf8_lossy(&out.stdout);
    let mut comments: Vec<GhComment> = Vec::new();
    for line in stdout.lines() {
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        let batch: Vec<GhComment> = serde_json::from_str(line)
            .with_context(|| "failed to parse GitHub comment API response")?;
        comments.extend(batch);
    }
    Ok(comments)
}

#[cfg(test)]
#[path = "../../../tests/unit/commands/sync/github.rs"]
mod tests;