use serde::{Deserialize, Serialize};
use std::process::Command;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubIssue {
pub number: i32,
pub title: String,
pub body: String,
pub state: String, pub html_url: String,
pub created_at: String,
pub updated_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GitHubComment {
pub id: i64,
pub body: String,
pub author: CommentAuthor,
pub created_at: String,
#[serde(rename = "createdAt")]
pub created_at_alt: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommentAuthor {
pub login: String,
}
#[derive(Debug)]
pub enum GitHubError {
CommandFailed { command: String, stderr: String },
NotAuthenticated,
RateLimited,
IssueNotFound { number: i32 },
ParseError { message: String },
IoError(std::io::Error),
}
impl std::fmt::Display for GitHubError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
GitHubError::CommandFailed { command, stderr } => {
write!(f, "Command '{}' failed: {}", command, stderr)
}
GitHubError::NotAuthenticated => {
write!(
f,
"Not authenticated with GitHub. Run 'gh auth login' first."
)
}
GitHubError::RateLimited => {
write!(f, "GitHub API rate limit exceeded. Try again later.")
}
GitHubError::IssueNotFound { number } => {
write!(f, "Issue #{} not found", number)
}
GitHubError::ParseError { message } => {
write!(f, "Failed to parse GitHub response: {}", message)
}
GitHubError::IoError(e) => write!(f, "IO error: {}", e),
}
}
}
impl std::error::Error for GitHubError {}
impl From<std::io::Error> for GitHubError {
fn from(e: std::io::Error) -> Self {
GitHubError::IoError(e)
}
}
pub type Result<T> = std::result::Result<T, GitHubError>;
pub struct GitHubClient {
repo: Option<String>, }
impl GitHubClient {
pub fn new(repo: Option<String>) -> Self {
Self { repo }
}
pub fn auto_detect() -> Result<Self> {
let output = Command::new("gh")
.args([
"repo",
"view",
"--json",
"nameWithOwner",
"-q",
".nameWithOwner",
])
.output()?;
if output.status.success() {
let repo = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !repo.is_empty() {
return Ok(Self { repo: Some(repo) });
}
}
Ok(Self { repo: None })
}
pub fn check_auth() -> Result<bool> {
let output = Command::new("gh").args(["auth", "status"]).output()?;
Ok(output.status.success())
}
fn repo_args(&self) -> Vec<String> {
match &self.repo {
Some(repo) => vec!["-R".to_string(), repo.clone()],
None => vec![],
}
}
pub fn create_issue(&self, title: &str, body: &str, labels: &[&str]) -> Result<GitHubIssue> {
let mut args = vec!["issue", "create", "--title", title, "--body", body];
for label in labels {
args.push("--label");
args.push(label);
}
let mut cmd = Command::new("gh");
cmd.args(&args);
for arg in self.repo_args() {
cmd.arg(&arg);
}
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if stderr.contains("not logged") || stderr.contains("auth") {
return Err(GitHubError::NotAuthenticated);
}
return Err(GitHubError::CommandFailed {
command: "gh issue create".to_string(),
stderr,
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
let url = stdout.trim();
let number: i32 = url
.rsplit('/')
.next()
.and_then(|s| s.parse().ok())
.ok_or_else(|| GitHubError::ParseError {
message: format!("Could not parse issue number from URL: {}", url),
})?;
self.get_issue(number)
}
pub fn get_issue(&self, number: i32) -> Result<GitHubIssue> {
let mut cmd = Command::new("gh");
cmd.args([
"issue",
"view",
&number.to_string(),
"--json",
"number,title,body,state,url,createdAt,updatedAt",
]);
for arg in self.repo_args() {
cmd.arg(&arg);
}
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if stderr.contains("not found") || stderr.contains("Could not resolve") {
return Err(GitHubError::IssueNotFound { number });
}
if stderr.contains("rate limit") {
return Err(GitHubError::RateLimited);
}
return Err(GitHubError::CommandFailed {
command: format!("gh issue view {}", number),
stderr,
});
}
let json_str = String::from_utf8_lossy(&output.stdout);
#[derive(Deserialize)]
struct IssueResponse {
number: i32,
title: String,
body: String,
state: String,
url: String,
#[serde(rename = "createdAt")]
created_at: String,
#[serde(rename = "updatedAt")]
updated_at: String,
}
let resp: IssueResponse =
serde_json::from_str(&json_str).map_err(|e| GitHubError::ParseError {
message: format!("JSON parse error: {} - Raw: {}", e, json_str),
})?;
Ok(GitHubIssue {
number: resp.number,
title: resp.title,
body: resp.body,
state: resp.state.to_lowercase(),
html_url: resp.url,
created_at: resp.created_at,
updated_at: resp.updated_at,
})
}
pub fn update_issue_body(&self, number: i32, body: &str) -> Result<()> {
let mut cmd = Command::new("gh");
cmd.args(["issue", "edit", &number.to_string(), "--body", body]);
for arg in self.repo_args() {
cmd.arg(&arg);
}
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(GitHubError::CommandFailed {
command: format!("gh issue edit {}", number),
stderr,
});
}
Ok(())
}
pub fn update_issue_title(&self, number: i32, title: &str) -> Result<()> {
let mut cmd = Command::new("gh");
cmd.args(["issue", "edit", &number.to_string(), "--title", title]);
for arg in self.repo_args() {
cmd.arg(&arg);
}
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(GitHubError::CommandFailed {
command: format!("gh issue edit {} --title", number),
stderr,
});
}
Ok(())
}
pub fn close_issue(&self, number: i32) -> Result<()> {
let mut cmd = Command::new("gh");
cmd.args(["issue", "close", &number.to_string()]);
for arg in self.repo_args() {
cmd.arg(&arg);
}
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(GitHubError::CommandFailed {
command: format!("gh issue close {}", number),
stderr,
});
}
Ok(())
}
pub fn reopen_issue(&self, number: i32) -> Result<()> {
let mut cmd = Command::new("gh");
cmd.args(["issue", "reopen", &number.to_string()]);
for arg in self.repo_args() {
cmd.arg(&arg);
}
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(GitHubError::CommandFailed {
command: format!("gh issue reopen {}", number),
stderr,
});
}
Ok(())
}
pub fn get_issue_comments(&self, number: i32) -> Result<Vec<GitHubComment>> {
let mut cmd = Command::new("gh");
cmd.args([
"issue",
"view",
&number.to_string(),
"--json",
"comments",
"-q",
".comments",
]);
for arg in self.repo_args() {
cmd.arg(&arg);
}
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(GitHubError::CommandFailed {
command: format!("gh issue view {} --json comments", number),
stderr,
});
}
let json_str = String::from_utf8_lossy(&output.stdout);
if json_str.trim() == "null" || json_str.trim().is_empty() {
return Ok(vec![]);
}
let comments: Vec<GitHubComment> =
serde_json::from_str(&json_str).map_err(|e| GitHubError::ParseError {
message: format!("JSON parse error for comments: {} - Raw: {}", e, json_str),
})?;
Ok(comments)
}
pub fn add_comment(&self, number: i32, body: &str) -> Result<()> {
let mut cmd = Command::new("gh");
cmd.args(["issue", "comment", &number.to_string(), "--body", body]);
for arg in self.repo_args() {
cmd.arg(&arg);
}
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(GitHubError::CommandFailed {
command: format!("gh issue comment {}", number),
stderr,
});
}
Ok(())
}
pub fn list_issues_with_label(&self, label: &str) -> Result<Vec<GitHubIssue>> {
let mut cmd = Command::new("gh");
cmd.args([
"issue",
"list",
"--label",
label,
"--state",
"all",
"--json",
"number,title,body,state,url,createdAt,updatedAt",
]);
for arg in self.repo_args() {
cmd.arg(&arg);
}
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(GitHubError::CommandFailed {
command: format!("gh issue list --label {}", label),
stderr,
});
}
let json_str = String::from_utf8_lossy(&output.stdout);
#[derive(Deserialize)]
struct IssueListItem {
number: i32,
title: String,
body: String,
state: String,
url: String,
#[serde(rename = "createdAt")]
created_at: String,
#[serde(rename = "updatedAt")]
updated_at: String,
}
let items: Vec<IssueListItem> =
serde_json::from_str(&json_str).map_err(|e| GitHubError::ParseError {
message: format!("JSON parse error: {}", e),
})?;
Ok(items
.into_iter()
.map(|item| GitHubIssue {
number: item.number,
title: item.title,
body: item.body,
state: item.state.to_lowercase(),
html_url: item.url,
created_at: item.created_at,
updated_at: item.updated_at,
})
.collect())
}
pub fn find_issue_by_title(&self, title: &str) -> Result<Option<GitHubIssue>> {
let mut cmd = Command::new("gh");
cmd.args([
"issue",
"list",
"--search",
&format!("\"{}\" in:title", title),
"--state",
"all",
"--json",
"number,title,body,state,url,createdAt,updatedAt",
"--limit",
"1",
]);
for arg in self.repo_args() {
cmd.arg(&arg);
}
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(GitHubError::CommandFailed {
command: "gh issue list --search".to_string(),
stderr,
});
}
let json_str = String::from_utf8_lossy(&output.stdout);
#[derive(Deserialize)]
struct IssueListItem {
number: i32,
title: String,
body: String,
state: String,
url: String,
#[serde(rename = "createdAt")]
created_at: String,
#[serde(rename = "updatedAt")]
updated_at: String,
}
let items: Vec<IssueListItem> =
serde_json::from_str(&json_str).map_err(|e| GitHubError::ParseError {
message: format!("JSON parse error: {}", e),
})?;
if items.is_empty() {
return Ok(None);
}
let item = &items[0];
if item.title.to_lowercase() == title.to_lowercase() {
Ok(Some(GitHubIssue {
number: item.number,
title: item.title.clone(),
body: item.body.clone(),
state: item.state.to_lowercase(),
html_url: item.url.clone(),
created_at: item.created_at.clone(),
updated_at: item.updated_at.clone(),
}))
} else {
Ok(None)
}
}
pub fn repo_name(&self) -> Option<&str> {
self.repo.as_deref()
}
pub fn label_exists(&self, name: &str) -> Result<bool> {
let mut cmd = Command::new("gh");
cmd.args([
"label",
"list",
"--search",
name,
"--json",
"name",
"-q",
&format!(".[] | select(.name == \"{}\")", name),
]);
for arg in self.repo_args() {
cmd.arg(&arg);
}
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(GitHubError::CommandFailed {
command: "gh label list".to_string(),
stderr,
});
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(!stdout.trim().is_empty())
}
pub fn create_label(&self, name: &str, description: &str, color: &str) -> Result<()> {
let mut cmd = Command::new("gh");
cmd.args([
"label",
"create",
name,
"--description",
description,
"--color",
color,
"--force", ]);
for arg in self.repo_args() {
cmd.arg(&arg);
}
let output = cmd.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
return Err(GitHubError::CommandFailed {
command: format!("gh label create {}", name),
stderr,
});
}
Ok(())
}
}
pub fn ensure_roadmap_label(client: &GitHubClient) -> Result<bool> {
match client.label_exists("roadmap") {
Ok(true) => Ok(false), Ok(false) => {
client.create_label(
"roadmap",
"Roadmap item synced from ROADMAP.md by deciduous",
"0e8a16", )?;
Ok(true) }
Err(e) => Err(e),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = GitHubClient::new(Some("owner/repo".to_string()));
assert_eq!(client.repo_name(), Some("owner/repo"));
}
#[test]
fn test_client_no_repo() {
let client = GitHubClient::new(None);
assert_eq!(client.repo_name(), None);
}
#[test]
fn test_repo_args() {
let client_with_repo = GitHubClient::new(Some("owner/repo".to_string()));
assert_eq!(client_with_repo.repo_args(), vec!["-R", "owner/repo"]);
let client_without_repo = GitHubClient::new(None);
assert!(client_without_repo.repo_args().is_empty());
}
}