gitbay/Orgs/OrgViewModels.swift

v1.14.1
gitbay-ios/gitbay/Orgs/OrgViewModels.swift history · blame · raw

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}