use crate::error::{JjjError, Result};
use crate::jj::find_executable;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone)]
pub struct GhClient {
gh_path: PathBuf,
repo_root: PathBuf,
}
impl GhClient {
pub fn new(repo_root: &Path) -> Result<Self> {
let gh_path = find_executable("gh").ok_or(JjjError::GhNotFound)?;
Ok(Self {
gh_path,
repo_root: repo_root.to_path_buf(),
})
}
pub fn execute(&self, args: &[&str]) -> Result<String> {
if std::env::var("JJJ_DEBUG").is_ok() {
eprintln!("DEBUG: gh {}", args.join(" "));
}
let output = Command::new(&self.gh_path)
.args(args)
.current_dir(&self.repo_root)
.output()
.map_err(|e| JjjError::GhIo {
args: args.join(" "),
source: e,
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if stderr.contains("auth login") || stderr.contains("not logged") {
return Err(JjjError::GhAuthFailed);
}
return Err(JjjError::GhCommandFailed {
args: args.join(" "),
stderr,
});
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub fn auth_status(&self) -> Result<String> {
let output = self.execute(&["auth", "status"])?;
for line in output.lines() {
if let Some(rest) = line.strip_prefix("Logged in to") {
if let Some(account) = rest.split("account ").nth(1) {
return Ok(account.split_whitespace().next().unwrap_or("").to_string());
}
}
}
self.execute(&["api", "user", "--jq", ".login"])
}
pub fn detect_repo(&self) -> Result<(String, String)> {
let output = self.execute(&[
"repo",
"view",
"--json",
"owner,name",
"--jq",
".owner.login + \"/\" + .name",
])?;
let parts: Vec<&str> = output.trim().splitn(2, '/').collect();
if parts.len() != 2 {
return Err(JjjError::GhRepoNotDetected);
}
Ok((parts[0].to_string(), parts[1].to_string()))
}
pub fn create_issue(&self, title: &str, body: &str, labels: &[&str]) -> Result<u64> {
let mut args = vec!["issue", "create", "--title", title, "--body", body];
for label in labels {
args.push("--label");
args.push(label);
}
args.extend_from_slice(&["--json", "number", "--jq", ".number"]);
let output = self.execute(&args)?;
output
.trim()
.parse()
.map_err(|_| JjjError::Validation(format!("Failed to parse issue number: {}", output)))
}
pub fn get_issue(&self, number: u64) -> Result<serde_json::Value> {
let num_str = number.to_string();
let output = self.execute(&[
"issue",
"view",
&num_str,
"--json",
"number,title,body,state,labels,author",
])?;
serde_json::from_str(&output).map_err(|e| JjjError::Validation(e.to_string()))
}
pub fn list_issues(&self, label: Option<&str>, state: &str) -> Result<Vec<serde_json::Value>> {
let mut args = vec![
"issue",
"list",
"--state",
state,
"--json",
"number,title,state,labels",
"--limit",
"100",
];
if let Some(l) = label {
args.push("--label");
args.push(l);
}
let output = self.execute(&args)?;
serde_json::from_str(&output).map_err(|e| JjjError::Validation(e.to_string()))
}
pub fn close_issue(&self, number: u64) -> Result<()> {
let num_str = number.to_string();
self.execute(&["issue", "close", &num_str])?;
Ok(())
}
pub fn reopen_issue(&self, number: u64) -> Result<()> {
let num_str = number.to_string();
self.execute(&["issue", "reopen", &num_str])?;
Ok(())
}
pub fn create_pr(&self, title: &str, body: &str, head: &str, base: &str) -> Result<u64> {
let output = self.execute(&[
"pr", "create", "--title", title, "--body", body, "--head", head, "--base", base,
"--json", "number", "--jq", ".number",
])?;
output
.trim()
.parse()
.map_err(|_| JjjError::Validation(format!("Failed to parse PR number: {}", output)))
}
pub fn get_pr(&self, number: u64) -> Result<serde_json::Value> {
let num_str = number.to_string();
let output = self.execute(&[
"pr",
"view",
&num_str,
"--json",
"number,title,body,state,reviews,headRefName",
])?;
serde_json::from_str(&output).map_err(|e| JjjError::Validation(e.to_string()))
}
pub fn merge_pr(&self, number: u64) -> Result<()> {
let num_str = number.to_string();
self.execute(&["pr", "merge", &num_str, "--squash", "--delete-branch"])?;
Ok(())
}
pub fn list_reviews(&self, pr_number: u64) -> Result<serde_json::Value> {
let num_str = pr_number.to_string();
let output = self.execute(&[
"pr", "view", &num_str, "--json", "reviews", "--jq", ".reviews",
])?;
serde_json::from_str(&output).map_err(|e| JjjError::Validation(e.to_string()))
}
pub fn list_review_threads(&self, pr_number: u64) -> Result<serde_json::Value> {
let num_str = pr_number.to_string();
let output = self.execute(&[
"pr",
"view",
&num_str,
"--json",
"reviewThreads",
"--jq",
".reviewThreads",
])?;
serde_json::from_str(&output).map_err(|e| JjjError::Validation(e.to_string()))
}
pub fn get_pr_body(&self, number: u64) -> Result<String> {
let num_str = number.to_string();
self.execute(&["pr", "view", &num_str, "--json", "body", "--jq", ".body"])
}
pub fn update_pr_body(&self, number: u64, body: &str) -> Result<()> {
let num_str = number.to_string();
self.execute(&["pr", "edit", &num_str, "--body", body])?;
Ok(())
}
pub fn pr_state(&self, number: u64) -> Result<String> {
let num_str = number.to_string();
self.execute(&["pr", "view", &num_str, "--json", "state", "--jq", ".state"])
}
pub fn issue_state(&self, number: u64) -> Result<String> {
let num_str = number.to_string();
self.execute(&[
"issue", "view", &num_str, "--json", "state", "--jq", ".state",
])
}
}