import Foundation struct GiteaClient { let baseURL: URL let token: String init() throws { guard AppSettings.isConfigured else { throw GiteaError.notConfigured } guard let url = URL(string: AppSettings.baseURL) else { throw GiteaError.invalidBaseURL } self.baseURL = url self.token = AppSettings.token } func currentUser() async throws -> GiteaUser { let req = makeRequest(path: "/api/v1/user") let (data, resp) = try await URLSession.shared.data(for: req) try ensureOK(resp, data: data) return try decode(data) } func searchRepos() async throws -> [GiteaRepo] { var components = URLComponents(url: baseURL.appendingPathComponent("/api/v1/repos/search"), resolvingAgainstBaseURL: false)! components.queryItems = [ URLQueryItem(name: "limit", value: "50"), URLQueryItem(name: "sort", value: "updated"), URLQueryItem(name: "order", value: "desc"), ] var req = URLRequest(url: components.url!) req.setValue("token \(token)", forHTTPHeaderField: "Authorization") let (data, resp) = try await URLSession.shared.data(for: req) try ensureOK(resp, data: data) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let result = try decoder.decode(GiteaRepoSearch.self, from: data) return result.data } func createIssue(owner: String, repo: String, title: String, body: String) async throws -> GiteaIssue { let payload = ["title": title, "body": body] var req = makeRequest(path: "/api/v1/repos/\(owner)/\(repo)/issues", method: "POST") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONSerialization.data(withJSONObject: payload) let (data, resp) = try await URLSession.shared.data(for: req) try ensureOK(resp, data: data) return try decode(data) } func uploadAsset(owner: String, repo: String, issueNumber: Int, image: Data, filename: String) async throws -> GiteaIssueAsset { let boundary = "Boundary-\(UUID().uuidString)" var req = makeRequest(path: "/api/v1/repos/\(owner)/\(repo)/issues/\(issueNumber)/assets", method: "POST") req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") var body = Data() body.append("--\(boundary)\r\n".data(using: .utf8)!) body.append("Content-Disposition: form-data; name=\"attachment\"; filename=\"\(filename)\"\r\n".data(using: .utf8)!) body.append("Content-Type: image/png\r\n\r\n".data(using: .utf8)!) body.append(image) body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!) req.httpBody = body let (data, resp) = try await URLSession.shared.data(for: req) try ensureOK(resp, data: data) return try decode(data) } func updateIssueBody(owner: String, repo: String, issueNumber: Int, body: String) async throws { let payload = ["body": body] var req = makeRequest(path: "/api/v1/repos/\(owner)/\(repo)/issues/\(issueNumber)", method: "PATCH") req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONSerialization.data(withJSONObject: payload) let (data, resp) = try await URLSession.shared.data(for: req) try ensureOK(resp, data: data) } private func makeRequest(path: String, method: String = "GET") -> URLRequest { var req = URLRequest(url: baseURL.appendingPathComponent(path)) req.httpMethod = method req.setValue("token \(token)", forHTTPHeaderField: "Authorization") req.setValue("application/json", forHTTPHeaderField: "Accept") return req } private func ensureOK(_ resp: URLResponse, data: Data) throws { guard let http = resp as? HTTPURLResponse else { return } if (200..<300).contains(http.statusCode) { return } let body = String(data: data, encoding: .utf8) ?? "" throw GiteaError.http(http.statusCode, body) } private func decode(_ data: Data) throws -> T { do { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(T.self, from: data) } catch { throw GiteaError.decoding(String(describing: error)) } } }