fix: preserve merge error context (#3)

Problem
- Merge failures currently collapse 405 and 409 responses to generic ServiceError cases, even when Forgejo returns a useful response body.

Change
- Keeps the existing notMergeable and mergeConflict fallbacks for empty responses.
- Preserves non-empty Forgejo response bodies as ServiceError.httpError for merge failures.
- Extracts the common JSON message field for clearer LocalizedError descriptions.

Tests
- DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test

Co-authored-by: Piotr Durlej <pdurlej@users.noreply.github.com>
Reviewed-on: https://codeberg.org/secana/ForgejoKit/pulls/3
This commit is contained in:
pdurlej 2026-05-17 21:19:39 +02:00 committed by secana
parent 2ab5808c34
commit 81d8bc5b4a
3 changed files with 39 additions and 4 deletions

View file

@ -488,7 +488,11 @@ public enum ServiceError: LocalizedError, Sendable {
case .invalidResponse: case .invalidResponse:
"Invalid response from server" "Invalid response from server"
case let .httpError(statusCode, message): case let .httpError(statusCode, message):
message.isEmpty ? "HTTP error: \(statusCode)" : "HTTP \(statusCode): \(message)" if let message = Self.normalizedHTTPMessage(message) {
"HTTP \(statusCode): \(message)"
} else {
"HTTP error: \(statusCode)"
}
case .notMergeable: case .notMergeable:
"This pull request cannot be merged" "This pull request cannot be merged"
case .mergeConflict: case .mergeConflict:
@ -497,4 +501,20 @@ public enum ServiceError: LocalizedError, Sendable {
"Decoding failed: \(detail)" "Decoding failed: \(detail)"
} }
} }
private static func normalizedHTTPMessage(_ message: String) -> String? {
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else {
return nil
}
guard
let data = trimmed.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let jsonMessage = object["message"] as? String
else {
return trimmed
}
let trimmedJSONMessage = jsonMessage.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmedJSONMessage.isEmpty ? trimmed : trimmedJSONMessage
}
} }

View file

@ -173,11 +173,11 @@ public final class PullRequestService: Sendable {
do { do {
_ = try await client.performRequestNoContent(url: url, method: "POST", body: jsonData, validateStatus: true) _ = try await client.performRequestNoContent(url: url, method: "POST", body: jsonData, validateStatus: true)
} catch let error as ServiceError { } catch let error as ServiceError {
if case let .httpError(statusCode, _) = error { if case let .httpError(statusCode, message) = error {
switch statusCode { switch statusCode {
case 405: case 405 where message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty:
throw ServiceError.notMergeable throw ServiceError.notMergeable
case 409: case 409 where message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty:
throw ServiceError.mergeConflict throw ServiceError.mergeConflict
default: default:
throw error throw error

View file

@ -75,6 +75,21 @@ struct NotificationTests {
#expect(ServiceError.decodingFailed(detail: "missing key").errorDescription == "Decoding failed: missing key") #expect(ServiceError.decodingFailed(detail: "missing key").errorDescription == "Decoding failed: missing key")
} }
@Test func httpErrorDescriptionUsesForgejoMessageBody() {
let error = ServiceError.httpError(
statusCode: 405,
message: #"{"message":"This pull request has failing status checks"}"#,
)
#expect(error.errorDescription == "HTTP 405: This pull request has failing status checks")
}
@Test func httpErrorDescriptionFallsBackToRawBody() {
let error = ServiceError.httpError(statusCode: 500, message: "database unavailable")
#expect(error.errorDescription == "HTTP 500: database unavailable")
}
// MARK: - Full API response decoding // MARK: - Full API response decoding
@Test func decodesNotificationArray() throws { @Test func decodesNotificationArray() throws {