gitbay/Repos/RepoSettingsViewModel.swift

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

168 lines · 5937 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    private let client: GitbayClient
 34    let repoPath: String
 35
 36    init(client: GitbayClient, repoPath: String) {
 37        self.client = client
 38        self.repoPath = repoPath
 39    }
 40
 41    func load() async {
 42        do {
 43            async let settings = client.read(
 44                ["repo", "settings", "show", repoPath], as: RepoSettings.self)
 45            async let detail = client.read(["repo", "show", repoPath], as: RepoDetail.self)
 46            state = .loaded(Loaded(settings: try await settings, detail: try await detail))
 47        } catch {
 48            state = .from(error)
 49        }
 50    }
 51
 52    /// `repo deps status` — three words, unlike every other `repo` command
 53    /// in this file, which is exactly why it's easy to get wrong.
 54    func loadDeps() async {
 55        depsError = nil
 56        deps = nil
 57        do {
 58            deps = try await client.read(["repo", "deps", "status", repoPath], as: DepsStatus.self)
 59        } catch let error as GitbayError {
 60            depsError = error.userFacingMessage
 61        } catch {
 62            depsError = GitbayError.transport(error).userFacingMessage
 63        }
 64    }
 65
 66    /// `enable`/`disable` are two distinct commands, not one command with a
 67    /// flag. Reloads the status afterward so the report reflects the
 68    /// change; a refused write leaves `deps` untouched.
 69    func setDepsEnabled(_ on: Bool) async {
 70        await perform(["repo", "deps", on ? "enable" : "disable", repoPath], reload: loadDeps)
 71    }
 72
 73    // MARK: - Writes, one command per knob
 74
 75    func setDescription(_ text: String) async {
 76        await perform(["repo", "settings", "description", repoPath, text])
 77    }
 78
 79    func setWebsite(_ url: String) async {
 80        await perform(["repo", "settings", "website", repoPath, url])
 81    }
 82
 83    func setVisibility(_ visibility: String) async {
 84        await perform(["repo", "settings", "visibility", repoPath, visibility])
 85    }
 86
 87    func setGitDaemon(_ on: Bool) async {
 88        await perform(["repo", "settings", "git-daemon", repoPath, on ? "on" : "off"])
 89    }
 90
 91    func protectBranch(_ branch: String) async {
 92        await perform(["repo", "settings", "protect", repoPath, branch])
 93    }
 94
 95    func unprotectBranch(_ branch: String) async {
 96        await perform(["repo", "settings", "unprotect", repoPath, branch])
 97    }
 98
 99    func setRequireApprovals(_ count: Int) async {
100        await perform(["repo", "settings", "require-approvals", repoPath, String(count)])
101    }
102
103    func setRequireResolved(_ on: Bool) async {
104        await perform(["repo", "settings", "require-resolved", repoPath, on ? "on" : "off"])
105    }
106
107    func setRequireChecks(_ on: Bool) async {
108        await perform(["repo", "settings", "require-checks", repoPath, on ? "on" : "off"])
109    }
110
111    func setRequireSigned(_ on: Bool) async {
112        await perform(["repo", "settings", "require-signed", repoPath, on ? "on" : "off"])
113    }
114
115    func setRequireCodeowners(_ on: Bool) async {
116        await perform(["repo", "settings", "require-codeowners", repoPath, on ? "on" : "off"])
117    }
118
119    func loadGrants() async {
120        grantsError = nil
121        grants = nil
122        do {
123            grants = try await client.readList(["repo", "access", "list", repoPath], of: AccessGrant.self)
124        } catch let error as GitbayError {
125            grantsError = error.userFacingMessage
126        } catch {
127            grantsError = GitbayError.transport(error).userFacingMessage
128        }
129    }
130
131    /// `repo access grant <repo> <user> read|write|admin` — granting again
132    /// changes an existing user's role.
133    func grant(user: String, role: String) async {
134        let user = user.trimmingCharacters(in: .whitespaces)
135        guard !user.isEmpty else { return }
136        await perform(["repo", "access", "grant", repoPath, user, role], reload: loadGrants)
137    }
138
139    func revoke(user: String) async {
140        await perform(["repo", "access", "revoke", repoPath, user], reload: loadGrants)
141    }
142
143    func addTopic(_ topic: String) async {
144        await perform(["repo", "topics", "add", repoPath, topic])
145    }
146
147    func removeTopic(_ topic: String) async {
148        await perform(["repo", "topics", "remove", repoPath, topic])
149    }
150
151    private func perform(_ argv: [String]) async {
152        await perform(argv, reload: load)
153    }
154
155    private func perform(_ argv: [String], reload: () async -> Void) async {
156        working = true
157        actionError = nil
158        defer { working = false }
159        do {
160            try await client.run(argv)
161            await reload()
162        } catch let error as GitbayError {
163            actionError = error.userFacingMessage
164        } catch {
165            actionError = GitbayError.transport(error).userFacingMessage
166        }
167    }
168}