gitbay/MRs/MRDetailViewModel.swift
127 lines · 4222 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 /// 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) async {
85 // Long text travels in stdin, but the server only reads it when
86 // argv says so: --file - is required, not implied.
87 await perform(["mr", "comment"] + ref + ["--file", "-"], stdin: text)
88 }
89
90 func merge(strategy: String? = nil) async {
91 var argv = ["mr", "merge"] + ref
92 if let strategy { argv.append(contentsOf: ["--strategy", strategy]) }
93 await perform(argv)
94 }
95
96 func close() async {
97 await perform(["mr", "close"] + ref)
98 }
99
100 func setResolved(_ thread: ReviewThread, _ resolved: Bool) async {
101 await perform(["mr", resolved ? "resolve" : "unresolve"] + ref + [String(thread.id)])
102 }
103
104 func reply(to thread: ReviewThread, _ text: String) async {
105 await perform(
106 ["mr", "diff-comment"] + ref + ["--reply", String(thread.id), "--file", "-"],
107 stdin: text
108 )
109 }
110
111 private func perform(_ argv: [String], stdin: String? = nil) async {
112 working = true
113 actionError = nil
114 defer { working = false }
115 do {
116 try await client.run(argv, stdin: stdin)
117 await load()
118 } catch let error as GitbayError {
119 // A refusal explains the rule (approvals missing, threads
120 // unresolved, checks red). Surface it verbatim; never route
121 // around it.
122 actionError = error.userFacingMessage
123 } catch {
124 actionError = GitbayError.transport(error).userFacingMessage
125 }
126 }
127}