import Foundation /// Uploads the social.org file to a GitHub repository via the Contents API. /// /// Uses `GET /repos/{owner}/{repo}/contents/{path}` to fetch the current SHA, /// then `PUT` to create or update the file. /// Required token scope: `repo` (or `public_repo` for public repositories). public struct GitHubUploader: FeedUploader { public let token: String public let owner: String public let repo: String public let path: String public let branch: String private let session: URLSession private static let apiBase = "https://api.github.com" public init( token: String, owner: String, repo: String, path: String = "social.org", branch: String = "main", session: URLSession = .shared ) { self.token = token self.owner = owner self.repo = repo self.path = path self.branch = branch self.session = session } public func upload(content: String) async throws { let sha = try? await fetchCurrentSHA() let encoded = Data(content.utf8).base64EncodedString() var body: [String: Any] = [ "message": "Update social.org via iOS", "content": encoded, "branch": branch ] if let sha { body["sha"] = sha } let url = try contentsURL() var request = URLRequest(url: url) request.httpMethod = "PUT" request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try? JSONSerialization.data(withJSONObject: body) let (_, response) = try await perform(request) if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { throw UploadError.uploadFailed(statusCode: http.statusCode) } } private func fetchCurrentSHA() async throws -> String { let url = try contentsURL() var request = URLRequest(url: url) request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") let (data, response) = try await perform(request) if let http = response as? HTTPURLResponse, http.statusCode == 404 { throw UploadError.fileNotFound } guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let sha = json["sha"] as? String else { throw UploadError.fileNotFound } return sha } private func contentsURL() throws -> URL { let urlString = "\(Self.apiBase)/repos/\(owner)/\(repo)/contents/\(path)" guard let url = URL(string: urlString) else { throw UploadError.invalidConfiguration("Invalid GitHub API URL: \(urlString)") } return url } private func perform(_ request: URLRequest) async throws -> (Data, URLResponse) { do { return try await session.data(for: request) } catch { throw UploadError.networkError(underlying: error.localizedDescription) } } /// Derives the public raw URL for the file (usable as `publicFeedURL`). public var derivedPublicURL: String { "https://raw.githubusercontent.com/\(owner)/\(repo)/\(branch)/\(path)" } }