gitbay/MRs/MRDetailViewModel.swift
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}