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 { 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 } } }