gitbay/Orgs/OrgViewModels.swift
189 lines · 5805 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 func createTeam(_ team: String) async {
98 await perform(["org", "team", "create", orgName, team])
99 }
100
101 func deleteTeam(_ team: String) async {
102 await perform(["org", "team", "delete", orgName, team])
103 }
104
105 private func perform(_ argv: [String]) async {
106 working = true
107 actionError = nil
108 defer { working = false }
109 do {
110 try await client.run(argv)
111 await load()
112 } catch let error as GitbayError {
113 actionError = error.userFacingMessage
114 } catch {
115 actionError = GitbayError.transport(error).userFacingMessage
116 }
117 }
118}
119
120/// One team: members and repo grants.
121@Observable
122@MainActor
123final class TeamViewModel {
124
125 nonisolated struct Grant: Decodable, Sendable, Hashable, Identifiable {
126 let repo: String
127 let role: String
128 var id: String { repo }
129 }
130
131 nonisolated struct Loaded: Decodable, Sendable, Hashable {
132 let team: String
133 let members: [String]?
134 let grants: [Grant]?
135 }
136
137 private(set) var state: LoadState<Loaded> = .loading
138 private(set) var actionError: String?
139 private(set) var working = false
140
141 private let client: GitbayClient
142 let orgName: String
143 let teamName: String
144
145 init(client: GitbayClient, orgName: String, teamName: String) {
146 self.client = client
147 self.orgName = orgName
148 self.teamName = teamName
149 }
150
151 func load() async {
152 do {
153 state = .loaded(try await client.read(
154 ["org", "team", "show", orgName, teamName], as: Loaded.self))
155 } catch {
156 state = .from(error)
157 }
158 }
159
160 func addMember(_ user: String) async {
161 await perform(["org", "team", "add", orgName, teamName, user])
162 }
163
164 func removeMember(_ user: String) async {
165 await perform(["org", "team", "remove", orgName, teamName, user])
166 }
167
168 func grant(repo: String, role: String) async {
169 await perform(["org", "team", "grant", orgName, teamName, repo, role])
170 }
171
172 func revoke(_ grant: Grant) async {
173 await perform(["org", "team", "revoke", orgName, teamName, grant.repo])
174 }
175
176 private func perform(_ argv: [String]) async {
177 working = true
178 actionError = nil
179 defer { working = false }
180 do {
181 try await client.run(argv)
182 await load()
183 } catch let error as GitbayError {
184 actionError = error.userFacingMessage
185 } catch {
186 actionError = GitbayError.transport(error).userFacingMessage
187 }
188 }
189}