use anyhow::{bail, Context, Result};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct PullRequest {
pub number: u64,
pub title: String,
pub head_branch: String,
pub author: String,
}
#[derive(Debug)]
pub struct PrCheckResult {
pub pr: PullRequest,
pub has_conflicts: bool,
pub conflicted_files: Vec<String>,
}
#[derive(Debug)]
#[allow(dead_code)]
pub enum PrSource {
GhCli,
GitHubApi,
}
pub fn gh_cli_available() -> bool {
Command::new("gh")
.args(["auth", "status"])
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn github_token() -> Option<String> {
std::env::var("GITHUB_TOKEN")
.or_else(|_| std::env::var("GH_TOKEN"))
.ok()
.filter(|t| !t.is_empty())
}
pub fn get_repo_slug() -> Result<String> {
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.output()
.context("Failed to get origin URL")?;
if !output.status.success() {
bail!("No 'origin' remote found");
}
let url = String::from_utf8(output.stdout)
.context("Invalid UTF-8 in remote URL")?
.trim()
.to_string();
parse_repo_slug(&url)
}
fn parse_repo_slug(url: &str) -> Result<String> {
let slug = if url.starts_with("git@") {
url.split(':')
.nth(1)
.map(|s| s.trim_end_matches(".git").trim_end_matches('/'))
.map(|s| s.to_string())
} else if url.contains("github.com") {
let parts: Vec<&str> = url
.trim_end_matches(".git")
.trim_end_matches('/')
.split('/')
.collect();
if parts.len() >= 2 {
let repo = parts[parts.len() - 1];
let owner = parts[parts.len() - 2];
Some(format!("{}/{}", owner, repo))
} else {
None
}
} else {
None
};
slug.context("Could not parse GitHub repository from origin URL")
}
pub fn list_prs_gh_cli(base_branch: &str) -> Result<Vec<PullRequest>> {
let output = Command::new("gh")
.args([
"pr",
"list",
"--base",
base_branch,
"--state",
"open",
"--json",
"number,title,headRefName,author",
"--limit",
"50",
])
.output()
.context("Failed to run gh pr list")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("rate limit") || stderr.contains("API rate") {
bail!("GitHub API rate limit reached. Try again later.");
}
if stderr.contains("auth") || stderr.contains("login") {
bail!("GitHub authentication failed. Run 'gh auth login' to authenticate.");
}
bail!("gh pr list failed: {}", stderr);
}
let stdout = String::from_utf8(output.stdout).context("Invalid UTF-8 in gh output")?;
parse_gh_json(&stdout)
}
pub fn list_prs_api(base_branch: &str, token: &str, repo_slug: &str) -> Result<Vec<PullRequest>> {
let url = format!(
"https://api.github.com/repos/{}/pulls?base={}&state=open&per_page=50",
repo_slug, base_branch
);
let output = Command::new("curl")
.args([
"-s",
"-H",
&format!("Authorization: Bearer {}", token),
"-H",
"Accept: application/vnd.github+json",
"-H",
"X-GitHub-Api-Version: 2022-11-28",
&url,
])
.output()
.context("Failed to call GitHub API via curl")?;
if !output.status.success() {
bail!("curl request failed");
}
let body = String::from_utf8(output.stdout).context("Invalid UTF-8 in API response")?;
if body.contains("\"message\"") && body.contains("rate limit") {
bail!("GitHub API rate limit reached. Try again later.");
}
if body.contains("\"message\"")
&& (body.contains("Bad credentials") || body.contains("Unauthorized"))
{
bail!("GitHub API authentication failed. Check your GITHUB_TOKEN.");
}
if body.contains("\"message\"") && body.contains("Not Found") {
bail!("Repository not found. Check that the repo is correct and your token has access.");
}
parse_api_json(&body)
}
fn parse_gh_json(json: &str) -> Result<Vec<PullRequest>> {
let trimmed = json.trim();
if trimmed == "[]" || trimmed.is_empty() {
return Ok(Vec::new());
}
let mut prs = Vec::new();
for obj in split_json_array(trimmed) {
let number = extract_json_number(&obj, "number").unwrap_or(0);
let title = extract_json_string(&obj, "title").unwrap_or_default();
let head_branch = extract_json_string(&obj, "headRefName").unwrap_or_default();
let author = extract_nested_json_string(&obj, "author", "login").unwrap_or_default();
if number > 0 && !head_branch.is_empty() {
prs.push(PullRequest {
number,
title,
head_branch,
author,
});
}
}
Ok(prs)
}
fn parse_api_json(json: &str) -> Result<Vec<PullRequest>> {
let trimmed = json.trim();
if trimmed == "[]" || trimmed.is_empty() {
return Ok(Vec::new());
}
if trimmed.starts_with('{') {
bail!("GitHub API returned an error response");
}
let mut prs = Vec::new();
for obj in split_json_array(trimmed) {
let number = extract_json_number(&obj, "number").unwrap_or(0);
let title = extract_json_string(&obj, "title").unwrap_or_default();
let head_branch = extract_nested_json_string(&obj, "head", "ref").unwrap_or_default();
let author = extract_nested_json_string(&obj, "user", "login").unwrap_or_default();
if number > 0 && !head_branch.is_empty() {
prs.push(PullRequest {
number,
title,
head_branch,
author,
});
}
}
Ok(prs)
}
fn split_json_array(json: &str) -> Vec<String> {
let mut objects = Vec::new();
let mut depth = 0;
let mut start = None;
let mut in_string = false;
let mut escape_next = false;
for (i, ch) in json.char_indices() {
if escape_next {
escape_next = false;
continue;
}
if ch == '\\' && in_string {
escape_next = true;
continue;
}
if ch == '"' {
in_string = !in_string;
continue;
}
if in_string {
continue;
}
match ch {
'{' => {
if depth == 0 {
start = Some(i);
}
depth += 1;
}
'}' => {
depth -= 1;
if depth == 0 {
if let Some(s) = start {
objects.push(json[s..i + ch.len_utf8()].to_string());
}
start = None;
}
}
_ => {}
}
}
objects
}
fn extract_json_string(json: &str, key: &str) -> Option<String> {
let search = format!("\"{}\"", key);
let key_pos = json.find(&search)?;
let after_key = &json[key_pos + search.len()..];
let colon_pos = after_key.find(':')?;
let after_colon = after_key[colon_pos + 1..].trim_start();
if after_colon.starts_with('"') {
let value_start = 1; let rest = &after_colon[value_start..];
let mut end = 0;
let mut escape = false;
for ch in rest.chars() {
if escape {
escape = false;
end += ch.len_utf8();
continue;
}
if ch == '\\' {
escape = true;
end += 1;
continue;
}
if ch == '"' {
break;
}
end += ch.len_utf8();
}
Some(rest[..end].to_string())
} else {
None
}
}
fn extract_json_number(json: &str, key: &str) -> Option<u64> {
let search = format!("\"{}\"", key);
let key_pos = json.find(&search)?;
let after_key = &json[key_pos + search.len()..];
let colon_pos = after_key.find(':')?;
let after_colon = after_key[colon_pos + 1..].trim_start();
let num_str: String = after_colon
.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
num_str.parse().ok()
}
fn extract_nested_json_string(json: &str, key: &str, nested_key: &str) -> Option<String> {
let search = format!("\"{}\"", key);
let key_pos = json.find(&search)?;
let after_key = &json[key_pos + search.len()..];
let colon_pos = after_key.find(':')?;
let after_colon = after_key[colon_pos + 1..].trim_start();
if after_colon.starts_with('{') {
let mut depth = 0;
let mut end = 0;
for (i, ch) in after_colon.chars().enumerate() {
match ch {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
end = i;
break;
}
}
_ => {}
}
}
let nested_obj = &after_colon[..=end];
extract_json_string(nested_obj, nested_key)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_repo_slug_ssh() {
let slug = parse_repo_slug("git@github.com:user/repo.git").unwrap();
assert_eq!(slug, "user/repo");
}
#[test]
fn test_parse_repo_slug_https() {
let slug = parse_repo_slug("https://github.com/user/repo.git").unwrap();
assert_eq!(slug, "user/repo");
}
#[test]
fn test_parse_repo_slug_https_no_git() {
let slug = parse_repo_slug("https://github.com/user/repo").unwrap();
assert_eq!(slug, "user/repo");
}
#[test]
fn test_parse_gh_json_empty() {
let prs = parse_gh_json("[]").unwrap();
assert!(prs.is_empty());
}
#[test]
fn test_parse_gh_json_single() {
let json = r#"[{"number":42,"title":"Add feature","headRefName":"feat-x","author":{"login":"dev1"}}]"#;
let prs = parse_gh_json(json).unwrap();
assert_eq!(prs.len(), 1);
assert_eq!(prs[0].number, 42);
assert_eq!(prs[0].head_branch, "feat-x");
assert_eq!(prs[0].author, "dev1");
}
#[test]
fn test_parse_api_json_single() {
let json =
r#"[{"number":7,"title":"Fix bug","head":{"ref":"fix-bug"},"user":{"login":"dev2"}}]"#;
let prs = parse_api_json(json).unwrap();
assert_eq!(prs.len(), 1);
assert_eq!(prs[0].number, 7);
assert_eq!(prs[0].head_branch, "fix-bug");
assert_eq!(prs[0].author, "dev2");
}
#[test]
fn test_split_json_array() {
let json = r#"[{"a":1},{"b":2}]"#;
let objects = split_json_array(json);
assert_eq!(objects.len(), 2);
}
}