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 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 { throw APIError.invalidResponse } switch http.statusCode { case 200...299: return data case 401, 403: throw APIError.unauthorized case 404: throw APIError.notFound default: let message = (try? JSONDecoder().decode([String: String].self, from: data))?["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 JSONDecoder().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 JSONDecoder().decode(T.self, from: data) } private func postNoBody(_ path: String) async throws { _ = try await request(path, method: "POST") } // 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") } }