Files
ios-gitea-prs/GiteaPR/Detail/PRDetailViewModel.swift
T
Hermes 9e71b6a8b9
Build iOS App / Build .ipa and publish source (push) Successful in 23s
Make PR detail load resilient to per-endpoint 403; bump to 1.3
Opening a PR could fail the whole screen when any one detail sub-request
returns 401/403 (e.g. a token scope the repo's Actions endpoint needs).
Refactor load() so the PR fetch is the only fatal call; comments/reviews/
files/commits/runs each fail independently, are logged, and the rest still
render. Also log actions/runs failures for visibility. Bump to 1.3.
2026-08-12 21:00:22 +00:00

159 lines
6.1 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"
}
}
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 {
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.
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 }
busyMessage = "Submitting…"
defer { busyMessage = nil }
do {
let r = try await client.submitReview(owner: owner, repo: repo.name,
number: prNumber, state: state,
comment: comment)
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 {
try await client.merge(owner: owner, repo: repo.name,
number: prNumber, method: method)
// Refresh the PR state to reflect the merge.
pr = try? await client.pullRequest(owner: owner, repo: repo.name,
number: prNumber)
return true
} 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
}
}
}