GiteaPR: native iOS app to manage Gitea pull requests
Build iOS App / Build .ipa and publish source (push) Failing after 35s

Multi-repo PR inbox, review (approve/request changes/comment), merge with
gating toggle, read-only diff viewer, and Gitea Actions check status with
one-tap re-run. Ships unsigned via the iMac runner to the SideStore source.
This commit is contained in:
Hermes committed 2026-08-12 17:07:58 +00:00
commit 1025634978
27 files changed
+2322

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
import Foundation
enum APIError: LocalizedError, Equatable {
case invalidURL
case invalidResponse
case httpStatus(Int, String?)
case decoding(String)
case unauthorized
case notFound
var errorDescription: String? {
switch self {
case .invalidURL:
return "The instance URL is invalid."
case .invalidResponse:
return "The server returned an invalid response."
case .httpStatus(let code, let message):
if code == 401 || code == 403 {
return "Authentication failed. Check your token."
}
return message?.isEmpty == false
? "Server error (\(code)): \(message!)"
: "Server error (\(code))."
case .decoding(let detail):
return "Could not parse the server response: \(detail)"
case .unauthorized:
return "Authentication failed. Check your token."
case .notFound:
return "Not found."
}
}
}
+208
View File
@@ -0,0 +1,208 @@
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<T: Decodable>(_ 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<T: Decodable>(_ 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")
}
}
+50
View File
@@ -0,0 +1,50 @@
import Foundation
import Security
/// Minimal Keychain wrapper for the API token and instance URL.
enum KeychainHelper {
private static let service = "com.bueso.giteapr"
static func set(_ value: String, key: String) {
guard let data = value.data(using: .utf8) else { return }
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
// Delete any existing item first (upsert semantics).
SecItemDelete(query as CFDictionary)
var attrs = query
attrs[kSecValueData as String] = data
attrs[kSecAttrAccessible as String] =
kSecAttrAccessibleAfterFirstUnlock
SecItemAdd(attrs as CFDictionary, nil)
}
static func get(_ key: String) -> String? {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecReturnData as String: true,
kSecMatchLimit as String: kSecMatchLimitOne,
]
var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let string = String(data: data, encoding: .utf8) else {
return nil
}
return string
}
static func delete(_ key: String) {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
SecItemDelete(query as CFDictionary)
}
}