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(_ 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(_ 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 } }