use std::io::Write;
use anyhow::{anyhow, bail, Context, Result};
use clap::{Args, Subcommand};
use serde_json::{json, Value};
use crate::client::{self, Client};
use crate::commands::issue::resolve_repo;
use crate::commands::parse_slug;
use crate::editor;
use crate::output::{render_record, render_table, FormatChoice};
#[derive(Debug, Subcommand)]
pub enum PrCmd {
List(ListArgs),
View(ViewArgs),
Create(CreateArgs),
Comment(CommentArgs),
Close(NumberArgs),
Reopen(NumberArgs),
Checkout(NumberArgs),
Merge(MergeArgs),
Review(ReviewArgs),
}
#[derive(Debug, Args)]
pub struct ListArgs {
pub repo: String,
#[arg(long, default_value = "open")]
pub state: String,
#[arg(long, default_value_t = false)]
pub json: bool,
#[arg(long, default_value_t = false)]
pub tsv: bool,
}
#[derive(Debug, Args)]
pub struct ViewArgs {
pub repo: String,
pub number: i64,
#[arg(long, default_value_t = false)]
pub json: bool,
#[arg(long, default_value_t = false)]
pub tsv: bool,
}
#[derive(Debug, Args)]
pub struct CreateArgs {
pub repo: String,
#[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())]
pub title: String,
#[arg(long)]
pub body: Option<String>,
#[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())]
pub head: String,
#[arg(long, value_parser = clap::builder::NonEmptyStringValueParser::new())]
pub base: String,
}
#[derive(Debug, Args)]
pub struct CommentArgs {
pub repo: String,
pub number: i64,
#[arg(long)]
pub body: Option<String>,
}
#[derive(Debug, Args)]
pub struct NumberArgs {
pub repo: String,
pub number: i64,
}
#[derive(Debug, Args)]
pub struct MergeArgs {
pub repo: String,
pub number: i64,
#[arg(long, default_value = "merge")]
pub strategy: String,
}
#[derive(Debug, Args)]
pub struct ReviewArgs {
pub repo: String,
pub number: i64,
#[arg(long, conflicts_with_all = ["request_changes", "comment"])]
pub approve: bool,
#[arg(long, conflicts_with_all = ["approve", "comment"])]
pub request_changes: bool,
#[arg(long, conflicts_with_all = ["approve", "request_changes"])]
pub comment: bool,
#[arg(long, default_value = "")]
pub body: String,
}
pub fn run(cmd: PrCmd) -> Result<()> {
match cmd {
PrCmd::List(a) => list(a),
PrCmd::View(a) => view(a),
PrCmd::Create(a) => create(a),
PrCmd::Comment(a) => comment(a),
PrCmd::Close(_) | PrCmd::Reopen(_) => {
bail!("PR close/reopen is not exposed in this API version — use the web UI");
}
PrCmd::Checkout(a) => checkout(a),
PrCmd::Merge(a) => merge(a),
PrCmd::Review(a) => review(a),
}
}
fn list(args: ListArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let (repo_id, _owner, _name) = resolve_repo(&cli, &args.repo)?;
let data = cli.execute(PR_LIST, json!({ "id": repo_id }))?;
let prs = data["pullRequests"].as_array().cloned().unwrap_or_default();
let rows = pr_rows(&prs, &args.state);
let fmt = FormatChoice {
json: args.json,
tsv: args.tsv,
}
.resolve();
let out = render_table(&["#", "status", "title", "branches", "author"], &rows, fmt);
std::io::stdout().lock().write_all(out.as_bytes())?;
Ok(())
}
fn view(args: ViewArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let pr = resolve_pr(&cli, &args.repo, args.number)?;
let pairs = vec![
("PR", format!("#{}", args.number)),
("Title", pr["title"].as_str().unwrap_or("").to_string()),
("Status", pr["status"].as_str().unwrap_or("").to_string()),
(
"Head",
pr["sourceBranch"].as_str().unwrap_or("").to_string(),
),
(
"Base",
pr["targetBranch"].as_str().unwrap_or("").to_string(),
),
(
"Author",
pr["authorUsername"].as_str().unwrap_or("").to_string(),
),
("Body", pr["body"].as_str().unwrap_or("").to_string()),
];
let fmt = FormatChoice {
json: args.json,
tsv: args.tsv,
}
.resolve();
std::io::stdout()
.lock()
.write_all(render_record(&pairs, fmt).as_bytes())?;
Ok(())
}
fn create(args: CreateArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let (repo_id, owner, name) = resolve_repo(&cli, &args.repo)?;
let body = match args.body {
Some(b) => b,
None => editor::compose("")?,
};
let data = cli.execute(
CREATE_PR,
json!({
"input": {
"repositoryId": repo_id,
"title": args.title,
"body": body,
"sourceBranch": args.head,
"targetBranch": args.base,
}
}),
)?;
let number = data["createPullRequest"]["number"].as_i64().unwrap_or(0);
writeln!(
std::io::stdout().lock(),
"Created PR #{} in {}/{}",
number,
owner,
name
)?;
Ok(())
}
fn comment(args: CommentArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let pr = resolve_pr(&cli, &args.repo, args.number)?;
let pr_id = pr["id"].as_str().ok_or_else(|| anyhow!("PR id missing"))?;
let body = match args.body {
Some(b) => b,
None => editor::compose("")?,
};
cli.execute(
ADD_PR_COMMENT,
json!({ "input": { "pullRequestId": pr_id, "body": body } }),
)?;
writeln!(
std::io::stdout().lock(),
"Comment posted to {}#{}",
args.repo,
args.number
)?;
Ok(())
}
fn checkout(args: NumberArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let pr = resolve_pr(&cli, &args.repo, args.number)?;
let head_branch = pr["sourceBranch"]
.as_str()
.ok_or_else(|| anyhow!("PR has no sourceBranch"))?;
let (owner, name) = parse_slug(&args.repo)?;
let url = format!("{}/{}/{}.git", cli.host(), owner, name);
let status = std::process::Command::new("git")
.arg("fetch")
.arg(&url)
.arg(format!("{head_branch}:{head_branch}"))
.status()
.context("run `git fetch`")?;
if !status.success() {
bail!("git fetch exited with status {status}");
}
let status = std::process::Command::new("git")
.arg("checkout")
.arg(head_branch)
.status()
.context("run `git checkout`")?;
if !status.success() {
bail!("git checkout exited with status {status}");
}
Ok(())
}
fn merge(args: MergeArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let pr = resolve_pr(&cli, &args.repo, args.number)?;
let pr_id = pr["id"].as_str().ok_or_else(|| anyhow!("PR id missing"))?;
let strategy = merge_strategy_arg(&args.strategy)?;
cli.execute(
MERGE_PR,
json!({ "input": { "pullRequestId": pr_id, "strategy": strategy } }),
)?;
writeln!(
std::io::stdout().lock(),
"Merged PR {}#{} ({})",
args.repo,
args.number,
strategy
)?;
Ok(())
}
fn review(args: ReviewArgs) -> Result<()> {
let (cli, _cfg) = client::authenticated()?;
let pr = resolve_pr(&cli, &args.repo, args.number)?;
let pr_id = pr["id"].as_str().ok_or_else(|| anyhow!("PR id missing"))?;
let state = review_state_arg(&args)?;
cli.execute(
SUBMIT_REVIEW,
json!({
"input": {
"pullRequestId": pr_id,
"state": state,
"body": args.body,
}
}),
)?;
writeln!(
std::io::stdout().lock(),
"Submitted {} review on {}#{}",
state,
args.repo,
args.number
)?;
Ok(())
}
fn merge_strategy_arg(strategy: &str) -> Result<&'static str> {
match strategy.to_ascii_lowercase().as_str() {
"ff" | "fast-forward" | "fast_forward" => Ok("FAST_FORWARD"),
"merge" | "merge-commit" | "merge_commit" => Ok("MERGE_COMMIT"),
"squash" => Ok("SQUASH"),
"rebase" => Ok("REBASE"),
other => bail!("unknown merge strategy `{other}` (expected ff/merge/squash/rebase)"),
}
}
fn review_state_arg(args: &ReviewArgs) -> Result<&'static str> {
match (args.approve, args.request_changes, args.comment) {
(true, false, false) => Ok("APPROVED"),
(false, true, false) => Ok("CHANGES_REQUESTED"),
(false, false, true) => Ok("COMMENTED"),
(false, false, false) => bail!("pass one of --approve / --request-changes / --comment"),
_ => bail!("--approve / --request-changes / --comment are mutually exclusive"),
}
}
fn resolve_pr(cli: &Client, slug: &str, number: i64) -> Result<Value> {
let (repo_id, owner, name) = resolve_repo(cli, slug)?;
let data = cli.execute(PR_LIST, json!({ "id": repo_id }))?;
let prs = data["pullRequests"].as_array().cloned().unwrap_or_default();
prs.into_iter()
.find(|p| p["number"].as_i64() == Some(number))
.ok_or_else(|| anyhow!("no PR #{} in {}/{}", number, owner, name))
}
fn pr_rows(prs: &[Value], state: &str) -> Vec<Vec<String>> {
let state_filter = state.to_ascii_lowercase();
prs.iter()
.filter(|p| match state_filter.as_str() {
"all" => true,
"closed" => matches!(p["status"].as_str(), Some("CLOSED")),
"merged" => matches!(p["status"].as_str(), Some("MERGED")),
_ => matches!(p["status"].as_str(), Some("OPEN")),
})
.map(|p| {
vec![
format!("#{}", p["number"].as_i64().unwrap_or(0)),
p["status"].as_str().unwrap_or("").to_string(),
p["title"].as_str().unwrap_or("").to_string(),
format!(
"{} → {}",
p["sourceBranch"].as_str().unwrap_or(""),
p["targetBranch"].as_str().unwrap_or("")
),
p["authorUsername"].as_str().unwrap_or("").to_string(),
]
})
.collect()
}
const PR_LIST: &str = "query($id: UUID!) { \
pullRequests(repositoryId: $id) { \
id number title status sourceBranch targetBranch authorUsername body updatedAt \
} \
}";
const CREATE_PR: &str = "mutation($input: CreatePullRequestInput!) { \
createPullRequest(input: $input) { id number } \
}";
const ADD_PR_COMMENT: &str = "mutation($input: AddPullRequestCommentInput!) { \
addPullRequestComment(input: $input) { id } \
}";
const MERGE_PR: &str = "mutation($input: MergePullRequestInput!) { \
mergePullRequest(input: $input) { id status } \
}";
const SUBMIT_REVIEW: &str = "mutation($input: SubmitPullRequestReviewInput!) { \
submitPullRequestReview(input: $input) { id state } \
}";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn merge_strategy_arg_accepts_documented_aliases() {
let cases = [
("ff", "FAST_FORWARD"),
("fast-forward", "FAST_FORWARD"),
("fast_forward", "FAST_FORWARD"),
("merge", "MERGE_COMMIT"),
("merge-commit", "MERGE_COMMIT"),
("merge_commit", "MERGE_COMMIT"),
("squash", "SQUASH"),
("rebase", "REBASE"),
("SQUASH", "SQUASH"),
];
for (input, expected) in cases {
assert_eq!(merge_strategy_arg(input).unwrap(), expected);
}
}
#[test]
fn merge_strategy_arg_rejects_unknown_values() {
let err = merge_strategy_arg("ours").unwrap_err().to_string();
assert!(err.contains("unknown merge strategy `ours`"));
}
#[test]
fn review_state_arg_requires_exactly_one_state_flag() {
let approved = ReviewArgs {
repo: "alice/r1".into(),
number: 7,
approve: true,
request_changes: false,
comment: false,
body: String::new(),
};
assert_eq!(review_state_arg(&approved).unwrap(), "APPROVED");
let changes = ReviewArgs {
request_changes: true,
approve: false,
..approved
};
assert_eq!(review_state_arg(&changes).unwrap(), "CHANGES_REQUESTED");
let commented = ReviewArgs {
comment: true,
request_changes: false,
..changes
};
assert_eq!(review_state_arg(&commented).unwrap(), "COMMENTED");
let missing = ReviewArgs {
comment: false,
..commented
};
assert!(review_state_arg(&missing)
.unwrap_err()
.to_string()
.contains("pass one of"));
let multiple = ReviewArgs {
approve: true,
comment: true,
..missing
};
assert!(review_state_arg(&multiple)
.unwrap_err()
.to_string()
.contains("mutually exclusive"));
}
#[test]
fn pr_rows_filters_by_state_and_formats_branch_pair() {
let prs = vec![
serde_json::json!({
"number": 1,
"status": "OPEN",
"title": "Add single-box CI",
"sourceBranch": "ci-local",
"targetBranch": "main",
"authorUsername": "bri"
}),
serde_json::json!({
"number": 2,
"status": "CLOSED",
"title": "Old deploy spike",
"sourceBranch": "deploy-spike",
"targetBranch": "main",
"authorUsername": "sam"
}),
serde_json::json!({
"number": 3,
"status": "MERGED",
"title": "Registry auth",
"sourceBranch": "registry-auth",
"targetBranch": "main",
"authorUsername": "alex"
}),
];
assert_eq!(
pr_rows(&prs, "open"),
vec![vec![
"#1",
"OPEN",
"Add single-box CI",
"ci-local → main",
"bri"
]]
);
assert_eq!(
pr_rows(&prs, "closed"),
vec![vec![
"#2",
"CLOSED",
"Old deploy spike",
"deploy-spike → main",
"sam"
]]
);
assert_eq!(
pr_rows(&prs, "merged"),
vec![vec![
"#3",
"MERGED",
"Registry auth",
"registry-auth → main",
"alex"
]]
);
assert_eq!(pr_rows(&prs, "all").len(), 3);
}
#[test]
fn pr_rows_defaults_missing_fields_to_empty_values() {
let prs = vec![serde_json::json!({})];
assert_eq!(pr_rows(&prs, "all"), vec![vec!["#0", "", "", " → ", ""]]);
}
}