gitbay/Repos/RepoSettingsViewModel.swift
294 lines · 11371 bytes
33 symbols in this file
RepoSettingsViewModelloadloadDepssetDepsEnabledsetDescriptionsetWebsitesetVisibilitysetGitDaemonprotectBranchunprotectBranchsetRequireApprovalssetRequireResolvedsetRequireChecksaddRequiredContextremoveRequiredContextsetRequiredContextssetRequireSignedsetRequireCodeownerssetRequireMRprotectTagunprotectTagsetDefaultBranchrenametransferdeleteloadBranchesloadGrantsgrantrevokeaddTopicremoveTopicperformperform
1import Foundation
2import Observation
3
4/// The repo settings screen: `repo settings show` for the admin knobs,
5/// `repo show` for description/topics/visibility, and one write command
6/// per control. Non-admins get the server's refusal verbatim.
7@Observable
8@MainActor
9final class RepoSettingsViewModel {
10
11 nonisolated struct Loaded: Sendable, Hashable {
12 let settings: RepoSettings
13 let detail: RepoDetail
14 }
15
16 private(set) var state: LoadState<Loaded> = .loading
17 private(set) var actionError: String?
18 private(set) var working = false
19
20 /// `repo deps status`, exposed as an optional plus a separate error
21 /// string rather than folded into one state: `nil` with no error means
22 /// "not loaded yet", `nil` with an error means "the read failed". The
23 /// settings screen must never render "checks are off" when it means
24 /// "we could not ask".
25 private(set) var deps: DepsStatus?
26 private(set) var depsError: String?
27
28 /// `repo access list`, with the same nil-plus-error shape as `deps`:
29 /// "not loaded" and "could not ask" must not read as "nobody granted".
30 private(set) var grants: [AccessGrant]?
31 private(set) var grantsError: String?
32
33 /// Branch names from `repo refs`, for the default-branch picker.
34 private(set) var branches: [String] = []
35
36 private let client: GitbayClient
37 let repoPath: String
38
39 init(client: GitbayClient, repoPath: String) {
40 self.client = client
41 self.repoPath = repoPath
42 }
43
44 func load() async {
45 do {
46 async let settings = client.read(
47 ["repo", "settings", "show", repoPath], as: RepoSettings.self)
48 async let detail = client.read(["repo", "show", repoPath], as: RepoDetail.self)
49 state = .loaded(Loaded(settings: try await settings, detail: try await detail))
50 } catch {
51 state = .from(error)
52 }
53 }
54
55 /// `repo deps status` — three words, unlike every other `repo` command
56 /// in this file, which is exactly why it's easy to get wrong.
57 func loadDeps() async {
58 depsError = nil
59 deps = nil
60 do {
61 deps = try await client.read(["repo", "deps", "status", repoPath], as: DepsStatus.self)
62 } catch let error as GitbayError {
63 depsError = error.userFacingMessage
64 } catch {
65 depsError = GitbayError.transport(error).userFacingMessage
66 }
67 }
68
69 /// `enable`/`disable` are two distinct commands, not one command with a
70 /// flag. Reloads the status afterward so the report reflects the
71 /// change; a refused write leaves `deps` untouched.
72 func setDepsEnabled(_ on: Bool) async {
73 await perform(["repo", "deps", on ? "enable" : "disable", repoPath], reload: loadDeps)
74 }
75
76 // MARK: - Writes, one command per knob
77
78 func setDescription(_ text: String) async {
79 await perform(["repo", "settings", "description", repoPath, text])
80 }
81
82 func setWebsite(_ url: String) async {
83 await perform(["repo", "settings", "website", repoPath, url])
84 }
85
86 func setVisibility(_ visibility: String) async {
87 await perform(["repo", "settings", "visibility", repoPath, visibility])
88 }
89
90 func setGitDaemon(_ on: Bool) async {
91 await perform(["repo", "settings", "git-daemon", repoPath, on ? "on" : "off"])
92 }
93
94 func protectBranch(_ branch: String) async {
95 await perform(["repo", "settings", "protect", repoPath, branch])
96 }
97
98 func unprotectBranch(_ branch: String) async {
99 await perform(["repo", "settings", "unprotect", repoPath, branch])
100 }
101
102 func setRequireApprovals(_ count: Int) async {
103 await perform(["repo", "settings", "require-approvals", repoPath, String(count)])
104 }
105
106 func setRequireResolved(_ on: Bool) async {
107 await perform(["repo", "settings", "require-resolved", repoPath, on ? "on" : "off"])
108 }
109
110 func setRequireChecks(_ on: Bool) async {
111 await perform(["repo", "settings", "require-checks", repoPath, on ? "on" : "off"])
112 }
113
114 /// `repo settings require-contexts` replaces the list, so the whole
115 /// list goes each time. Naming one turns the checks gate on.
116 /// Returns whether the field can be cleared: the server took it, or
117 /// it was already listed. A refused name stays in the field to fix.
118 @discardableResult
119 func addRequiredContext(_ context: String) async -> Bool {
120 let context = context.trimmingCharacters(in: .whitespaces)
121 let current = state.value?.settings.contexts ?? []
122 guard !context.isEmpty, !working else { return false }
123 guard !current.contains(context) else { return true }
124 await setRequiredContexts(current + [context])
125 return actionError == nil
126 }
127
128 /// Removing the last one leaves the gate as it was, waiting on every check.
129 func removeRequiredContext(_ context: String) async {
130 await setRequiredContexts((state.value?.settings.contexts ?? []).filter { $0 != context })
131 }
132
133 /// A check's name is typed, so the server's refusal of one is shown.
134 private func setRequiredContexts(_ contexts: [String]) async {
135 await perform(["repo", "settings", "require-contexts", repoPath] + contexts, typed: true) { await load() }
136 }
137
138 func setRequireSigned(_ on: Bool) async {
139 await perform(["repo", "settings", "require-signed", repoPath, on ? "on" : "off"])
140 }
141
142 func setRequireCodeowners(_ on: Bool) async {
143 await perform(["repo", "settings", "require-codeowners", repoPath, on ? "on" : "off"])
144 }
145
146 func setRequireMR(_ on: Bool) async {
147 await perform(["repo", "settings", "require-mr", repoPath, on ? "on" : "off"])
148 }
149
150 func protectTag(_ glob: String) async {
151 await perform(["repo", "settings", "protect-tag", repoPath, glob])
152 }
153
154 func unprotectTag(_ glob: String) async {
155 await perform(["repo", "settings", "unprotect-tag", repoPath, glob])
156 }
157
158 /// `repo settings default-branch`: moves HEAD and the record. The
159 /// server refuses a branch that does not exist, so the picker only
160 /// offers what `repo refs` listed.
161 func setDefaultBranch(_ branch: String) async {
162 await perform(["repo", "settings", "default-branch", repoPath, branch])
163 }
164
165 nonisolated private struct Renamed: Decodable, Sendable {
166 let repo: String
167 }
168
169 /// `repo rename <owner/name> <new-name>`: the row and the directory
170 /// move together and clone URLs change. Returns the new path; the
171 /// caller navigates there, since this screen's path is now gone.
172 func rename(to name: String) async -> String? {
173 let name = name.trimmingCharacters(in: .whitespaces)
174 guard !name.isEmpty else { return nil }
175 working = true
176 actionError = nil
177 defer { working = false }
178 do {
179 let renamed = try await client.run(["repo", "rename", repoPath, name], as: Renamed.self)
180 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
181 return renamed?.repo
182 } catch let error as GitbayError {
183 actionError = error.userFacingMessage
184 } catch {
185 actionError = GitbayError.transport(error).userFacingMessage
186 }
187 return nil
188 }
189
190 nonisolated private struct Transferred: Decodable, Sendable {
191 let repo: String
192 }
193
194 /// `repo transfer <owner/name> <new-owner>`: the repository moves to
195 /// another account or organization and its clone URLs change. Returns
196 /// the new path; this screen's is gone.
197 func transfer(to owner: String) async -> String? {
198 let owner = owner.trimmingCharacters(in: .whitespaces)
199 guard !owner.isEmpty else { return nil }
200 working = true
201 actionError = nil
202 defer { working = false }
203 do {
204 let moved = try await client.run(["repo", "transfer", repoPath, owner], as: Transferred.self)
205 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
206 return moved?.repo
207 } catch let error as GitbayError {
208 // The owner is typed: a refusal of it says what to fix.
209 actionError = error.messageForTypedInput
210 } catch {
211 actionError = GitbayError.transport(error).userFacingMessage
212 }
213 return nil
214 }
215
216 /// `repo delete <owner/name> --yes`. The screen asks for the path to
217 /// be typed first, as the web does; --yes is that confirmation.
218 func delete() async -> Bool {
219 working = true
220 actionError = nil
221 defer { working = false }
222 do {
223 try await client.run(["repo", "delete", repoPath, "--yes"])
224 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
225 return true
226 } catch GitbayError.notFound {
227 // The client retries a failure once. A delete that removed the
228 // row and then failed cleaning up answers the retry with not
229 // found: the repository is gone either way.
230 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
231 return true
232 } catch let error as GitbayError {
233 actionError = error.userFacingMessage
234 } catch {
235 actionError = GitbayError.transport(error).userFacingMessage
236 }
237 return false
238 }
239
240 func loadBranches() async {
241 let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self)
242 branches = refs?.branches.map(\.name) ?? []
243 }
244
245 func loadGrants() async {
246 grantsError = nil
247 grants = nil
248 do {
249 grants = try await client.readList(["repo", "access", "list", repoPath], of: AccessGrant.self)
250 } catch let error as GitbayError {
251 grantsError = error.userFacingMessage
252 } catch {
253 grantsError = GitbayError.transport(error).userFacingMessage
254 }
255 }
256
257 /// `repo access grant <repo> <user> read|write|admin` — granting again
258 /// changes an existing user's role.
259 func grant(user: String, role: String) async {
260 let user = user.trimmingCharacters(in: .whitespaces)
261 guard !user.isEmpty else { return }
262 await perform(["repo", "access", "grant", repoPath, user, role], reload: loadGrants)
263 }
264
265 func revoke(user: String) async {
266 await perform(["repo", "access", "revoke", repoPath, user], reload: loadGrants)
267 }
268
269 func addTopic(_ topic: String) async {
270 await perform(["repo", "topics", "add", repoPath, topic])
271 }
272
273 func removeTopic(_ topic: String) async {
274 await perform(["repo", "topics", "remove", repoPath, topic])
275 }
276
277 private func perform(_ argv: [String]) async {
278 await perform(argv, reload: load)
279 }
280
281 private func perform(_ argv: [String], typed: Bool = false, reload: () async -> Void) async {
282 working = true
283 actionError = nil
284 defer { working = false }
285 do {
286 try await client.run(argv)
287 await reload()
288 } catch let error as GitbayError {
289 actionError = typed ? error.messageForTypedInput : error.userFacingMessage
290 } catch {
291 actionError = GitbayError.transport(error).userFacingMessage
292 }
293 }
294}