Skip to content

Commit 53b588d

Browse files
williamdesclaudehappy-otter
committed
fix(gitea): surface Gitea error body in merge/FF failures
Gitea returns 405 Method Not Allowed when a merge *style* (e.g. "fast-forward-only", "merge") is disabled in the repo settings — NOT when the HTTP method is wrong. The previous error only showed the status code, making this indistinguishable from a routing bug: Gitea merge pull request (POST /repos/…/merge) returned HTTP 405 Now the response body's `message` field is extracted and appended: Gitea merge pull request (POST /repos/…/merge) returned HTTP 405: merge style 'merge' is not allowed for this repository This applies to both `fast_forward` and `merge_pull` on GiteaClient. Non-JSON or body-less errors degrade gracefully (empty detail string). Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Happy <yesreply@happy.engineering>
1 parent ba77460 commit 53b588d

2 files changed

Lines changed: 81 additions & 13 deletions

File tree

src/gitea_client.rs

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,22 @@ impl GithubClient for GiteaClient {
125125
._post(url.clone(), Some(&body))
126126
.await
127127
.with_context(|| format!("failed to fast-forward via POST {}", url))?;
128-
ensure_success(
129-
resp.status().as_u16(),
130-
&format!("fast-forward (POST {})", url),
131-
)
128+
let status = resp.status().as_u16();
129+
if (200..300).contains(&status) {
130+
return Ok(());
131+
}
132+
let detail = self
133+
.inner
134+
.body_to_string(resp)
135+
.await
136+
.map(|b| extract_message(&b))
137+
.unwrap_or_default();
138+
Err(anyhow!(
139+
"Gitea fast-forward (POST {}) returned HTTP {}: {}",
140+
url,
141+
status,
142+
detail
143+
))
132144
}
133145

134146
async fn merge_pull(
@@ -148,10 +160,22 @@ impl GithubClient for GiteaClient {
148160
._post(url.clone(), Some(&body))
149161
.await
150162
.with_context(|| format!("failed to send merge request to POST {}", url))?;
151-
ensure_success(
152-
resp.status().as_u16(),
153-
&format!("merge pull request (POST {})", url),
154-
)
163+
let status = resp.status().as_u16();
164+
if (200..300).contains(&status) {
165+
return Ok(());
166+
}
167+
let detail = self
168+
.inner
169+
.body_to_string(resp)
170+
.await
171+
.map(|b| extract_message(&b))
172+
.unwrap_or_default();
173+
Err(anyhow!(
174+
"Gitea merge pull request (POST {}) returned HTTP {}: {}",
175+
url,
176+
status,
177+
detail
178+
))
155179
}
156180

157181
async fn remove_label(
@@ -183,6 +207,15 @@ impl GithubClient for GiteaClient {
183207
}
184208
}
185209

210+
/// Parse Gitea's `{ "message": "..." }` JSON envelope, falling back to
211+
/// the raw body string if it's not JSON.
212+
fn extract_message(body: &str) -> String {
213+
serde_json::from_str::<serde_json::Value>(body)
214+
.ok()
215+
.and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
216+
.unwrap_or_else(|| body.to_string())
217+
}
218+
186219
fn ensure_success(status: u16, what: &str) -> Result<()> {
187220
if (200..300).contains(&status) {
188221
Ok(())
@@ -466,4 +499,25 @@ mod tests {
466499
assert_eq!(pr.labels[0].name, "merge-it");
467500
assert_eq!(pr.labels[1].name, "release");
468501
}
502+
503+
#[test]
504+
fn extract_message_gets_json_message_field() {
505+
let body = r#"{"message":"merge style 'merge' is not allowed for this repository"}"#;
506+
assert_eq!(
507+
extract_message(body),
508+
"merge style 'merge' is not allowed for this repository"
509+
);
510+
}
511+
512+
#[test]
513+
fn extract_message_falls_back_to_raw_body_for_non_json() {
514+
assert_eq!(extract_message("Not Found"), "Not Found");
515+
}
516+
517+
#[test]
518+
fn extract_message_falls_back_when_no_message_field() {
519+
let body = r#"{"error":"something"}"#;
520+
// No `message` key — returns the raw body.
521+
assert_eq!(extract_message(body), body);
522+
}
469523
}

tests/wire.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,14 @@ async fn gitea_merge_pull_surfaces_4xx_as_error() {
185185
"expected URL path in error for diagnostics: {}",
186186
err
187187
);
188+
// Gitea's response body contains a `message` field with the actual
189+
// reason (e.g. "merge style is not allowed"). It must surface in the
190+
// error so the user doesn't need to guess.
191+
assert!(
192+
err.contains("Pull request is not mergeable"),
193+
"expected Gitea's error message in output: {}",
194+
err
195+
);
188196
}
189197

190198
#[tokio::test]
@@ -254,14 +262,15 @@ async fn gitea_fast_forward_uses_post_on_merge_endpoint_with_fast_forward_only_d
254262

255263
#[tokio::test]
256264
async fn gitea_fast_forward_surfaces_4xx_with_url_in_error() {
257-
// When Gitea rejects the fast-forward (e.g. older instance without
258-
// `fast-forward-only`, or a non-fast-forwardable head), the error
259-
// must include both the status code and the path so the user can
260-
// see what the action actually called.
265+
// When Gitea rejects the fast-forward (e.g. the merge style is
266+
// disabled in repo settings), the error must include the status code,
267+
// the path, AND Gitea's error message from the response body.
261268
let server = MockServer::start().await;
262269
Mock::given(method("POST"))
263270
.and(path("/repos/octo/widget/pulls/7/merge"))
264-
.respond_with(ResponseTemplate::new(405))
271+
.respond_with(ResponseTemplate::new(405).set_body_json(json!({
272+
"message": "merge style 'fast-forward-only' is not allowed for this repository",
273+
})))
265274
.mount(&server)
266275
.await;
267276

@@ -277,6 +286,11 @@ async fn gitea_fast_forward_surfaces_4xx_with_url_in_error() {
277286
"expected URL path in error for diagnostics: {}",
278287
err
279288
);
289+
assert!(
290+
err.contains("is not allowed for this repository"),
291+
"expected Gitea's error message in output: {}",
292+
err
293+
);
280294
}
281295

282296
#[tokio::test]

0 commit comments

Comments
 (0)