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