use std::path::Path;
use std::process::Command;
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct GitHubIssue {
pub number: u64,
pub title: String,
pub state: String, #[allow(dead_code)]
pub labels: Vec<String>,
pub url: String,
}
#[derive(Debug)]
pub struct IssueVerification {
#[allow(dead_code)]
pub spec_path: String,
pub valid: Vec<GitHubIssue>,
pub closed: Vec<GitHubIssue>,
pub not_found: Vec<u64>,
pub errors: Vec<String>,
}
pub fn detect_repo(root: &Path) -> Option<String> {
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.current_dir(root)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
parse_repo_from_url(&url)
}
fn parse_repo_from_url(url: &str) -> Option<String> {
if let Some(rest) = url.strip_prefix("git@github.com:") {
let repo = rest.strip_suffix(".git").unwrap_or(rest);
return Some(repo.to_string());
}
if let Some(rest) = url
.strip_prefix("https://github.com/")
.or_else(|| url.strip_prefix("http://github.com/"))
{
let repo = rest.strip_suffix(".git").unwrap_or(rest);
return Some(repo.to_string());
}
None
}
pub fn resolve_repo(config_repo: Option<&str>, root: &Path) -> Result<String, String> {
if let Some(repo) = config_repo {
return Ok(repo.to_string());
}
detect_repo(root).ok_or_else(|| {
"Cannot determine GitHub repo. Set `github.repo` in specsync.json or ensure a git remote is configured.".to_string()
})
}
pub fn gh_is_available() -> bool {
Command::new("gh")
.args(["auth", "status"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn fetch_issue_gh(repo: &str, number: u64) -> Result<GitHubIssue, String> {
let output = Command::new("gh")
.args([
"issue",
"view",
&number.to_string(),
"--repo",
repo,
"--json",
"number,title,state,labels,url",
])
.output()
.map_err(|e| format!("Failed to run gh: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not found") || stderr.contains("Could not resolve") {
return Err(format!("Issue #{number} not found in {repo}"));
}
return Err(format!("gh error: {}", stderr.trim()));
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|e| format!("Failed to parse gh output: {e}"))?;
Ok(GitHubIssue {
number,
title: json["title"].as_str().unwrap_or("").to_string(),
state: json["state"].as_str().unwrap_or("OPEN").to_lowercase(),
labels: json["labels"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|l| l["name"].as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
url: json["url"].as_str().unwrap_or("").to_string(),
})
}
pub fn fetch_issue_api(repo: &str, number: u64) -> Result<GitHubIssue, String> {
let token = std::env::var("GITHUB_TOKEN")
.map_err(|_| "GITHUB_TOKEN not set and gh CLI not available".to_string())?;
let url = format!("https://api.github.com/repos/{repo}/issues/{number}");
let agent = ureq::Agent::new_with_config(
ureq::config::Config::builder()
.timeout_global(Some(Duration::from_secs(10)))
.build(),
);
let mut response = agent
.get(&url)
.header("Authorization", &format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "specsync")
.call()
.map_err(|e| format!("GitHub API request failed: {e}"))?;
if response.status() == 404 {
return Err(format!("Issue #{number} not found in {repo}"));
}
if response.status() != 200 {
return Err(format!("GitHub API returned HTTP {}", response.status()));
}
let body: serde_json::Value = response
.body_mut()
.read_json()
.map_err(|e| format!("Failed to parse GitHub API response: {e}"))?;
Ok(GitHubIssue {
number,
title: body["title"].as_str().unwrap_or("").to_string(),
state: body["state"].as_str().unwrap_or("open").to_string(),
labels: body["labels"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|l| l["name"].as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
url: body["html_url"].as_str().unwrap_or("").to_string(),
})
}
pub fn fetch_issue(repo: &str, number: u64) -> Result<GitHubIssue, String> {
if gh_is_available() {
fetch_issue_gh(repo, number)
} else {
fetch_issue_api(repo, number)
}
}
pub fn verify_spec_issues(
repo: &str,
spec_path: &str,
implements: &[u64],
tracks: &[u64],
) -> IssueVerification {
let mut result = IssueVerification {
spec_path: spec_path.to_string(),
valid: Vec::new(),
closed: Vec::new(),
not_found: Vec::new(),
errors: Vec::new(),
};
let all_issues: Vec<u64> = implements.iter().chain(tracks.iter()).copied().collect();
for number in all_issues {
match fetch_issue(repo, number) {
Ok(issue) => {
if issue.state == "closed" {
result.closed.push(issue);
} else {
result.valid.push(issue);
}
}
Err(e) => {
if e.contains("not found") {
result.not_found.push(number);
} else {
result.errors.push(format!("#{number}: {e}"));
}
}
}
}
result
}
pub fn list_issues(repo: &str, label: Option<&str>) -> Result<Vec<GitHubIssue>, String> {
if gh_is_available() {
list_issues_gh(repo, label)
} else {
list_issues_api(repo, label)
}
}
fn list_issues_gh(repo: &str, label: Option<&str>) -> Result<Vec<GitHubIssue>, String> {
let mut args = vec![
"issue",
"list",
"--repo",
repo,
"--state",
"open",
"--json",
"number,title,state,labels,url",
"--limit",
"500",
];
let label_owned;
if let Some(l) = label {
label_owned = l.to_string();
args.push("--label");
args.push(&label_owned);
}
let output = Command::new("gh")
.args(&args)
.output()
.map_err(|e| format!("Failed to run gh: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("gh error: {}", stderr.trim()));
}
let json: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|e| format!("Failed to parse gh output: {e}"))?;
let issues = json
.as_array()
.ok_or_else(|| "Expected JSON array from gh issue list".to_string())?
.iter()
.map(|i| GitHubIssue {
number: i["number"].as_u64().unwrap_or(0),
title: i["title"].as_str().unwrap_or("").to_string(),
state: i["state"].as_str().unwrap_or("OPEN").to_lowercase(),
labels: i["labels"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|l| l["name"].as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
url: i["url"].as_str().unwrap_or("").to_string(),
})
.collect();
Ok(issues)
}
fn list_issues_api(repo: &str, label: Option<&str>) -> Result<Vec<GitHubIssue>, String> {
let token = std::env::var("GITHUB_TOKEN")
.map_err(|_| "GITHUB_TOKEN not set and gh CLI not available".to_string())?;
let mut url = format!("https://api.github.com/repos/{repo}/issues?state=open&per_page=100");
if let Some(l) = label {
url.push_str(&format!("&labels={}", l));
}
let agent = ureq::Agent::new_with_config(
ureq::config::Config::builder()
.timeout_global(Some(Duration::from_secs(15)))
.build(),
);
let mut response = agent
.get(&url)
.header("Authorization", &format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("User-Agent", "specsync")
.call()
.map_err(|e| format!("GitHub API request failed: {e}"))?;
if response.status() != 200 {
return Err(format!("GitHub API returned HTTP {}", response.status()));
}
let body: serde_json::Value = response
.body_mut()
.read_json()
.map_err(|e| format!("Failed to parse GitHub API response: {e}"))?;
let issues = body
.as_array()
.ok_or_else(|| "Expected JSON array from GitHub API".to_string())?
.iter()
.filter(|i| i["pull_request"].is_null())
.map(|i| GitHubIssue {
number: i["number"].as_u64().unwrap_or(0),
title: i["title"].as_str().unwrap_or("").to_string(),
state: i["state"].as_str().unwrap_or("open").to_string(),
labels: i["labels"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|l| l["name"].as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
url: i["html_url"].as_str().unwrap_or("").to_string(),
})
.collect();
Ok(issues)
}
pub fn create_drift_issue(
repo: &str,
spec_path: &str,
errors: &[String],
labels: &[String],
) -> Result<GitHubIssue, String> {
if !gh_is_available() {
return Err("gh CLI is required to create issues".to_string());
}
let title = format!("Spec drift detected: {spec_path}");
let body = format!(
"## Spec Drift Detected\n\n\
**Spec:** `{spec_path}`\n\n\
### Validation Errors\n\n{}\n\n\
---\n\
*Auto-created by `specsync check --create-issues`*",
errors
.iter()
.map(|e| format!("- {e}"))
.collect::<Vec<_>>()
.join("\n")
);
let mut args = vec![
"issue", "create", "--repo", repo, "--title", &title, "--body", &body,
];
let label_str = labels.join(",");
if !labels.is_empty() {
args.push("--label");
args.push(&label_str);
}
let output = Command::new("gh")
.args(&args)
.output()
.map_err(|e| format!("Failed to run gh: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to create issue: {}", stderr.trim()));
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let number = url
.rsplit('/')
.next()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);
Ok(GitHubIssue {
number,
title,
state: "open".to_string(),
labels: labels.to_vec(),
url,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_repo_from_url_https() {
assert_eq!(
parse_repo_from_url("https://github.com/CorvidLabs/spec-sync.git"),
Some("CorvidLabs/spec-sync".to_string())
);
assert_eq!(
parse_repo_from_url("https://github.com/CorvidLabs/spec-sync"),
Some("CorvidLabs/spec-sync".to_string())
);
}
#[test]
fn test_parse_repo_from_url_ssh() {
assert_eq!(
parse_repo_from_url("git@github.com:CorvidLabs/spec-sync.git"),
Some("CorvidLabs/spec-sync".to_string())
);
}
#[test]
fn test_parse_repo_from_url_unknown() {
assert_eq!(parse_repo_from_url("https://gitlab.com/foo/bar.git"), None);
}
}