gitbay/Repos/RepoSettingsViewModel.swift
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}