gitbay/Repos/RepoSettingsViewModel.swift

v1.14.1
gitbay-ios/gitbay/Repos/RepoSettingsViewModel.swift history · blame · raw

294 lines · 11371 bytes

  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}