Build iOS App / Build .ipa and publish source (push) Successful in 22s
When a PR's head branch is behind main (or conflicts), Gitea's merge returns
409. Previously the app just showed an error. Now: on a 409 the app calls
POST /pulls/{index}/update (merges base into head), then retries the merge
once. Add APIError.mergeConflict for a clear message and map 409 in the
client. Bump to 2.6.
286 lines
12 KiB
Swift
286 lines
12 KiB
Swift
import Foundation
|
|
|
|
/// Thin async/await wrapper around the Gitea REST API.
|
|
///
|
|
/// Gitea endpoints used:
|
|
/// GET /user/repos — all accessible repos
|
|
/// GET /repos/{o}/{r}/pulls?state=open — open PRs (multi-repo inbox)
|
|
/// GET /repos/{o}/{r}/pulls/{n} — PR detail
|
|
/// GET /repos/{o}/{r}/pulls/{n}/files — changed files
|
|
/// GET /repos/{o}/{r}/pulls/{n}/commits — commits
|
|
/// GET /repos/{o}/{r}/issues/{n}/comments — PR comments
|
|
/// GET /repos/{o}/{r}/pulls/{n}/reviews — review submissions
|
|
/// POST /repos/{o}/{r}/pulls/{n}/reviews — approve / request changes / comment
|
|
/// POST /repos/{o}/{r}/issues/{n}/comments — post comment
|
|
/// POST /repos/{o}/{r}/pulls/{n}/merge — merge
|
|
/// PATCH /repos/{o}/{r}/pulls/{n} — close / reopen
|
|
/// GET /repos/{o}/{r}/actions/runs?head_sha=... — CI runs for the PR head
|
|
/// GET /repos/{o}/{r}/actions/runs/{id}/jobs — jobs in a run
|
|
/// POST /repos/{o}/{r}/actions/runs/{id}/rerun — re-run a failed run
|
|
struct GiteaClient {
|
|
let instanceURL: URL
|
|
let token: String
|
|
let session: URLSession
|
|
|
|
/// Shared decoder. Gitea returns ISO8601 date strings WITH a timezone
|
|
/// offset (e.g. "2026-08-12T11:09:13+02:00"), which neither the default
|
|
/// `.deferredToDate` strategy (expects a Unix timestamp number) nor
|
|
/// `.iso8601` (expects a trailing "Z") can parse. Without this, decoding
|
|
/// any `Date?` field throws and the whole array fails — the inbox shows
|
|
/// nothing even though the data is present.
|
|
private static let decoder: JSONDecoder = {
|
|
let d = JSONDecoder()
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime,
|
|
.withFractionalSeconds]
|
|
d.dateDecodingStrategy = .custom { decoder in
|
|
let container = try decoder.singleValueContainer()
|
|
let str = try container.decode(String.self)
|
|
if let date = formatter.date(from: str) { return date }
|
|
// Fall back to a formatter without fractional seconds.
|
|
let plain = ISO8601DateFormatter()
|
|
plain.formatOptions = [.withInternetDateTime]
|
|
if let date = plain.date(from: str) { return date }
|
|
throw DecodingError.dataCorruptedError(
|
|
in: container,
|
|
debugDescription: "Cannot decode date string \(str)")
|
|
}
|
|
return d
|
|
}()
|
|
|
|
init(instanceURL: URL, token: String, session: URLSession = .shared) {
|
|
self.instanceURL = instanceURL
|
|
self.token = token
|
|
self.session = session
|
|
}
|
|
|
|
private func request(_ path: String,
|
|
method: String = "GET",
|
|
query: [URLQueryItem]? = nil,
|
|
body: Data? = nil) async throws -> Data {
|
|
var url = instanceURL.appendingPathComponent("api/v1").appendingPathComponent(path)
|
|
if let query, !query.isEmpty {
|
|
var comps = URLComponents(url: url, resolvingAgainstBaseURL: false)!
|
|
var items = comps.queryItems ?? []
|
|
items.append(contentsOf: query)
|
|
comps.queryItems = items
|
|
url = comps.url!
|
|
}
|
|
|
|
var req = URLRequest(url: url)
|
|
req.httpMethod = method
|
|
req.setValue("token \(token)", forHTTPHeaderField: "Authorization")
|
|
req.setValue("application/json", forHTTPHeaderField: "Accept")
|
|
// Bound every request so a hung call can't leave a spinner forever.
|
|
req.timeoutInterval = 30
|
|
if let body {
|
|
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
req.httpBody = body
|
|
}
|
|
|
|
let (data, response) = try await session.data(for: req)
|
|
guard let http = response as? HTTPURLResponse else {
|
|
await LogStore.shared.log(.error, "\(method) \(path) → invalid response")
|
|
throw APIError.invalidResponse
|
|
}
|
|
switch http.statusCode {
|
|
case 200...299:
|
|
await LogStore.shared.log(.info, "\(method) \(path) → \(http.statusCode)")
|
|
return data
|
|
case 401, 403:
|
|
await LogStore.shared.log(.error, "\(method) \(path) → \(http.statusCode) unauthorized")
|
|
throw APIError.unauthorized
|
|
case 404:
|
|
await LogStore.shared.log(.error, "\(method) \(path) → 404 not found")
|
|
throw APIError.notFound
|
|
case 409:
|
|
await LogStore.shared.log(.warn, "\(method) \(path) → 409 conflict")
|
|
throw APIError.mergeConflict
|
|
default:
|
|
let message = (try? Self.decoder.decode([String: String].self,
|
|
from: data))?["message"]
|
|
await LogStore.shared.log(.error, "\(method) \(path) → \(http.statusCode) \(message ?? "")")
|
|
throw APIError.httpStatus(http.statusCode, message)
|
|
}
|
|
}
|
|
|
|
private func get<T: Decodable>(_ path: String,
|
|
query: [URLQueryItem]? = nil) async throws -> T {
|
|
let data = try await request(path, query: query)
|
|
do {
|
|
return try Self.decoder.decode(T.self, from: data)
|
|
} catch {
|
|
throw APIError.decoding(String(describing: error))
|
|
}
|
|
}
|
|
|
|
private func post<T: Decodable>(_ path: String, body: Data? = nil) async throws -> T {
|
|
let data = try await request(path, method: "POST", body: body)
|
|
return try Self.decoder.decode(T.self, from: data)
|
|
}
|
|
|
|
private func postNoBody(_ path: String) async throws {
|
|
_ = try await request(path, method: "POST")
|
|
}
|
|
|
|
/// Raw unified diff for a PR as plain text (from the `.diff` endpoint).
|
|
/// This Gitea instance does NOT return `patch` per file in
|
|
/// `/pulls/{n}/files` (the field is absent), so the diff must come from
|
|
/// here. The response is `text/plain`, not JSON.
|
|
func diff(owner: String, repo: String, number: Int) async throws -> String {
|
|
let data = try await request("repos/\(owner)/\(repo)/pulls/\(number).diff")
|
|
guard let text = String(data: data, encoding: .utf8) else {
|
|
throw APIError.invalidResponse
|
|
}
|
|
return text
|
|
}
|
|
|
|
// MARK: - User
|
|
|
|
/// Current authenticated user (for the "Test connection" button and
|
|
/// for resolving the current login used by inbox filters).
|
|
func currentUser() async throws -> PRUser {
|
|
try await get("user")
|
|
}
|
|
|
|
// MARK: - Repos
|
|
|
|
func repos() async throws -> [Repository] {
|
|
var all: [Repository] = []
|
|
// Fetch paginated. Page size 50; multi-repo inbox across a homelab is
|
|
// typically < 50 repos, but loop to be safe.
|
|
for page in 1...10 {
|
|
let batch: [Repository] = try await get(
|
|
"user/repos",
|
|
query: [URLQueryItem(name: "page", value: "\(page)"),
|
|
URLQueryItem(name: "limit", value: "50")])
|
|
all.append(contentsOf: batch)
|
|
if batch.count < 50 { break }
|
|
}
|
|
return all
|
|
}
|
|
|
|
// MARK: - Pull requests
|
|
|
|
func pullRequests(owner: String, repo: String) async throws -> [PullRequest] {
|
|
try await get("repos/\(owner)/\(repo)/pulls",
|
|
query: [URLQueryItem(name: "state", value: "open"),
|
|
URLQueryItem(name: "limit", value: "100")])
|
|
}
|
|
|
|
func pullRequest(owner: String, repo: String, number: Int) async throws -> PullRequest {
|
|
try await get("repos/\(owner)/\(repo)/pulls/\(number)")
|
|
}
|
|
|
|
func changedFiles(owner: String, repo: String, number: Int) async throws -> [ChangedFile] {
|
|
try await get("repos/\(owner)/\(repo)/pulls/\(number)/files")
|
|
}
|
|
|
|
func commits(owner: String, repo: String, number: Int) async throws -> [PRCommit] {
|
|
try await get("repos/\(owner)/\(repo)/pulls/\(number)/commits")
|
|
}
|
|
|
|
func reviews(owner: String, repo: String, number: Int) async throws -> [PullReview] {
|
|
try await get("repos/\(owner)/\(repo)/pulls/\(number)/reviews")
|
|
}
|
|
|
|
func comments(owner: String, repo: String, number: Int) async throws -> [IssueComment] {
|
|
try await get("repos/\(owner)/\(repo)/issues/\(number)/comments")
|
|
}
|
|
|
|
func postComment(owner: String, repo: String, number: Int,
|
|
body: String) async throws -> IssueComment {
|
|
struct Body: Encodable { let body: String }
|
|
return try await post(
|
|
"repos/\(owner)/\(repo)/issues/\(number)/comments",
|
|
body: try JSONEncoder().encode(Body(body: body)))
|
|
}
|
|
|
|
func submitReview(owner: String, repo: String, number: Int,
|
|
state: String, comment: String?) async throws -> PullReview {
|
|
struct Body: Encodable {
|
|
let event: String // APPROVED | REQUEST_CHANGES | COMMENT
|
|
let body: String?
|
|
}
|
|
let event: String
|
|
switch state {
|
|
case "approved": event = "APPROVED"
|
|
case "changes": event = "REQUEST_CHANGES"
|
|
default: event = "COMMENT"
|
|
}
|
|
return try await post(
|
|
"repos/\(owner)/\(repo)/pulls/\(number)/reviews",
|
|
body: try JSONEncoder().encode(Body(event: event, body: comment)))
|
|
}
|
|
|
|
func merge(owner: String, repo: String, number: Int,
|
|
method: String, deleteBranchAfterMerge: Bool) async throws {
|
|
struct Body: Encodable {
|
|
let Do: String // merge|rebase|squash
|
|
let delete_branch_after_merge: Bool
|
|
}
|
|
_ = try await request(
|
|
"repos/\(owner)/\(repo)/pulls/\(number)/merge",
|
|
method: "POST",
|
|
body: try JSONEncoder().encode(Body(Do: method,
|
|
delete_branch_after_merge: deleteBranchAfterMerge)))
|
|
}
|
|
|
|
/// Delete a branch (used after merging when the setting is enabled).
|
|
func deleteBranch(owner: String, repo: String, branch: String) async throws {
|
|
let encoded = branch.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? branch
|
|
_ = try await request("repos/\(owner)/\(repo)/branches/\(encoded)",
|
|
method: "DELETE")
|
|
}
|
|
|
|
/// Merge the base branch into the head branch (`POST /pulls/{index}/update`).
|
|
/// When a PR is behind `main` (e.g. conflicts), the merge returns 409; this
|
|
/// rebases/syncs the head onto the base so it can then be merged.
|
|
func updateBranch(owner: String, repo: String, number: Int) async throws {
|
|
_ = try await request("repos/\(owner)/\(repo)/pulls/\(number)/update",
|
|
method: "POST")
|
|
}
|
|
|
|
func setState(owner: String, repo: String, number: Int,
|
|
state: String) async throws {
|
|
struct Body: Encodable { let state: String }
|
|
let body = try JSONEncoder().encode(Body(state: state))
|
|
let data = try await request(
|
|
"repos/\(owner)/\(repo)/pulls/\(number)",
|
|
method: "PATCH", body: body)
|
|
_ = data
|
|
}
|
|
|
|
// MARK: - Actions / CI
|
|
|
|
func runs(owner: String, repo: String, sha: String) async throws -> [ActionRun] {
|
|
struct Envelope: Decodable { let workflow_runs: [ActionRun]? }
|
|
let env: Envelope = try await get(
|
|
"repos/\(owner)/\(repo)/actions/runs",
|
|
query: [URLQueryItem(name: "head_sha", value: sha),
|
|
URLQueryItem(name: "limit", value: "20")])
|
|
return env.workflow_runs ?? []
|
|
}
|
|
|
|
func jobs(owner: String, repo: String, runID: Int64) async throws -> [ActionJob] {
|
|
struct Envelope: Decodable { let jobs: [ActionJob]? }
|
|
let env: Envelope = try await get(
|
|
"repos/\(owner)/\(repo)/actions/runs/\(runID)/jobs")
|
|
return env.jobs ?? []
|
|
}
|
|
|
|
func rerun(owner: String, repo: String, runID: Int64) async throws {
|
|
try await postNoBody("repos/\(owner)/\(repo)/actions/runs/\(runID)/rerun")
|
|
}
|
|
|
|
/// Raw log text for a single job (from `/actions/jobs/{job_id}/logs`).
|
|
/// The response is plain text, not JSON.
|
|
func jobLogs(owner: String, repo: String, jobID: Int64) async throws -> String {
|
|
let data = try await request("repos/\(owner)/\(repo)/actions/jobs/\(jobID)/logs")
|
|
guard let text = String(data: data, encoding: .utf8) else {
|
|
throw APIError.invalidResponse
|
|
}
|
|
return text
|
|
}
|
|
}
|