gitbay/MRs/MRDetailViewModel.swift

v1.0.6
gitbay-ios/gitbay/MRs/MRDetailViewModel.swift history · blame · raw

171 lines · 6247 bytes

  1import Foundation
  2import Observation
  3
  4/// One merge request: `mr show`, the parsed diff, the review threads, and
  5/// every write the review cycle needs. Writes reload — the server's state
  6/// is the state.
  7@Observable
  8@MainActor
  9final class MRDetailViewModel {
 10
 11    private(set) var state: LoadState<MRDetail> = .loading
 12    private(set) var diff: UnifiedDiff?
 13    private(set) var threads: [ReviewThread] = []
 14    /// A write failed; the server's sentence, shown until the next action.
 15    private(set) var actionError: String?
 16    private(set) var working = false
 17
 18    private let client: GitbayClient
 19    let repoPath: String
 20    let number: Int64
 21
 22    init(client: GitbayClient, repoPath: String, number: Int64) {
 23        self.client = client
 24        self.repoPath = repoPath
 25        self.number = number
 26    }
 27
 28    private var ref: [String] { [repoPath, String(number)] }
 29
 30    func load() async {
 31        do {
 32            let detail = try await client.read(["mr", "show"] + ref, as: MRDetail.self)
 33            state = .loaded(detail)
 34        } catch {
 35            state = .from(error)
 36            return
 37        }
 38        // The diff and threads are secondary: their failure leaves the
 39        // header usable rather than sinking the screen.
 40        async let diffText = try? client.readText(["mr", "diff"] + ref)
 41        async let threadList = try? client.readList(["mr", "threads"] + ref, of: ReviewThread.self)
 42        diff = (await diffText).map(UnifiedDiff.parse)
 43        threads = await threadList ?? []
 44    }
 45
 46    var unresolvedCount: Int {
 47        threads.count { !$0.isResolved }
 48    }
 49
 50    // MARK: - Writes
 51
 52    enum Verdict: String, Sendable {
 53        case approve = "--approve"
 54        case requestChanges = "--request-changes"
 55    }
 56
 57    func review(_ verdict: Verdict) async {
 58        await perform(["mr", "review"] + ref + [verdict.rawValue])
 59    }
 60
 61    /// Both fields are always sent; an empty body clears it. `format` is
 62    /// appended only when given; omitting it lets the server default.
 63    func edit(title: String, body: String, format: String? = nil) async {
 64        var argv = ["mr", "edit"] + ref + ["--title", title, "--file", "-"]
 65        if let format { argv.append(contentsOf: ["--format", format]) }
 66        await perform(argv, stdin: body)
 67    }
 68
 69    /// Open milestones for the picker; fetched on first use.
 70    private(set) var availableMilestones: [Milestone]?
 71
 72    func loadMilestones() async {
 73        guard availableMilestones == nil else { return }
 74        availableMilestones = (try? await client.readList(
 75            ["milestone", "list", repoPath, "--state", "open"], of: Milestone.self
 76        )) ?? []
 77    }
 78
 79    /// nil clears it; the command spells that "none".
 80    func setMilestone(_ title: String?) async {
 81        await perform(["mr", "milestone"] + ref + [title ?? "none"])
 82    }
 83
 84    func comment(_ text: String, format: String? = nil) async {
 85        // Long text travels in stdin, but the server only reads it when
 86        // argv says so: --file - is required, not implied.
 87        var argv = ["mr", "comment"] + ref + ["--file", "-"]
 88        if let format { argv.append(contentsOf: ["--format", format]) }
 89        await perform(argv, stdin: text)
 90    }
 91
 92    func merge(strategy: String? = nil) async {
 93        var argv = ["mr", "merge"] + ref
 94        if let strategy { argv.append(contentsOf: ["--strategy", strategy]) }
 95        await perform(argv)
 96    }
 97
 98    func close() async {
 99        await perform(["mr", "close"] + ref)
100    }
101
102    func setResolved(_ thread: ReviewThread, _ resolved: Bool) async {
103        await perform(["mr", resolved ? "resolve" : "unresolve"] + ref + [String(thread.id)])
104    }
105
106    func reply(to thread: ReviewThread, _ text: String) async {
107        await perform(
108            ["mr", "diff-comment"] + ref + ["--reply", String(thread.id), "--file", "-"],
109            stdin: text
110        )
111    }
112
113    func setDraft(_ draft: Bool) async {
114        await perform(["mr", draft ? "draft" : "ready"] + ref)
115    }
116
117    func retarget(to branch: String) async {
118        await perform(["mr", "retarget"] + ref + [branch])
119    }
120
121    func requestReview(from user: String) async {
122        await perform(["mr", "review", "request"] + ref + ["--add", user])
123    }
124
125    func removeReviewRequest(_ user: String) async {
126        await perform(["mr", "review", "request"] + ref + ["--remove", user])
127    }
128
129    /// Branches for the retarget picker, fetched on demand from `repo refs`.
130    /// `nil` means "not yet loaded" so the picker can tell that apart from
131    /// a repository that genuinely has no other branches; a failed fetch
132    /// leaves `branches` nil and sets `branchesError` instead of silently
133    /// showing an empty list.
134    private(set) var branches: [String]?
135    private(set) var branchesError: String?
136
137    /// Cached for the life of this view model, same as `loadMilestones()`:
138    /// branches can go stale after a push, but re-opening the retarget
139    /// picker should not re-fetch every time. A pull-to-refresh of the
140    /// whole screen doesn't clear this cache either — retarget is a
141    /// secondary action, not the primary content `load()` refreshes.
142    func loadBranches() async {
143        guard branches == nil else { return }
144        branchesError = nil
145        do {
146            let refs = try await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
147            branches = refs.branches.map(\.name)
148        } catch let error as GitbayError {
149            branchesError = error.userFacingMessage
150        } catch {
151            branchesError = GitbayError.transport(error).userFacingMessage
152        }
153    }
154
155    private func perform(_ argv: [String], stdin: String? = nil) async {
156        working = true
157        actionError = nil
158        defer { working = false }
159        do {
160            try await client.run(argv, stdin: stdin)
161            await load()
162        } catch let error as GitbayError {
163            // A refusal explains the rule (approvals missing, threads
164            // unresolved, checks red). Surface it verbatim; never route
165            // around it.
166            actionError = error.userFacingMessage
167        } catch {
168            actionError = GitbayError.transport(error).userFacingMessage
169        }
170    }
171}