gitbay/MRs/MRDetailViewModel.swift
112 lines · 3668 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.
62 func edit(title: String, body: String) async {
63 await perform(
64 ["mr", "edit"] + ref + ["--title", title, "--file", "-"],
65 stdin: body
66 )
67 }
68
69 func comment(_ text: String) async {
70 // Long text travels in stdin, but the server only reads it when
71 // argv says so: --file - is required, not implied.
72 await perform(["mr", "comment"] + ref + ["--file", "-"], stdin: text)
73 }
74
75 func merge(strategy: String? = nil) async {
76 var argv = ["mr", "merge"] + ref
77 if let strategy { argv.append(contentsOf: ["--strategy", strategy]) }
78 await perform(argv)
79 }
80
81 func close() async {
82 await perform(["mr", "close"] + ref)
83 }
84
85 func setResolved(_ thread: ReviewThread, _ resolved: Bool) async {
86 await perform(["mr", resolved ? "resolve" : "unresolve"] + ref + [String(thread.id)])
87 }
88
89 func reply(to thread: ReviewThread, _ text: String) async {
90 await perform(
91 ["mr", "diff-comment"] + ref + ["--reply", String(thread.id), "--file", "-"],
92 stdin: text
93 )
94 }
95
96 private func perform(_ argv: [String], stdin: String? = nil) async {
97 working = true
98 actionError = nil
99 defer { working = false }
100 do {
101 try await client.run(argv, stdin: stdin)
102 await load()
103 } catch let error as GitbayError {
104 // A refusal explains the rule (approvals missing, threads
105 // unresolved, checks red). Surface it verbatim; never route
106 // around it.
107 actionError = error.userFacingMessage
108 } catch {
109 actionError = GitbayError.transport(error).userFacingMessage
110 }
111 }
112}