Build iOS App / Build .ipa and publish source (push) Successful in 21s
This Gitea instance never returns a 'patch' field from /pulls/{n}/files (the
field is absent for all files, regardless of size), so the diff viewer always
fell back to 'Diff too large to preview'. Now DiffView fetches the PR's .diff
endpoint (plain-text unified diff), extracts the hunks for the selected file,
and renders them colour-coded. Bump to 1.4.
252 lines
11 KiB
Swift
252 lines
11 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")
|
|
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
|
|
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) async throws {
|
|
struct Body: Encodable { let Do: String } // 'Do' = merge method: merge|rebase|squash
|
|
_ = try await request(
|
|
"repos/\(owner)/\(repo)/pulls/\(number)/merge",
|
|
method: "POST",
|
|
body: try JSONEncoder().encode(Body(Do: method)))
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|