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;