gitbay/MRs/MRDetailViewModel.swift

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

272 lines · 10870 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    private(set) var revisions: [MRRevision] = []
 15    /// A write failed; the server's sentence, shown until the next action.
 16    private(set) var actionError: String?
 17    private(set) var working = false
 18
 19    private let client: GitbayClient
 20    let repoPath: String
 21    let number: Int64
 22
 23    init(client: GitbayClient, repoPath: String, number: Int64) {
 24        self.client = client
 25        self.repoPath = repoPath
 26        self.number = number
 27    }
 28
 29    private var ref: [String] { [repoPath, String(number)] }
 30
 31    func load() async {
 32        do {
 33            let detail = try await client.read(["mr", "show"] + ref, as: MRDetail.self)
 34            state = .loaded(detail)
 35        } catch {
 36            state = .from(error)
 37            return
 38        }
 39        // The diff and threads are secondary: their failure leaves the
 40        // header usable rather than sinking the screen.
 41        async let diffText = try? client.readText(["mr", "diff"] + ref)
 42        async let threadList = try? client.readList(["mr", "threads"] + ref, of: ReviewThread.self)
 43        async let revisionList = try? client.readList(["mr", "revisions"] + ref, of: MRRevision.self)
 44        diff = (await diffText).map(UnifiedDiff.parse)
 45        threads = await threadList ?? []
 46        revisions = await revisionList ?? []
 47    }
 48
 49    var unresolvedCount: Int {
 50        threads.count { !$0.isResolved }
 51    }
 52
 53    // MARK: - Writes
 54
 55    enum Verdict: String, Sendable {
 56        case approve = "--approve"
 57        case requestChanges = "--request-changes"
 58    }
 59
 60    func review(_ verdict: Verdict) async {
 61        await perform(["mr", "review"] + ref + [verdict.rawValue])
 62    }
 63
 64    /// Both fields are always sent; an empty body clears it. `format` is
 65    /// appended only when given; omitting it lets the server default.
 66    func edit(title: String, body: String, format: String? = nil) async {
 67        var argv = ["mr", "edit"] + ref + ["--title", title, "--file", "-"]
 68        if let format { argv.append(contentsOf: ["--format", format]) }
 69        await perform(argv, stdin: body)
 70    }
 71
 72    /// Open milestones for the picker; fetched on first use.
 73    private(set) var availableMilestones: [Milestone]?
 74
 75    func loadMilestones() async {
 76        guard availableMilestones == nil else { return }
 77        availableMilestones = (try? await client.readList(
 78            ["milestone", "list", repoPath, "--state", "open"], of: Milestone.self
 79        )) ?? []
 80    }
 81
 82    /// nil clears it; the command spells that "none".
 83    func setMilestone(_ title: String?) async {
 84        await perform(["mr", "milestone"] + ref + [title ?? "none"])
 85    }
 86
 87    /// The repository's labels, for chip colours. Read on first use from
 88    /// the Labels section, not in `load()`: colours are secondary, and the
 89    /// detail screen's tests count `load()`'s four reads.
 90    private(set) var colors = LabelColors()
 91    private var labelsLoaded = false
 92
 93    func loadLabels() async {
 94        guard !labelsLoaded else { return }
 95        labelsLoaded = true
 96        colors = LabelColors(
 97            (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [])
 98    }
 99
100    /// A label that does not exist yet is created on the repository.
101    func addLabel(_ label: String) async {
102        await perform(["mr", "label"] + ref + ["--add", label])
103    }
104
105    func removeLabel(_ label: String) async {
106        await perform(["mr", "label"] + ref + ["--remove", label])
107    }
108
109    func comment(_ text: String, format: String? = nil) async {
110        // Long text travels in stdin, but the server only reads it when
111        // argv says so: --file - is required, not implied.
112        var argv = ["mr", "comment"] + ref + ["--file", "-"]
113        if let format { argv.append(contentsOf: ["--format", format]) }
114        await perform(argv, stdin: text)
115    }
116
117    /// React to the merge request, or to one of its comments. A reaction the
118    /// account already gave is taken back.
119    func react(_ name: String, comment: Int64?) async {
120        let given = comment == nil
121            ? state.value?.reactions
122            : state.value?.comments?.first { $0.commentID == comment }?.reactions
123        await perform(Reaction.argv(noun: "mr", repo: repoPath, number: number, comment: comment,
124                                    name: name, remove: Reaction.remove(name, given: given)))
125    }
126
127    /// `whenReady` queues the merge until the gates pass; the server
128    /// merges at once if they already do.
129    func merge(strategy: String? = nil, whenReady: Bool = false) async {
130        var argv = ["mr", "merge"] + ref
131        if let strategy { argv.append(contentsOf: ["--strategy", strategy]) }
132        if whenReady { argv.append("--when-ready") }
133        await perform(argv)
134    }
135
136    /// Whether the head prune is offered: the merge request is merged or
137    /// closed and the account is an instance admin. Asked by the screen,
138    /// not by `load()`, so an open merge request costs no extra read.
139    private(set) var canPruneHead = false
140
141    func checkCanPruneHead() async {
142        guard let mr = state.value, !mr.isOpen else {
143            canPruneHead = false
144            return
145        }
146        canPruneHead = await AdminCheck.isAdmin(client)
147    }
148
149    /// `admin mr prune <repo> <n> --yes`, for instance admins: drops the
150    /// head ref of a merged or closed merge request and the objects only it
151    /// kept. The diff is gone afterwards; the thread records who pruned it.
152    func pruneHead() async {
153        await perform(["admin", "mr", "prune", repoPath, String(number), "--yes"])
154        // Done: another prune would only add another system comment.
155        if actionError == nil { canPruneHead = false }
156    }
157
158    func cancelQueuedMerge() async {
159        await perform(["mr", "merge"] + ref + ["--cancel"])
160    }
161
162    /// `--by` records which merge request replaces this one; the server
163    /// refuses a closed, merged or self reference.
164    func close(inFavourOf successor: Int64? = nil) async {
165        var argv = ["mr", "close"] + ref
166        if let successor { argv.append(contentsOf: ["--by", String(successor)]) }
167        await perform(argv)
168    }
169
170    /// The repository's other open merge requests, for the close-in-
171    /// favour picker. Fetched on demand and cached like `loadBranches()`;
172    /// this merge request is dropped since it cannot supersede itself.
173    private(set) var openMergeRequests: [MergeRequest]?
174    private(set) var openMergeRequestsError: String?
175
176    func loadOpenMergeRequests() async {
177        guard openMergeRequests == nil else { return }
178        openMergeRequestsError = nil
179        do {
180            let rows = try await client.readList(
181                ["mr", "list", repoPath, "--state", "open"], of: MergeRequest.self)
182            openMergeRequests = rows.filter { $0.number != number }
183        } catch let error as GitbayError {
184            openMergeRequestsError = error.userFacingMessage
185        } catch {
186            openMergeRequestsError = GitbayError.transport(error).userFacingMessage
187        }
188    }
189
190    func setResolved(_ thread: ReviewThread, _ resolved: Bool) async {
191        await perform(["mr", resolved ? "resolve" : "unresolve"] + ref + [String(thread.id)])
192    }
193
194    func reply(to thread: ReviewThread, _ text: String) async {
195        await perform(
196            ["mr", "diff-comment"] + ref + ["--reply", String(thread.id), "--file", "-"],
197            stdin: text
198        )
199    }
200
201    /// Start a review thread on lines of the diff. The body goes over
202    /// stdin; a ```suggestion block in it is the server's to read. Returns
203    /// whether it was accepted, so the sheet can close.
204    func diffComment(_ draft: DiffCommentDraft, startLine: Int, body: String) async -> Bool {
205        await perform(draft.argv(repo: repoPath, number: number, startLine: startLine), stdin: body)
206        return actionError == nil
207    }
208
209    /// Commit a thread's suggestion to the source branch on the server.
210    func applySuggestion(_ thread: ReviewThread) async {
211        await perform(["mr", "apply-suggestion"] + ref + [String(thread.id)])
212    }
213
214    func setDraft(_ draft: Bool) async {
215        await perform(["mr", draft ? "draft" : "ready"] + ref)
216    }
217
218    func retarget(to branch: String) async {
219        await perform(["mr", "retarget"] + ref + [branch])
220    }
221
222    func requestReview(from user: String) async {
223        await perform(["mr", "review", "request"] + ref + ["--add", user])
224    }
225
226    func removeReviewRequest(_ user: String) async {
227        await perform(["mr", "review", "request"] + ref + ["--remove", user])
228    }
229
230    /// Branches for the retarget picker, fetched on demand from `repo refs`.
231    /// `nil` means "not yet loaded" so the picker can tell that apart from
232    /// a repository that genuinely has no other branches; a failed fetch
233    /// leaves `branches` nil and sets `branchesError` instead of silently
234    /// showing an empty list.
235    private(set) var branches: [String]?
236    private(set) var branchesError: String?
237
238    /// Cached for the life of this view model, same as `loadMilestones()`:
239    /// branches can go stale after a push, but re-opening the retarget
240    /// picker should not re-fetch every time. A pull-to-refresh of the
241    /// whole screen doesn't clear this cache either — retarget is a
242    /// secondary action, not the primary content `load()` refreshes.
243    func loadBranches() async {
244        guard branches == nil else { return }
245        branchesError = nil
246        do {
247            let refs = try await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
248            branches = refs.branches.map(\.name)
249        } catch let error as GitbayError {
250            branchesError = error.userFacingMessage
251        } catch {
252            branchesError = GitbayError.transport(error).userFacingMessage
253        }
254    }
255
256    private func perform(_ argv: [String], stdin: String? = nil) async {
257        working = true
258        actionError = nil
259        defer { working = false }
260        do {
261            try await client.run(argv, stdin: stdin)
262            await load()
263        } catch let error as GitbayError {
264            // A refusal explains the rule (approvals missing, threads
265            // unresolved, checks red). Surface it verbatim; never route
266            // around it.
267            actionError = error.userFacingMessage
268        } catch {
269            actionError = GitbayError.transport(error).userFacingMessage
270        }
271    }
272}