import Foundation import Observation /// One merge request: `mr show`, the parsed diff, the review threads, and /// every write the review cycle needs. Writes reload — the server's state /// is the state. @Observable @MainActor final class MRDetailViewModel { private(set) var state: LoadState = .loading private(set) var diff: UnifiedDiff? private(set) var threads: [ReviewThread] = [] /// A write failed; the server's sentence, shown until the next action. private(set) var actionError: String? private(set) var working = false private let client: GitbayClient let repoPath: String let number: Int64 init(client: GitbayClient, repoPath: String, number: Int64) { self.client = client self.repoPath = repoPath self.number = number } private var ref: [String] { [repoPath, String(number)] } func load() async { do { let detail = try await client.read(["mr", "show"] + ref, as: MRDetail.self) state = .loaded(detail) } catch { state = .from(error) return } // The diff and threads are secondary: their failure leaves the // header usable rather than sinking the screen. async let diffText = try? client.readText(["mr", "diff"] + ref) async let threadList = try? client.readList(["mr", "threads"] + ref, of: ReviewThread.self) diff = (await diffText).map(UnifiedDiff.parse) threads = await threadList ?? [] } var unresolvedCount: Int { threads.count { !$0.isResolved } } // MARK: - Writes enum Verdict: String, Sendable { case approve = "--approve" case requestChanges = "--request-changes" } func review(_ verdict: Verdict) async { await perform(["mr", "review"] + ref + [verdict.rawValue]) } /// Both fields are always sent; an empty body clears it. func edit(title: String, body: String) async { await perform( ["mr", "edit"] + ref + ["--title", title, "--file", "-"], stdin: body ) } /// Open milestones for the picker; fetched on first use. private(set) var availableMilestones: [Milestone]? func loadMilestones() async { guard availableMilestones == nil else { return } availableMilestones = (try? await client.readList( ["milestone", "list", repoPath, "--state", "open"], of: Milestone.self )) ?? [] } /// nil clears it; the command spells that "none". func setMilestone(_ title: String?) async { await perform(["mr", "milestone"] + ref + [title ?? "none"]) } func comment(_ text: String) async { // Long text travels in stdin, but the server only reads it when // argv says so: --file - is required, not implied. await perform(["mr", "comment"] + ref + ["--file", "-"], stdin: text) } func merge(strategy: String? = nil) async { var argv = ["mr", "merge"] + ref if let strategy { argv.append(contentsOf: ["--strategy", strategy]) } await perform(argv) } func close() async { await perform(["mr", "close"] + ref) } func setResolved(_ thread: ReviewThread, _ resolved: Bool) async { await perform(["mr", resolved ? "resolve" : "unresolve"] + ref + [String(thread.id)]) } func reply(to thread: ReviewThread, _ text: String) async { await perform( ["mr", "diff-comment"] + ref + ["--reply", String(thread.id), "--file", "-"], stdin: text ) } private func perform(_ argv: [String], stdin: String? = nil) async { working = true actionError = nil defer { working = false } do { try await client.run(argv, stdin: stdin) await load() } catch let error as GitbayError { // A refusal explains the rule (approvals missing, threads // unresolved, checks red). Surface it verbatim; never route // around it. actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } }