ref:main
use crate::client::Client;
use crate::config;
use crate::output;
use clap::{Args, Subcommand};
use serde::Deserialize;
#[derive(Args)]
pub struct PrArgs {
#[command(subcommand)]
pub command: PrCommand,
}
#[derive(Subcommand)]
pub enum PrCommand {
/// List pull requests
List {
/// Repository (org/repo)
repo: Option<String>,
/// Filter by state: open, closed, merged, all
#[arg(long, default_value = "open")]
state: String,
/// Maximum results
#[arg(long, default_value = "30")]
limit: u32,
},
/// View a pull request
View {
/// PR number
number: u32,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Create a pull request
Create {
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
/// PR title
#[arg(long)]
title: String,
/// PR body/description
#[arg(long, default_value = "")]
body: String,
/// Base branch
#[arg(long)]
base: String,
/// Head branch (defaults to current branch)
#[arg(long)]
head: Option<String>,
},
/// Close a pull request
Close {
/// PR number
number: u32,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Reopen a closed pull request
Reopen {
/// PR number
number: u32,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Edit a pull request's title or body
Edit {
/// PR number
number: u32,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
/// New title
#[arg(long)]
title: Option<String>,
/// New body
#[arg(long)]
body: Option<String>,
},
/// Merge a pull request
Merge {
/// PR number
number: u32,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
/// Merge strategy: merge_commit, squash, rebase
#[arg(long, default_value = "merge_commit")]
strategy: String,
},
/// Submit a review on a pull request
Review {
/// PR number
number: u32,
/// Review action: approve, request_changes, comment
#[arg(long)]
action: String,
/// Review body/comment
#[arg(long, default_value = "")]
body: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// List reviews on a pull request
Reviews {
/// PR number
number: u32,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Add an inline comment on a pull request
#[command(name = "comment")]
CommentCode {
/// PR number
number: u32,
/// File path to comment on
#[arg(long)]
file: String,
/// Line number
#[arg(long)]
line: u32,
/// Comment body
#[arg(long)]
body: String,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
},
/// Print the PR diff to stdout (uses local git)
Diff {
/// PR number
number: u32,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
/// Print only file names, not the patch
#[arg(long)]
name_only: bool,
/// Git remote to fetch from
#[arg(long, default_value = "origin")]
remote: String,
},
/// Fetch and check out a PR locally (uses local git)
Checkout {
/// PR number
number: u32,
/// Repository (org/repo)
#[arg(long)]
repo: Option<String>,
/// Git remote to fetch from
#[arg(long, default_value = "origin")]
remote: String,
},
}
#[derive(Debug, Deserialize)]
struct PullRequest {
number: Option<u32>,
title: Option<String>,
state: Option<String>,
base_branch: Option<String>,
head_branch: Option<String>,
body: Option<String>,
ci_status: Option<String>,
inserted_at: Option<String>,
author: Option<Author>,
}
#[derive(Debug, Deserialize)]
struct Author {
username: Option<String>,
}
#[derive(Debug, Deserialize)]
#[allow(dead_code)]
struct PrListResponse {
data: Option<Vec<PullRequest>>,
}
pub async fn run(args: PrArgs) -> Result<(), Box<dyn std::error::Error>> {
match args.command {
PrCommand::List { repo, state, limit } => list(repo.as_deref(), &state, limit).await,
PrCommand::View { number, repo } => view(repo.as_deref(), number).await,
PrCommand::Create {
repo,
title,
body,
base,
head,
} => create(repo.as_deref(), &title, &body, &base, head.as_deref()).await,
PrCommand::Edit {
number,
repo,
title,
body,
} => edit(repo.as_deref(), number, title.as_deref(), body.as_deref()).await,
PrCommand::Close { number, repo } => close(repo.as_deref(), number).await,
PrCommand::Reopen { number, repo } => reopen(repo.as_deref(), number).await,
PrCommand::Merge {
number,
repo,
strategy,
} => merge(repo.as_deref(), number, &strategy).await,
PrCommand::Review {
number,
action,
body,
repo,
} => submit_review(repo.as_deref(), number, &action, &body).await,
PrCommand::Reviews { number, repo } => list_reviews(repo.as_deref(), number).await,
PrCommand::CommentCode {
number,
file,
line,
body,
repo,
} => add_pr_comment(repo.as_deref(), number, &file, line, &body).await,
PrCommand::Diff {
number,
repo,
name_only,
remote,
} => diff(repo.as_deref(), number, name_only, &remote).await,
PrCommand::Checkout {
number,
repo,
remote,
} => checkout(repo.as_deref(), number, &remote).await,
}
}
/// Resolve a PR's base/head branches via the API. Used by diff/checkout.
async fn resolve_pr_branches(
repo: Option<&str>,
number: u32,
) -> Result<(String, String), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/pulls/{number}")).await?;
let pr: PullRequest = if let Some(obj) = resp.get("pull_request") {
serde_json::from_value(obj.clone())?
} else if let Some(obj) = resp.get("data") {
serde_json::from_value(obj.clone())?
} else {
serde_json::from_value(resp)?
};
let base = pr.base_branch.ok_or("PR response missing base_branch")?;
let head = pr.head_branch.ok_or("PR response missing head_branch")?;
Ok((base, head))
}
async fn diff(
repo: Option<&str>,
number: u32,
name_only: bool,
remote: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let (base, head) = resolve_pr_branches(repo, number).await?;
// Fetch both branches so the diff is against the server's current view.
let fetch = std::process::Command::new("git")
.args(["fetch", remote, &base, &head])
.status()?;
if !fetch.success() {
return Err(format!("git fetch {remote} {base} {head} failed").into());
}
let mut args: Vec<String> = vec!["diff".into()];
if name_only {
args.push("--name-only".into());
}
// Three-dot syntax: diff from the merge-base of remote/base..remote/head.
args.push(format!("{remote}/{base}...{remote}/{head}"));
let status = std::process::Command::new("git").args(&args).status()?;
if !status.success() {
return Err("git diff failed".into());
}
Ok(())
}
async fn checkout(
repo: Option<&str>,
number: u32,
remote: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let (_base, head) = resolve_pr_branches(repo, number).await?;
// Refuse to clobber uncommitted changes — match `gh pr checkout` behavior.
let dirty = std::process::Command::new("git")
.args(["diff-index", "--quiet", "HEAD", "--"])
.status()?;
if !dirty.success() {
return Err("working tree has uncommitted changes; commit or stash before checkout".into());
}
let fetch = std::process::Command::new("git")
.args(["fetch", remote, &head])
.status()?;
if !fetch.success() {
return Err(format!("git fetch {remote} {head} failed").into());
}
// Create or update the local branch tracking remote/head, then check it out.
let checkout = std::process::Command::new("git")
.args(["checkout", "-B", &head, &format!("{remote}/{head}")])
.status()?;
if !checkout.success() {
return Err(format!("git checkout {head} failed").into());
}
output::success(&format!("Checked out PR #{number} ({head})"));
Ok(())
}
async fn list(
repo: Option<&str>,
state: &str,
limit: u32,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get_with_query(
&format!("/{org}/{name}/pulls"),
&[("state", state), ("per_page", &limit.to_string())],
)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let prs: Vec<PullRequest> = if let Some(arr) = resp.get("pull_requests") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else if let Some(arr) = resp.get("data") {
serde_json::from_value(arr.clone()).unwrap_or_default()
} else {
serde_json::from_value(resp).unwrap_or_default()
};
output::header(&format!("Pull requests ({org}/{name}) — {state}"));
let rows: Vec<Vec<String>> = prs
.iter()
.map(|pr| {
vec![
format!("#{}", pr.number.unwrap_or(0)),
pr.title.clone().unwrap_or_default(),
output::colorize_status(pr.state.as_deref().unwrap_or("?")),
pr.author
.as_ref()
.and_then(|a| a.username.clone())
.unwrap_or_default(),
format!(
"{} ← {}",
pr.base_branch.as_deref().unwrap_or("?"),
pr.head_branch.as_deref().unwrap_or("?")
),
]
})
.collect();
output::print_table(&["#", "TITLE", "STATE", "AUTHOR", "BRANCHES"], &rows);
Ok(())
}
async fn view(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client.get(&format!("/{org}/{name}/pulls/{number}")).await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
let pr: PullRequest = if let Some(obj) = resp.get("pull_request") {
serde_json::from_value(obj.clone())?
} else if let Some(obj) = resp.get("data") {
serde_json::from_value(obj.clone())?
} else {
serde_json::from_value(resp.clone())?
};
output::header(&format!(
"PR #{} — {}",
pr.number.unwrap_or(number),
pr.title.as_deref().unwrap_or("(untitled)")
));
output::detail(
"State",
&output::colorize_status(pr.state.as_deref().unwrap_or("?")),
);
output::detail(
"Branches",
&format!(
"{} ← {}",
pr.base_branch.as_deref().unwrap_or("?"),
pr.head_branch.as_deref().unwrap_or("?")
),
);
if let Some(ref author) = pr.author {
if let Some(ref name) = author.username {
output::detail("Author", name);
}
}
if let Some(ref ci) = pr.ci_status {
output::detail("CI", &output::colorize_status(ci));
}
if let Some(ref ts) = pr.inserted_at {
output::detail("Created", &output::format_time(ts));
}
if let Some(ref body) = pr.body {
if !body.is_empty() {
println!("\n{body}");
}
}
Ok(())
}
async fn edit(
repo: Option<&str>,
number: u32,
title: Option<&str>,
body: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let mut payload = serde_json::Map::new();
if let Some(t) = title {
payload.insert("title".into(), serde_json::json!(t));
}
if let Some(b) = body {
payload.insert("body".into(), serde_json::json!(b));
}
if payload.is_empty() {
output::warn("Nothing to update — specify --title or --body");
return Ok(());
}
let _resp: serde_json::Value = client
.patch(
&format!("/{org}/{name}/pulls/{number}"),
&serde_json::Value::Object(payload),
)
.await?;
output::success(&format!("Updated PR #{number}"));
Ok(())
}
async fn close(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
.patch(
&format!("/{org}/{name}/pulls/{number}"),
&serde_json::json!({"state": "closed"}),
)
.await?;
output::success(&format!("Closed PR #{number}"));
Ok(())
}
async fn reopen(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
.patch(
&format!("/{org}/{name}/pulls/{number}"),
&serde_json::json!({"state": "open"}),
)
.await?;
output::success(&format!("Reopened PR #{number}"));
Ok(())
}
async fn merge(
repo: Option<&str>,
number: u32,
strategy: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/pulls/{number}/merge"),
&serde_json::json!({
"strategy": strategy,
}),
)
.await?;
let merge_sha = resp
.get("merge_sha")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
output::success(&format!("Merged PR #{number} via {strategy}"));
output::detail("Merge SHA", merge_sha);
Ok(())
}
async fn create(
repo: Option<&str>,
title: &str,
body: &str,
base: &str,
head: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let head_branch = match head {
Some(h) => h.to_string(),
None => {
let output = std::process::Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.output()?;
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
};
let resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/pulls"),
&serde_json::json!({
"title": title,
"body": body,
"base_branch": base,
"head_branch": head_branch,
}),
)
.await?;
let number = resp
.pointer("/pull_request/number")
.or_else(|| resp.pointer("/data/number"))
.or_else(|| resp.get("number"))
.and_then(|v| v.as_u64())
.unwrap_or(0);
output::success(&format!("Created PR #{number}: {title}"));
output::detail("Base", base);
output::detail("Head", &head_branch);
// Print web URL
let config = crate::config::Config::load()?;
if let Some(ref url) = config.server_url {
println!(
"\n {}",
format!("{url}/{org}/{name}/pull/{number}").as_str()
);
}
Ok(())
}
async fn submit_review(
repo: Option<&str>,
number: u32,
action: &str,
body: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/pulls/{number}/reviews"),
&serde_json::json!({"state": action, "body": body}),
)
.await?;
let action_display = match action {
"approve" | "approved" => "Approved",
"request_changes" | "changes_requested" => "Requested changes on",
_ => "Commented on",
};
output::success(&format!("{action_display} PR #{number}"));
Ok(())
}
async fn list_reviews(repo: Option<&str>, number: u32) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let resp: serde_json::Value = client
.get(&format!("/{org}/{name}/pulls/{number}/reviews"))
.await?;
let reviews: Vec<serde_json::Value> = resp
.get("reviews")
.and_then(|v| serde_json::from_value(v.clone()).ok())
.unwrap_or_default();
output::header(&format!("Reviews on PR #{number}"));
let rows: Vec<Vec<String>> = reviews
.iter()
.map(|r| {
vec![
r.get("state")
.and_then(|v| v.as_str())
.map(output::colorize_status)
.unwrap_or_default(),
r.pointer("/author/email")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string(),
r.get("body")
.and_then(|v| v.as_str())
.unwrap_or("")
.lines()
.next()
.unwrap_or("")
.to_string(),
r.get("inserted_at")
.and_then(|v| v.as_str())
.map(output::format_time)
.unwrap_or_default(),
]
})
.collect();
output::print_table(&["STATE", "AUTHOR", "BODY", "DATE"], &rows);
Ok(())
}
async fn add_pr_comment(
repo: Option<&str>,
number: u32,
file: &str,
line: u32,
body: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
let _resp: serde_json::Value = client
.post(
&format!("/{org}/{name}/pulls/{number}/comments"),
&serde_json::json!({"file_path": file, "line_number": line, "body": body}),
)
.await?;
output::success(&format!("Added comment on {file}:{line} in PR #{number}"));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[derive(Parser)]
#[command(no_binary_name = true)]
struct PrCli {
#[command(subcommand)]
command: PrCommand,
}
fn parse(args: &[&str]) -> PrCommand {
PrCli::try_parse_from(args).expect("parse").command
}
#[test]
fn diff_parses_with_defaults() {
match parse(&["diff", "42"]) {
PrCommand::Diff {
number,
repo,
name_only,
remote,
} => {
assert_eq!(number, 42);
assert!(repo.is_none());
assert!(!name_only);
assert_eq!(remote, "origin");
}
other => panic!("expected Diff, got {:?}", std::mem::discriminant(&other)),
}
}
#[test]
fn diff_parses_with_name_only_and_custom_remote() {
match parse(&["diff", "42", "--name-only", "--remote", "upstream"]) {
PrCommand::Diff {
name_only, remote, ..
} => {
assert!(name_only);
assert_eq!(remote, "upstream");
}
_ => panic!("expected Diff"),
}
}
#[test]
fn checkout_parses() {
match parse(&["checkout", "42"]) {
PrCommand::Checkout {
number,
repo,
remote,
} => {
assert_eq!(number, 42);
assert!(repo.is_none());
assert_eq!(remote, "origin");
}
_ => panic!("expected Checkout"),
}
}
#[test]
fn checkout_parses_with_repo() {
match parse(&["checkout", "42", "--repo", "fangorn/anvil"]) {
PrCommand::Checkout { repo, .. } => {
assert_eq!(repo.as_deref(), Some("fangorn/anvil"));
}
_ => panic!("expected Checkout"),
}
}
}