gitbay/Orgs/OrgViewModels.swift
212 lines · 6754 bytes
1import Foundation
2import Observation
3
4/// One membership row of `org list`.
5nonisolated struct OrgMembership: Decodable, Sendable, Hashable, Identifiable {
6 let org: String
7 let role: String
8 var id: String { org }
9}
10
11/// One member of `org show` / `org members list`.
12nonisolated struct OrgMember: Decodable, Sendable, Hashable, Identifiable {
13 let user: String
14 let role: String
15 var id: String { user }
16}
17
18/// An organization: members and teams, plus the membership writes.
19/// Reads require membership; the refusal names the rule for outsiders.
20@Observable
21@MainActor
22final class OrgViewModel {
23
24 nonisolated struct Loaded: Sendable, Hashable {
25 let members: [OrgMember]
26 let teams: [String]
27 }
28
29 private(set) var state: LoadState<Loaded> = .loading
30 private(set) var actionError: String?
31 private(set) var working = false
32
33 private let client: GitbayClient
34 private(set) var orgName: String
35
36 init(client: GitbayClient, orgName: String) {
37 self.client = client
38 self.orgName = orgName
39 }
40
41 nonisolated private struct OrgShow: Decodable, Sendable {
42 let org: String
43 let members: [OrgMember]
44 }
45
46 func load() async {
47 do {
48 async let show = client.read(["org", "show", orgName], as: OrgShow.self)
49 // Team list is bare names.
50 async let teams = client.readList(["org", "team", "list", orgName], of: String.self)
51 state = .loaded(Loaded(members: try await show.members, teams: try await teams))
52 } catch {
53 state = .from(error)
54 }
55 }
56
57 func addMember(_ user: String, role: String) async {
58 await perform(["org", "members", "add", orgName, user, "--role", role])
59 }
60
61 func removeMember(_ member: OrgMember) async {
62 await perform(["org", "members", "remove", orgName, member.user])
63 }
64
65 /// Role changes reuse `members add` — it updates an existing member.
66 func setRole(_ member: OrgMember, role: String) async {
67 await perform(["org", "members", "add", orgName, member.user, "--role", role])
68 }
69
70 /// `org rename <old> <new>` — both positional, old first. Swapping
71 /// them renames the wrong way with no error at all.
72 ///
73 /// Doesn't go through `perform`: its automatic reload would still
74 /// query the old, now-renamed org. `orgName` only moves to the new
75 /// name once the rename itself has actually succeeded, and the
76 /// reload that follows targets that new name.
77 func rename(to newName: String) async -> Bool {
78 let newName = newName.trimmingCharacters(in: .whitespacesAndNewlines)
79 guard !newName.isEmpty else { return false }
80 working = true
81 actionError = nil
82 defer { working = false }
83 do {
84 try await client.run(["org", "rename", orgName, newName])
85 orgName = newName
86 await load()
87 return true
88 } catch let error as GitbayError {
89 actionError = error.userFacingMessage
90 return false
91 } catch {
92 actionError = GitbayError.transport(error).userFacingMessage
93 return false
94 }
95 }
96
97 /// `org delete <name> --yes`. The screen asks for the name to be typed
98 /// first; --yes is that confirmation. The server refuses an
99 /// organization that still owns repositories, and says so.
100 func delete() async -> Bool {
101 working = true
102 actionError = nil
103 defer { working = false }
104 do {
105 try await client.run(["org", "delete", orgName, "--yes"])
106 } catch GitbayError.notFound {
107 // The client retries a failure once; a retry that finds the
108 // organization gone means the first attempt deleted it.
109 } catch let error as GitbayError {
110 actionError = error.userFacingMessage
111 return false
112 } catch {
113 actionError = GitbayError.transport(error).userFacingMessage
114 return false
115 }
116 NotificationCenter.default.post(name: .gitbayOrganizationsChanged, object: orgName)
117 return true
118 }
119
120 func createTeam(_ team: String) async {
121 await perform(["org", "team", "create", orgName, team])
122 }
123
124 func deleteTeam(_ team: String) async {
125 await perform(["org", "team", "delete", orgName, team])
126 }
127
128 private func perform(_ argv: [String]) async {
129 working = true
130 actionError = nil
131 defer { working = false }
132 do {
133 try await client.run(argv)
134 await load()
135 } catch let error as GitbayError {
136 actionError = error.userFacingMessage
137 } catch {
138 actionError = GitbayError.transport(error).userFacingMessage
139 }
140 }
141}
142
143/// One team: members and repo grants.
144@Observable
145@MainActor
146final class TeamViewModel {
147
148 nonisolated struct Grant: Decodable, Sendable, Hashable, Identifiable {
149 let repo: String
150 let role: String
151 var id: String { repo }
152 }
153
154 nonisolated struct Loaded: Decodable, Sendable, Hashable {
155 let team: String
156 let members: [String]?
157 let grants: [Grant]?
158 }
159
160 private(set) var state: LoadState<Loaded> = .loading
161 private(set) var actionError: String?
162 private(set) var working = false
163
164 private let client: GitbayClient
165 let orgName: String
166 let teamName: String
167
168 init(client: GitbayClient, orgName: String, teamName: String) {
169 self.client = client
170 self.orgName = orgName
171 self.teamName = teamName
172 }
173
174 func load() async {
175 do {
176 state = .loaded(try await client.read(
177 ["org", "team", "show", orgName, teamName], as: Loaded.self))
178 } catch {
179 state = .from(error)
180 }
181 }
182
183 func addMember(_ user: String) async {
184 await perform(["org", "team", "add", orgName, teamName, user])
185 }
186
187 func removeMember(_ user: String) async {
188 await perform(["org", "team", "remove", orgName, teamName, user])
189 }
190
191 func grant(repo: String, role: String) async {
192 await perform(["org", "team", "grant", orgName, teamName, repo, role])
193 }
194
195 func revoke(_ grant: Grant) async {
196 await perform(["org", "team", "revoke", orgName, teamName, grant.repo])
197 }
198
199 private func perform(_ argv: [String]) async {
200 working = true
201 actionError = nil
202 defer { working = false }
203 do {
204 try await client.run(argv)
205 await load()
206 } catch let error as GitbayError {
207 actionError = error.userFacingMessage
208 } catch {
209 actionError = GitbayError.transport(error).userFacingMessage
210 }
211 }
212}