Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion crates/mergify-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2384,12 +2384,15 @@ fn run_native(cmd: NativeCommand) -> ExitCode {
} => {
println!("Opening PR #{pull_number}: {title}");
println!(" {pull_url}");
Ok(mergify_core::ExitCode::Success)
}
mergify_stack::commands::open::Outcome::EmptyStack => {
// Python exits STACK_NOT_FOUND (3) so callers
// can detect the empty stack via `$?`.
println!("No commits in stack");
Ok(mergify_core::ExitCode::StackNotFound)
}
}
Ok(mergify_core::ExitCode::Success)
}
NativeCommand::StackHooks(opts) => {
if opts.do_setup {
Expand Down
88 changes: 78 additions & 10 deletions crates/mergify-core/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -390,15 +390,20 @@ impl Client {
/// diagnostics — timeouts and connect failures must read the
/// same regardless of HTTP method.
fn terminal_send_error_message(&self, e: &reqwest::Error) -> String {
if e.is_timeout() {
format!(
"{} did not respond in time. The request was aborted — please retry.",
self.service_name()
)
let svc = self.service_name();
let base = if e.is_timeout() {
format!("{svc} did not respond in time. The request was aborted — please retry.")
} else if e.is_connect() {
format!("could not reach {}: {e}", self.service_name())
format!("could not reach {svc}: {e}")
} else {
format!("request failed: {e}")
};
// Surface the contacted URL so a hung or mis-configured
// endpoint (e.g. a wrong --api-url) is diagnosable — Python
// printed it in the same network-error message.
match e.url() {
Some(url) => format!("{base} (url: {url})"),
None => base,
}
}
}
Expand All @@ -408,6 +413,11 @@ fn is_transient(e: &reqwest::Error) -> bool {
}

async fn error_message(status: StatusCode, mut resp: reqwest::Response) -> String {
// Capture the URL before the body stream consumes `resp` — a
// failing endpoint (e.g. a mis-resolved --api-url) is surfaced on
// a trailing `url:` line, matching Python's `check_for_status`.
let url = resp.url().clone();

// Stream chunks until we've buffered at most `MAX_ERROR_BODY_BYTES`,
// then drop the rest. `Response::text()` would slurp the entire
// body into memory regardless of size.
Expand All @@ -426,11 +436,29 @@ async fn error_message(status: StatusCode, mut resp: reqwest::Response) -> Strin
if truncated {
text.push_str("…[truncated]");
}
if text.is_empty() {
format!("HTTP {status}")

// Prefer the JSON `detail` field the Mergify API returns so the
// user sees a clean sentence instead of the raw `{"detail": …}`
// envelope. Only when the (untruncated) body parses as an object
// carrying a string `detail`; otherwise fall back to the body text.
let detail = if truncated {
None
} else {
format!("HTTP {status}: {text}")
}
serde_json::from_slice::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
v.get("detail")
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
})
};

let head = match detail {
Some(detail) => format!("HTTP {status}: {detail}"),
None if text.is_empty() => format!("HTTP {status}"),
None => format!("HTTP {status}: {text}"),
};
format!("{head}\nurl: {url}")
}

#[cfg(test)]
Expand Down Expand Up @@ -679,6 +707,46 @@ mod tests {
assert!(msg.contains("404"), "expected status in message, got {msg}");
}

#[tokio::test]
async fn json_detail_field_is_extracted_and_url_is_appended() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/foo"))
.respond_with(
ResponseTemplate::new(404)
.set_body_json(serde_json::json!({"detail": "Repository not found"})),
)
.mount(&server)
.await;

let client = fast_client(&server, ApiFlavor::Mergify);
let msg = client.get::<Foo>("/foo").await.unwrap_err().to_string();
// Clean sentence from `detail`, not the raw `{"detail": …}` body.
assert!(
msg.contains("HTTP 404 Not Found: Repository not found"),
"expected extracted detail, got {msg}"
);
assert!(!msg.contains('{'), "raw JSON envelope leaked: {msg}");
// Failing endpoint surfaced on a trailing url: line.
assert!(msg.contains("\nurl: "), "missing url line: {msg}");
assert!(msg.contains("/foo"), "url should name the path: {msg}");
}

#[tokio::test]
async fn non_json_error_body_falls_back_to_raw_text_with_url() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/foo"))
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
.mount(&server)
.await;

let client = fast_client(&server, ApiFlavor::Mergify);
let msg = client.get::<Foo>("/foo").await.unwrap_err().to_string();
assert!(msg.contains("boom"), "raw body should survive: {msg}");
assert!(msg.contains("\nurl: "), "missing url line: {msg}");
}

#[tokio::test]
async fn join_rejects_absolute_url() {
let server = MockServer::start().await;
Expand Down
19 changes: 9 additions & 10 deletions crates/mergify-stack/src/commands/drop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -125,22 +125,21 @@ fn match_commit(prefix: &str, commits: &[LocalCommit]) -> Result<DroppedCommit,
)
};
match matches.as_slice() {
[] => Err(CliError::StackNotFound(format!(
"no commit found matching {field} prefix '{prefix}'"
))),
[] => Err(crate::match_commit::not_found(field, prefix)),
[only] => Ok(DroppedCommit {
sha: only.commit_sha.clone(),
subject: only.title.clone(),
}),
many => {
let listing = many
let candidates: Vec<crate::match_commit::Candidate<'_>> = many
.iter()
.map(|c| format!("{} {}", &c.commit_sha[..7], c.title))
.collect::<Vec<_>>()
.join("\n ");
Err(CliError::InvalidState(format!(
"{field} prefix '{prefix}' matches multiple commits:\n {listing}"
)))
.map(|c| crate::match_commit::Candidate {
commit_sha: &c.commit_sha,
title: &c.title,
change_id: &c.change_id,
})
.collect();
Err(crate::match_commit::ambiguous(field, prefix, &candidates))
}
}
}
Expand Down
19 changes: 9 additions & 10 deletions crates/mergify-stack/src/commands/edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,22 +116,21 @@ fn match_commit(
)
};
match matches.as_slice() {
[] => Err(CliError::StackNotFound(format!(
"no commit found matching {field} prefix '{prefix}'"
))),
[] => Err(crate::match_commit::not_found(field, prefix)),
[only] => Ok(EditingCommit {
sha: only.commit_sha.clone(),
subject: only.title.clone(),
}),
many => {
let listing = many
let candidates: Vec<crate::match_commit::Candidate<'_>> = many
.iter()
.map(|c| format!("{} {}", &c.commit_sha[..7], c.title))
.collect::<Vec<_>>()
.join("\n ");
Err(CliError::InvalidState(format!(
"{field} prefix '{prefix}' matches multiple commits:\n {listing}"
)))
.map(|c| crate::match_commit::Candidate {
commit_sha: &c.commit_sha,
title: &c.title,
change_id: &c.change_id,
})
.collect();
Err(crate::match_commit::ambiguous(field, prefix, &candidates))
}
}
}
Expand Down
19 changes: 9 additions & 10 deletions crates/mergify-stack/src/commands/fixup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,22 +138,21 @@ fn match_commit(prefix: &str, commits: &[LocalCommit]) -> Result<FixedUpCommit,
)
};
match matches.as_slice() {
[] => Err(CliError::StackNotFound(format!(
"no commit found matching {field} prefix '{prefix}'"
))),
[] => Err(crate::match_commit::not_found(field, prefix)),
[only] => Ok(FixedUpCommit {
sha: only.commit_sha.clone(),
subject: only.title.clone(),
}),
many => {
let listing = many
let candidates: Vec<crate::match_commit::Candidate<'_>> = many
.iter()
.map(|c| format!("{} {}", &c.commit_sha[..7], c.title))
.collect::<Vec<_>>()
.join("\n ");
Err(CliError::InvalidState(format!(
"{field} prefix '{prefix}' matches multiple commits:\n {listing}"
)))
.map(|c| crate::match_commit::Candidate {
commit_sha: &c.commit_sha,
title: &c.title,
change_id: &c.change_id,
})
.collect();
Err(crate::match_commit::ambiguous(field, prefix, &candidates))
}
}
}
Expand Down
11 changes: 6 additions & 5 deletions crates/mergify-stack/src/commands/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,12 @@ pub async fn run(opts: &Options<'_>) -> Result<StackListOutput, CliError> {

let (remote, base_branch) = opts.trunk;
if base_branch == dest_branch {
return Err(CliError::InvalidState(format!(
"your local branch `{dest_branch}` targets itself: `{remote}/{base_branch}`. \
Either fix the target branch (`git branch {dest_branch} \
--set-upstream-to=<remote>/<target>`) or rename it."
)));
return Err(stack_context::targets_itself_error(
Some(&repo_dir),
&dest_branch,
remote,
base_branch,
));
}

let stack_prefix = if opts.branch_prefix.is_empty() {
Expand Down
19 changes: 9 additions & 10 deletions crates/mergify-stack/src/commands/move_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,23 +206,22 @@ fn match_commit(prefix: &str, commits: &[LocalCommit]) -> Result<OrderedCommit,
)
};
match matches.as_slice() {
[] => Err(CliError::StackNotFound(format!(
"no commit found matching {field} prefix '{prefix}'"
))),
[] => Err(crate::match_commit::not_found(field, prefix)),
[only] => Ok(OrderedCommit {
sha: only.commit_sha.clone(),
subject: only.title.clone(),
change_id: only.change_id.clone(),
}),
many => {
let listing = many
let candidates: Vec<crate::match_commit::Candidate<'_>> = many
.iter()
.map(|c| format!("{} {}", &c.commit_sha[..7], c.title))
.collect::<Vec<_>>()
.join("\n ");
Err(CliError::InvalidState(format!(
"{field} prefix '{prefix}' matches multiple commits:\n {listing}"
)))
.map(|c| crate::match_commit::Candidate {
commit_sha: &c.commit_sha,
title: &c.title,
change_id: &c.change_id,
})
.collect();
Err(crate::match_commit::ambiguous(field, prefix, &candidates))
}
}
}
Expand Down
33 changes: 19 additions & 14 deletions crates/mergify-stack/src/commands/note.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,11 @@ pub enum Outcome {
///
/// Errors:
/// - [`CliError::InvalidState`] for an empty message (inline `-m ""`
/// or editor returning only comment lines), and for an
/// ambiguous / missing Change-Id prefix match. Matches Python's
/// `sys.exit(ExitCode.INVALID_STATE)` flow.
/// or editor returning only comment lines) and for an ambiguous
/// Change-Id prefix match (exit 7).
/// - [`CliError::StackNotFound`] when a Change-Id prefix matches no
/// commit in the stack (exit 3) — matches Python's
/// `sys.exit(ExitCode.STACK_NOT_FOUND)`.
/// - [`CliError::Generic`] for git invocation failures and editor
/// process errors.
pub fn run(
Expand Down Expand Up @@ -183,19 +185,22 @@ fn resolve_change_id_prefix(repo_dir: Option<&Path>, prefix: &str) -> Result<Str
.filter(|c| c.change_id.starts_with(prefix))
.collect();
match matches.as_slice() {
[] => Err(CliError::InvalidState(format!(
"no commit found matching Change-Id prefix '{prefix}'"
))),
[] => Err(crate::match_commit::not_found("Change-Id", prefix)),
[only] => Ok(only.commit_sha.clone()),
many => {
let listing = many
let candidates: Vec<crate::match_commit::Candidate<'_>> = many
.iter()
.map(|c| format!("{} {}", &c.commit_sha[..7], c.title))
.collect::<Vec<_>>()
.join("\n ");
Err(CliError::InvalidState(format!(
"Change-Id prefix '{prefix}' matches multiple commits:\n {listing}"
)))
.map(|c| crate::match_commit::Candidate {
commit_sha: &c.commit_sha,
title: &c.title,
change_id: &c.change_id,
})
.collect();
Err(crate::match_commit::ambiguous(
"Change-Id",
prefix,
&candidates,
))
}
}
}
Expand Down Expand Up @@ -604,7 +609,7 @@ mod tests {
)
.unwrap_err();
match err {
CliError::InvalidState(m) => {
CliError::StackNotFound(m) => {
assert!(m.contains("no commit found matching Change-Id"), "got: {m}");
}
other => panic!("unexpected: {other:?}"),
Expand Down
11 changes: 6 additions & 5 deletions crates/mergify-stack/src/commands/push.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,12 @@ pub async fn run(opts: &Options<'_>) -> Result<Outcome, CliError> {

let (remote, base_branch) = opts.trunk;
if base_branch == dest_branch {
return Err(CliError::InvalidState(format!(
"your local branch `{dest_branch}` targets itself: \
`{remote}/{base_branch}`. \
Switch off the trunk branch before pushing.",
)));
return Err(stack_context::targets_itself_error(
Some(&repo_dir),
&dest_branch,
remote,
base_branch,
));
}

let stack_prefix = if opts.branch_prefix.is_empty() {
Expand Down
19 changes: 9 additions & 10 deletions crates/mergify-stack/src/commands/reorder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,23 +131,22 @@ fn match_commit(prefix: &str, commits: &[LocalCommit]) -> Result<OrderedCommit,
)
};
match matches.as_slice() {
[] => Err(CliError::StackNotFound(format!(
"no commit found matching {field} prefix '{prefix}'"
))),
[] => Err(crate::match_commit::not_found(field, prefix)),
[only] => Ok(OrderedCommit {
sha: only.commit_sha.clone(),
subject: only.title.clone(),
change_id: only.change_id.clone(),
}),
many => {
let listing = many
let candidates: Vec<crate::match_commit::Candidate<'_>> = many
.iter()
.map(|c| format!("{} {}", &c.commit_sha[..7], c.title))
.collect::<Vec<_>>()
.join("\n ");
Err(CliError::InvalidState(format!(
"{field} prefix '{prefix}' matches multiple commits:\n {listing}"
)))
.map(|c| crate::match_commit::Candidate {
commit_sha: &c.commit_sha,
title: &c.title,
change_id: &c.change_id,
})
.collect();
Err(crate::match_commit::ambiguous(field, prefix, &candidates))
}
}
}
Expand Down
Loading
Loading