@@ -175,6 +175,8 @@
head_branch: Option<String>,
body: Option<String>,
ci_status: Option<String>,
mergeable: Option<String>,
is_draft: Option<bool>,
inserted_at: Option<String>,
author: Option<Author>,
}
@@ -210,15 +212,15 @@
draft,
ready,
} => {
edit(EditArgs {
edit(
repo: repo.as_deref(),
repo.as_deref(),
number,
title.as_deref(),
body.as_deref(),
base.as_deref(),
title: title.as_deref(),
body: body.as_deref(),
base: base.as_deref(),
draft,
ready,
)
})
.await
}
PrCommand::Close { number, repo } => close(repo.as_deref(), number).await,
@@ -374,9 +376,15 @@
let rows: Vec<Vec<String>> = prs
.iter()
.map(|pr| {
let title = pr.title.clone().unwrap_or_default();
let title = if pr.is_draft == Some(true) {
format!("{title} [draft]")
} else {
title
};
vec![
format!("#{}", pr.number.unwrap_or(0)),
title,
pr.title.clone().unwrap_or_default(),
output::colorize_status(pr.state.as_deref().unwrap_or("?")),
pr.author
.as_ref()
@@ -438,6 +446,14 @@
output::detail("Author", name);
}
}
output::detail(
"Draft",
if pr.is_draft == Some(true) {
"yes"
} else {
"no"
},
);
if let Some(ref ci) = pr.ci_status {
output::detail("CI", &output::colorize_status(ci));
}
@@ -453,15 +469,38 @@
Ok(())
}
/// Arguments for [`edit`]. Bundled into a struct so the function takes one
/// parameter instead of seven (which tripped clippy::too_many_arguments).
#[allow(clippy::too_many_arguments)]
async fn edit(
struct EditArgs<'a> {
repo: Option<&'a str>,
repo: Option<&str>,
number: u32,
title: Option<&'a str>,
body: Option<&'a str>,
title: Option<&str>,
base: Option<&'a str>,
body: Option<&str>,
base: Option<&str>,
draft: bool,
ready: bool,
}
/// Extracts a PR from a response that may be the bare detail object or wrapped
/// under `pull_request`/`data`. Returns `None` if it doesn't deserialize.
fn parse_pr(resp: &serde_json::Value) -> Option<PullRequest> {
let obj = resp
.get("pull_request")
.or_else(|| resp.get("data"))
.unwrap_or(resp);
serde_json::from_value(obj.clone()).ok()
}
async fn edit(args: EditArgs<'_>) -> Result<(), Box<dyn std::error::Error>> {
let EditArgs {
repo,
number,
title,
body,
base,
draft,
) -> Result<(), Box<dyn std::error::Error>> {
ready,
} = args;
let client = Client::from_config()?;
let (org, name) = config::resolve_repo(repo)?;
@@ -489,16 +528,43 @@
return Ok(());
}
let _resp: serde_json::Value = client
let resp: serde_json::Value = client
.patch(
&format!("/{org}/{name}/pulls/{number}"),
&serde_json::Value::Object(payload),
)
.await?;
if output::is_json() {
output::print_json(&resp);
return Ok(());
}
output::success(&format!("Updated PR #{number}"));
// The update endpoint returns the full PR detail; echo what changed back
// from the server's authoritative copy so a repoint/draft toggle is visible.
let pr = parse_pr(&resp);
if base.is_some() {
// Prefer the server's recorded base; fall back to the requested value.
let shown_base = pr
.as_ref()
.and_then(|p| p.base_branch.clone())
.or_else(|| base.map(str::to_string));
if let Some(b) = shown_base {
output::detail("Base", &b);
}
if let Some(m) = pr.as_ref().and_then(|p| p.mergeable.clone()) {
if let Some(base) = base {
output::detail("Mergeable", &output::colorize_status(&m));
}
}
if draft {
output::detail("Draft", "yes");
} else if ready {
output::detail("Draft", "no — marked ready for review");
output::detail("Base", base);
}
Ok(())
}
@@ -825,5 +891,46 @@
#[test]
fn edit_rejects_draft_and_ready_together() {
assert!(PrCli::try_parse_from(["edit", "42", "--draft", "--ready"]).is_err());
}
#[test]
fn parse_pr_reads_draft_and_mergeable_from_bare_object() {
let resp = serde_json::json!({
"number": 7,
"title": "Repointed",
"base_branch": "main",
"is_draft": true,
"mergeable": "conflict"
});
let pr = parse_pr(&resp).expect("should parse");
assert_eq!(pr.is_draft, Some(true));
assert_eq!(pr.mergeable.as_deref(), Some("conflict"));
assert_eq!(pr.base_branch.as_deref(), Some("main"));
}
#[test]
fn parse_pr_unwraps_pull_request_envelope() {
let resp = serde_json::json!({
"pull_request": { "number": 1, "is_draft": false, "mergeable": "mergeable" }
});
let pr = parse_pr(&resp).expect("should parse");
assert_eq!(pr.is_draft, Some(false));
assert_eq!(pr.mergeable.as_deref(), Some("mergeable"));
}
#[test]
fn parse_pr_unwraps_data_envelope() {
let resp = serde_json::json!({ "data": { "number": 2, "is_draft": true } });
let pr = parse_pr(&resp).expect("should parse");
assert_eq!(pr.number, Some(2));
assert_eq!(pr.is_draft, Some(true));
}
#[test]
fn parse_pr_defaults_missing_draft_to_none() {
let resp = serde_json::json!({ "number": 3, "title": "No draft field" });
let pr = parse_pr(&resp).expect("should parse");
assert_eq!(pr.is_draft, None);
assert_eq!(pr.mergeable, None);
}
}