import Foundation import SwiftUI enum InboxFilter: String, CaseIterable, Identifiable { case all = "All" case assigned = "Assigned" case created = "By me" case review = "Review" var id: String { rawValue } } /// A PR row enriched with its repo and aggregate status. struct PRRow: Identifiable, Hashable { let repo: Repository let pr: PullRequest let ciStatus: CIStatus let reviewState: ReviewState var id: String { "\(repo.fullName)#\(pr.number)" } var fullName: String { repo.fullName } } @MainActor final class InboxViewModel: ObservableObject { @Published var rows: [PRRow] = [] @Published var isLoading = false @Published var errorMessage: String? @Published var filter: InboxFilter = .all { didSet { recomputeDisplayed() } } // Raw rows before filtering, kept so switching filters is instant. private var allRows: [PRRow] = [] @Published var displayedRows: [PRRow] = [] var isConfigured: Bool { SettingsStore.shared.isConfigured } private func recomputeDisplayed() { switch filter { case .all: displayedRows = allRows case .assigned: displayedRows = allRows.filter { ($0.pr.assignees ?? []).contains { $0.login == AppSession.currentLogin } } case .created: displayedRows = allRows.filter { $0.pr.user?.login == AppSession.currentLogin } case .review: displayedRows = allRows.filter { switch $0.reviewState { case .needsReview, .unknown: return true default: return false } } } } func load() async { guard isConfigured, let client = SettingsStore.shared.client() else { errorMessage = "Configure your Gitea instance in Settings." return } isLoading = true errorMessage = nil defer { isLoading = false } do { let repos = try await client.repos() // Fetch open PRs across all repos concurrently. var rows: [PRRow] = [] try await withThrowingTaskGroup(of: [PRRow].self) { group in for repo in repos { group.addTask { let prs = try await client.pullRequests(owner: repo.owner.login, repo: repo.name) return try await self.enrich(prs, in: repo, client: client) } } for try await batch in group { rows.append(contentsOf: batch) } } // Sort: most recently updated first. rows.sort { ($0.pr.updatedAt ?? .distantPast) > ($1.pr.updatedAt ?? .distantPast) } allRows = rows recomputeDisplayed() } catch { errorMessage = error.localizedDescription allRows = [] recomputeDisplayed() } } /// Enrich each PR with CI + review status. Runs sequentially per repo to /// avoid hammering the API from one inbox refresh. private func enrich(_ prs: [PullRequest], in repo: Repository, client: GiteaClient) async throws -> [PRRow] { var out: [PRRow] = [] for pr in prs { let sha = pr.head?.sha var ci: CIStatus = .unknown var review: ReviewState = .unknown if let sha, !sha.isEmpty { let runs = (try? await client.runs(owner: repo.owner.login, repo: repo.name, sha: sha)) ?? [] ci = CIStatus.from(runs: runs) } let reviews = (try? await client.reviews(owner: repo.owner.login, repo: repo.name, number: pr.number)) ?? [] review = ReviewState.from(reviews: reviews) out.append(PRRow(repo: repo, pr: pr, ciStatus: ci, reviewState: review)) } return out } }