Build iOS App / Build .ipa and publish source (push) Successful in 1m44s
Pull-to-refresh cancels the still-in-flight request (URLError.cancelled, localizedDescription 'cancelled') to run the refresh, which surfaced as an error dialog. Add an isCancellation helper and skip cancellations in the PR load and CI-runs paths. Bump to 3.5.
208 lines
8.8 KiB
Swift
208 lines
8.8 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
final class PRDetailViewModel: ObservableObject {
|
|
let repo: Repository
|
|
let prNumber: Int
|
|
|
|
@Published var pr: PullRequest?
|
|
@Published var comments: [IssueComment] = []
|
|
@Published var reviews: [PullReview] = []
|
|
@Published var files: [ChangedFile] = []
|
|
@Published var commits: [PRCommit] = []
|
|
@Published var runs: [ActionRun] = []
|
|
@Published var jobs: [Int64: [ActionJob]] = [:]
|
|
|
|
@Published var isLoading = false
|
|
@Published var isActionsLoading = false
|
|
@Published var errorMessage: String?
|
|
@Published var busyMessage: String?
|
|
|
|
private var owner: String { repo.owner.login }
|
|
|
|
init(repo: Repository, prNumber: Int) {
|
|
self.repo = repo
|
|
self.prNumber = prNumber
|
|
}
|
|
|
|
var ciStatus: CIStatus { CIStatus.from(runs: runs) }
|
|
var reviewState: ReviewState { ReviewState.from(reviews: reviews) }
|
|
|
|
var hasFailedRun: Bool {
|
|
runs.contains {
|
|
let c = ($0.conclusion ?? "").lowercased()
|
|
return c == "failure" || c == "cancelled"
|
|
}
|
|
}
|
|
|
|
/// A task cancellation is not a real failure — it happens when a refresh
|
|
/// (pull-to-refresh / .task / .onAppear) supersedes an in-flight request,
|
|
/// and surfaces as URLError.cancelled ("cancelled"). These must not be
|
|
/// shown as errors.
|
|
private func isCancellation(_ error: Error) -> Bool {
|
|
if let urlError = error as? URLError {
|
|
return urlError.code == .cancelled
|
|
}
|
|
let ns = error as NSError
|
|
return ns.domain == NSURLErrorDomain && ns.code == NSURLErrorCancelled
|
|
}
|
|
|
|
func load() async {
|
|
guard let client = SettingsStore.shared.client() else { return }
|
|
isLoading = true
|
|
errorMessage = nil
|
|
defer { isLoading = false }
|
|
// Fetch each piece independently. A single sub-request returning 403
|
|
// (e.g. a token scope the repo's Actions needs) must NOT wipe the whole
|
|
// detail screen — catch it, log it, keep the rest. Only a failure to
|
|
// load the PR itself is fatal.
|
|
do {
|
|
pr = try await client.pullRequest(owner: owner, repo: repo.name, number: prNumber)
|
|
} catch {
|
|
if isCancellation(error) { return } // pull-to-refresh superseded; not a real error
|
|
LogStore.shared.log(.error, "PR \(prNumber): load failed: \(error.localizedDescription)")
|
|
errorMessage = error.localizedDescription
|
|
return
|
|
}
|
|
async let commentsT = client.comments(owner: owner, repo: repo.name, number: prNumber)
|
|
async let reviewsT = client.reviews(owner: owner, repo: repo.name, number: prNumber)
|
|
async let filesT = client.changedFiles(owner: owner, repo: repo.name, number: prNumber)
|
|
async let commitsT = client.commits(owner: owner, repo: repo.name, number: prNumber)
|
|
comments = (try? await commentsT) ?? []
|
|
reviews = (try? await reviewsT) ?? []
|
|
files = (try? await filesT) ?? []
|
|
commits = (try? await commitsT) ?? []
|
|
// CI runs depend on the head SHA from the PR.
|
|
if let sha = pr?.head?.sha, !sha.isEmpty {
|
|
await loadRuns(client: client, sha: sha)
|
|
}
|
|
}
|
|
|
|
private func loadRuns(client: GiteaClient, sha: String) async {
|
|
isActionsLoading = true
|
|
defer { isActionsLoading = false }
|
|
do {
|
|
runs = try await client.runs(owner: owner, repo: repo.name, sha: sha)
|
|
// Load jobs for each run concurrently.
|
|
for run in runs {
|
|
let jobList = (try? await client.jobs(owner: owner, repo: repo.name,
|
|
runID: run.id)) ?? []
|
|
jobs[run.id] = jobList
|
|
}
|
|
} catch {
|
|
// CI is best-effort; don't fail the whole detail view, but log it so
|
|
// a token-scope 403 on the Actions endpoint is visible. Ignore
|
|
// cancellations (refresh superseding an in-flight request).
|
|
if !isCancellation(error) {
|
|
LogStore.shared.log(.warn, "PR \(prNumber): actions/runs failed: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
func rerun(runID: Int64) async {
|
|
guard let client = SettingsStore.shared.client() else { return }
|
|
busyMessage = "Re-running job…"
|
|
defer { busyMessage = nil }
|
|
do {
|
|
try await client.rerun(owner: owner, repo: repo.name, runID: runID)
|
|
if let sha = pr?.head?.sha {
|
|
await loadRuns(client: client, sha: sha)
|
|
}
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func postComment(_ body: String) async {
|
|
guard let client = SettingsStore.shared.client() else { return }
|
|
busyMessage = "Posting…"
|
|
defer { busyMessage = nil }
|
|
do {
|
|
let c = try await client.postComment(owner: owner, repo: repo.name,
|
|
number: prNumber, body: body)
|
|
comments.append(c)
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func submitReview(state: String, comment: String?) async {
|
|
guard let client = SettingsStore.shared.client() else { return }
|
|
// Guard against concurrent submissions: rapid taps on Approve used to
|
|
// fire several requests that each appended a review, stacking
|
|
// duplicate entries. Skip if one is already in flight.
|
|
guard busyMessage == nil else { return }
|
|
busyMessage = "Submitting…"
|
|
defer { busyMessage = nil }
|
|
do {
|
|
let r = try await client.submitReview(owner: owner, repo: repo.name,
|
|
number: prNumber, state: state,
|
|
comment: comment)
|
|
// A user's latest review replaces their earlier ones (Gitea stores
|
|
// one review per user per PR). Drop any prior review by this user
|
|
// so we don't render duplicates.
|
|
reviews.removeAll { $0.user?.login == r.user?.login }
|
|
reviews.append(r)
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
|
|
func merge(method: String) async -> Bool {
|
|
guard let client = SettingsStore.shared.client() else { return false }
|
|
busyMessage = "Merging…"
|
|
defer { busyMessage = nil }
|
|
do {
|
|
let deleteBranch = SettingsStore.shared.deleteBranchAfterMerge
|
|
try await client.merge(owner: owner, repo: repo.name,
|
|
number: prNumber, method: method,
|
|
deleteBranchAfterMerge: deleteBranch)
|
|
// If the setting is on, delete the head branch after a successful
|
|
// merge (best-effort; the branch may already be gone or protected).
|
|
if deleteBranch, let head = pr?.head?.ref {
|
|
try? await client.deleteBranch(owner: owner, repo: repo.name,
|
|
branch: head)
|
|
}
|
|
// Refresh the PR state to reflect the merge.
|
|
pr = try? await client.pullRequest(owner: owner, repo: repo.name,
|
|
number: prNumber)
|
|
return true
|
|
} catch APIError.mergeConflict {
|
|
// Branch is behind main (or conflicts). Update it and retry once.
|
|
busyMessage = "Updating branch…"
|
|
do {
|
|
try await client.updateBranch(owner: owner, repo: repo.name,
|
|
number: prNumber)
|
|
busyMessage = "Merging…"
|
|
try await client.merge(owner: owner, repo: repo.name,
|
|
number: prNumber, method: method,
|
|
deleteBranchAfterMerge: SettingsStore.shared.deleteBranchAfterMerge)
|
|
pr = try? await client.pullRequest(owner: owner, repo: repo.name,
|
|
number: prNumber)
|
|
return true
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
return false
|
|
}
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
return false
|
|
}
|
|
}
|
|
|
|
func setState(_ state: String) async {
|
|
guard let client = SettingsStore.shared.client() else { return }
|
|
busyMessage = "Updating…"
|
|
defer { busyMessage = nil }
|
|
do {
|
|
try await client.setState(owner: owner, repo: repo.name,
|
|
number: prNumber, state: state)
|
|
pr = try? await client.pullRequest(owner: owner, repo: repo.name,
|
|
number: prNumber)
|
|
} catch {
|
|
errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
}
|