Commit 6c517ed0b5
Verified · cmc
gitbay/Account/AccountViewModel.swift +5 −1
| @@ -32,6 +32,7 @@ final class AccountViewModel { | ||
| 32 | 32 | nonisolated struct Loaded: Sendable, Hashable { |
| 33 | 33 | let sshKeys: [SSHKey] |
| 34 | 34 | let pgpKeys: [PGPKey] |
| 35 | let orgs: [OrgMembership] | |
| 35 | 36 | } |
| 36 | 37 | |
| 37 | 38 | private(set) var state: LoadState<Loaded> = .loading |
| @@ -51,7 +52,10 @@ final class AccountViewModel { | ||
| 51 | 52 | do { |
| 52 | 53 | async let ssh = client.readList(["keys", "list"], of: SSHKey.self) |
| 53 | 54 | async let pgp = client.readList(["pgp", "list"], of: PGPKey.self) |
| 54 | state = .loaded(Loaded(sshKeys: try await ssh, pgpKeys: try await pgp)) | |
| 55 | async let orgs = client.readList(["org", "list"], of: OrgMembership.self) | |
| 56 | state = .loaded(Loaded( | |
| 57 | sshKeys: try await ssh, pgpKeys: try await pgp, orgs: try await orgs | |
| 58 | )) | |
| 55 | 59 | } catch { |
| 56 | 60 | state = .from(error) |
| 57 | 61 | } |
gitbay/ContentView.swift +13
| @@ -58,6 +58,9 @@ private struct RouteDestinations: ViewModifier { | ||
| 58 | 58 | .navigationDestination(for: ReleaseRoute.self) { route in |
| 59 | 59 | destination(route, client: client) |
| 60 | 60 | } |
| 61 | .navigationDestination(for: OrgRoute.self) { route in | |
| 62 | destination(route, client: client) | |
| 63 | } | |
| 61 | 64 | } |
| 62 | 65 | |
| 63 | 66 | @ViewBuilder |
| @@ -106,6 +109,16 @@ private struct RouteDestinations: ViewModifier { | ||
| 106 | 109 | } |
| 107 | 110 | } |
| 108 | 111 | |
| 112 | @ViewBuilder | |
| 113 | private func destination(_ route: OrgRoute, client: GitbayClient) -> some View { | |
| 114 | switch route { | |
| 115 | case .org(let name): | |
| 116 | OrgView(client: client, org: name) | |
| 117 | case .team(let org, let team): | |
| 118 | TeamView(client: client, org: org, team: team) | |
| 119 | } | |
| 120 | } | |
| 121 | ||
| 109 | 122 | @ViewBuilder |
| 110 | 123 | private func destination(_ route: ReleaseRoute, client: GitbayClient) -> some View { |
| 111 | 124 | switch route { |
gitbay/Orgs/OrgViewModels.swift added +162
| @@ -0,0 +1,162 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// One membership row of `org list`. | |
| 5 | nonisolated 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`. | |
| 12 | nonisolated 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 | |
| 22 | final 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 | let 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 | func createTeam(_ team: String) async { | |
| 71 | await perform(["org", "team", "create", orgName, team]) | |
| 72 | } | |
| 73 | ||
| 74 | func deleteTeam(_ team: String) async { | |
| 75 | await perform(["org", "team", "delete", orgName, team]) | |
| 76 | } | |
| 77 | ||
| 78 | private func perform(_ argv: [String]) async { | |
| 79 | working = true | |
| 80 | actionError = nil | |
| 81 | defer { working = false } | |
| 82 | do { | |
| 83 | try await client.run(argv) | |
| 84 | await load() | |
| 85 | } catch let error as GitbayError { | |
| 86 | actionError = error.userFacingMessage | |
| 87 | } catch { | |
| 88 | actionError = GitbayError.transport(error).userFacingMessage | |
| 89 | } | |
| 90 | } | |
| 91 | } | |
| 92 | ||
| 93 | /// One team: members and repo grants. | |
| 94 | @Observable | |
| 95 | @MainActor | |
| 96 | final class TeamViewModel { | |
| 97 | ||
| 98 | nonisolated struct Grant: Decodable, Sendable, Hashable, Identifiable { | |
| 99 | let repo: String | |
| 100 | let role: String | |
| 101 | var id: String { repo } | |
| 102 | } | |
| 103 | ||
| 104 | nonisolated struct Loaded: Decodable, Sendable, Hashable { | |
| 105 | let team: String | |
| 106 | let members: [String]? | |
| 107 | let grants: [Grant]? | |
| 108 | } | |
| 109 | ||
| 110 | private(set) var state: LoadState<Loaded> = .loading | |
| 111 | private(set) var actionError: String? | |
| 112 | private(set) var working = false | |
| 113 | ||
| 114 | private let client: GitbayClient | |
| 115 | let orgName: String | |
| 116 | let teamName: String | |
| 117 | ||
| 118 | init(client: GitbayClient, orgName: String, teamName: String) { | |
| 119 | self.client = client | |
| 120 | self.orgName = orgName | |
| 121 | self.teamName = teamName | |
| 122 | } | |
| 123 | ||
| 124 | func load() async { | |
| 125 | do { | |
| 126 | state = .loaded(try await client.read( | |
| 127 | ["org", "team", "show", orgName, teamName], as: Loaded.self)) | |
| 128 | } catch { | |
| 129 | state = .from(error) | |
| 130 | } | |
| 131 | } | |
| 132 | ||
| 133 | func addMember(_ user: String) async { | |
| 134 | await perform(["org", "team", "add", orgName, teamName, user]) | |
| 135 | } | |
| 136 | ||
| 137 | func removeMember(_ user: String) async { | |
| 138 | await perform(["org", "team", "remove", orgName, teamName, user]) | |
| 139 | } | |
| 140 | ||
| 141 | func grant(repo: String, role: String) async { | |
| 142 | await perform(["org", "team", "grant", orgName, teamName, repo, role]) | |
| 143 | } | |
| 144 | ||
| 145 | func revoke(_ grant: Grant) async { | |
| 146 | await perform(["org", "team", "revoke", orgName, teamName, grant.repo]) | |
| 147 | } | |
| 148 | ||
| 149 | private func perform(_ argv: [String]) async { | |
| 150 | working = true | |
| 151 | actionError = nil | |
| 152 | defer { working = false } | |
| 153 | do { | |
| 154 | try await client.run(argv) | |
| 155 | await load() | |
| 156 | } catch let error as GitbayError { | |
| 157 | actionError = error.userFacingMessage | |
| 158 | } catch { | |
| 159 | actionError = GitbayError.transport(error).userFacingMessage | |
| 160 | } | |
| 161 | } | |
| 162 | } | |
gitbay/Views/Account/AccountView.swift +18
| @@ -19,6 +19,24 @@ struct AccountView: View { | ||
| 19 | 19 | var body: some View { |
| 20 | 20 | List { |
| 21 | 21 | if let loaded = model.state.value { |
| 22 | if !loaded.orgs.isEmpty { | |
| 23 | Section("Organizations") { | |
| 24 | ForEach(loaded.orgs) { membership in | |
| 25 | NavigationLink(value: OrgRoute.org(membership.org)) { | |
| 26 | HStack { | |
| 27 | Label(membership.org, systemImage: "building.2") | |
| 28 | .font(.subheadline) | |
| 29 | Spacer() | |
| 30 | Text(membership.role) | |
| 31 | .font(.caption) | |
| 32 | .padding(.horizontal, 6) | |
| 33 | .padding(.vertical, 2) | |
| 34 | .background(.quaternary, in: Capsule()) | |
| 35 | } | |
| 36 | } | |
| 37 | } | |
| 38 | } | |
| 39 | } | |
| 22 | 40 | sshSection(loaded.sshKeys) |
| 23 | 41 | pgpSection(loaded.pgpKeys) |
| 24 | 42 | // Feedback sits beside the email actions: with the |
gitbay/Views/Discovery/ProfileView.swift +8
| @@ -41,6 +41,14 @@ struct ProfileView: View { | ||
| 41 | 41 | .padding(.vertical, 4) |
| 42 | 42 | } |
| 43 | 43 | |
| 44 | if profile.kind == "org" { | |
| 45 | Section { | |
| 46 | NavigationLink(value: OrgRoute.org(profile.name)) { | |
| 47 | Label("Members & Teams", systemImage: "person.3") | |
| 48 | } | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 44 | 52 | if !model.repos.isEmpty { |
| 45 | 53 | Section("Repositories") { |
| 46 | 54 | ForEach(model.repos) { repo in |
gitbay/Views/Orgs/OrgView.swift added +158
| @@ -0,0 +1,158 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// An organization's members and teams. Reads need membership — the | |
| 4 | /// server's refusal explains itself to outsiders. | |
| 5 | struct OrgView: View { | |
| 6 | ||
| 7 | @State private var model: OrgViewModel | |
| 8 | @State private var newMember = "" | |
| 9 | @State private var newMemberRole = "member" | |
| 10 | @State private var newTeam = "" | |
| 11 | @State private var removingMember: OrgMember? | |
| 12 | @State private var deletingTeam: String? | |
| 13 | ||
| 14 | init(client: GitbayClient, org: String) { | |
| 15 | _model = State(initialValue: OrgViewModel(client: client, orgName: org)) | |
| 16 | } | |
| 17 | ||
| 18 | var body: some View { | |
| 19 | List { | |
| 20 | if let loaded = model.state.value { | |
| 21 | if let error = model.actionError { | |
| 22 | Section { | |
| 23 | Label(error, systemImage: "hand.raised") | |
| 24 | .foregroundStyle(.orange) | |
| 25 | .font(.subheadline) | |
| 26 | } | |
| 27 | } | |
| 28 | membersSection(loaded.members) | |
| 29 | teamsSection(loaded.teams) | |
| 30 | } | |
| 31 | } | |
| 32 | .overlay { LoadStateOverlay(state: model.state) } | |
| 33 | .navigationTitle(model.orgName) | |
| 34 | .navigationBarTitleDisplayMode(.inline) | |
| 35 | .task { await model.load() } | |
| 36 | .refreshable { await model.load() } | |
| 37 | .confirmationDialog( | |
| 38 | "Remove \(removingMember?.user ?? "") from \(model.orgName)?", | |
| 39 | isPresented: Binding( | |
| 40 | get: { removingMember != nil }, | |
| 41 | set: { if !$0 { removingMember = nil } } | |
| 42 | ) | |
| 43 | ) { | |
| 44 | Button("Remove", role: .destructive) { | |
| 45 | if let member = removingMember { | |
| 46 | Task { await model.removeMember(member) } | |
| 47 | } | |
| 48 | removingMember = nil | |
| 49 | } | |
| 50 | Button("Cancel", role: .cancel) {} | |
| 51 | } | |
| 52 | .confirmationDialog( | |
| 53 | "Delete team \(deletingTeam ?? "")? Its repo grants go with it.", | |
| 54 | isPresented: Binding( | |
| 55 | get: { deletingTeam != nil }, | |
| 56 | set: { if !$0 { deletingTeam = nil } } | |
| 57 | ) | |
| 58 | ) { | |
| 59 | Button("Delete", role: .destructive) { | |
| 60 | if let team = deletingTeam { | |
| 61 | Task { await model.deleteTeam(team) } | |
| 62 | } | |
| 63 | deletingTeam = nil | |
| 64 | } | |
| 65 | Button("Cancel", role: .cancel) {} | |
| 66 | } | |
| 67 | } | |
| 68 | ||
| 69 | private func membersSection(_ members: [OrgMember]) -> some View { | |
| 70 | Section("Members") { | |
| 71 | ForEach(members) { member in | |
| 72 | HStack { | |
| 73 | NavigationLink(value: RepoRoute.profile(member.user)) { | |
| 74 | Text(member.user) | |
| 75 | .font(.subheadline) | |
| 76 | } | |
| 77 | Spacer() | |
| 78 | Menu { | |
| 79 | Button("Admin") { Task { await model.setRole(member, role: "admin") } } | |
| 80 | Button("Member") { Task { await model.setRole(member, role: "member") } } | |
| 81 | } label: { | |
| 82 | Text(member.role) | |
| 83 | .font(.caption) | |
| 84 | .padding(.horizontal, 6) | |
| 85 | .padding(.vertical, 2) | |
| 86 | .background(.quaternary, in: Capsule()) | |
| 87 | } | |
| 88 | .disabled(model.working) | |
| 89 | } | |
| 90 | .swipeActions { | |
| 91 | Button("Remove", role: .destructive) { | |
| 92 | removingMember = member | |
| 93 | } | |
| 94 | } | |
| 95 | } | |
| 96 | HStack { | |
| 97 | TextField("Add user", text: $newMember) | |
| 98 | .autocorrectionDisabled() | |
| 99 | .textInputAutocapitalization(.never) | |
| 100 | .accessibilityIdentifier("org-add-member") | |
| 101 | Picker("", selection: $newMemberRole) { | |
| 102 | Text("member").tag("member") | |
| 103 | Text("admin").tag("admin") | |
| 104 | } | |
| 105 | .labelsHidden() | |
| 106 | .fixedSize() | |
| 107 | Button { | |
| 108 | let user = newMember.trimmingCharacters(in: .whitespaces) | |
| 109 | newMember = "" | |
| 110 | Task { await model.addMember(user, role: newMemberRole) } | |
| 111 | } label: { | |
| 112 | Image(systemName: "plus.circle.fill") | |
| 113 | } | |
| 114 | .disabled(newMember.trimmingCharacters(in: .whitespaces).isEmpty || model.working) | |
| 115 | } | |
| 116 | } | |
| 117 | } | |
| 118 | ||
| 119 | private func teamsSection(_ teams: [String]) -> some View { | |
| 120 | Section { | |
| 121 | ForEach(teams, id: \.self) { team in | |
| 122 | NavigationLink(value: OrgRoute.team(org: model.orgName, team: team)) { | |
| 123 | Label(team, systemImage: "person.3") | |
| 124 | .font(.subheadline) | |
| 125 | } | |
| 126 | .swipeActions { | |
| 127 | Button("Delete", role: .destructive) { | |
| 128 | deletingTeam = team | |
| 129 | } | |
| 130 | } | |
| 131 | } | |
| 132 | HStack { | |
| 133 | TextField("New team", text: $newTeam) | |
| 134 | .autocorrectionDisabled() | |
| 135 | .textInputAutocapitalization(.never) | |
| 136 | .accessibilityIdentifier("org-new-team") | |
| 137 | Button { | |
| 138 | let team = newTeam.trimmingCharacters(in: .whitespaces) | |
| 139 | newTeam = "" | |
| 140 | Task { await model.createTeam(team) } | |
| 141 | } label: { | |
| 142 | Image(systemName: "plus.circle.fill") | |
| 143 | } | |
| 144 | .disabled(newTeam.trimmingCharacters(in: .whitespaces).isEmpty || model.working) | |
| 145 | .accessibilityIdentifier("org-new-team-submit") | |
| 146 | } | |
| 147 | } header: { | |
| 148 | Text("Teams") | |
| 149 | } footer: { | |
| 150 | Text("Plain membership already implies a role on every org repo; teams grant more.") | |
| 151 | } | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | nonisolated enum OrgRoute: Hashable { | |
| 156 | case org(String) | |
| 157 | case team(org: String, team: String) | |
| 158 | } | |
gitbay/Views/Orgs/TeamView.swift added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// One team: its members and its repo grants. | |
| 4 | struct TeamView: View { | |
| 5 | ||
| 6 | @State private var model: TeamViewModel | |
| 7 | @State private var newMember = "" | |
| 8 | @State private var grantRepo = "" | |
| 9 | @State private var grantRole = "read" | |
| 10 | ||
| 11 | init(client: GitbayClient, org: String, team: String) { | |
| 12 | _model = State(initialValue: TeamViewModel(client: client, orgName: org, teamName: team)) | |
| 13 | } | |
| 14 | ||
| 15 | var body: some View { | |
| 16 | List { | |
| 17 | if let loaded = model.state.value { | |
| 18 | if let error = model.actionError { | |
| 19 | Section { | |
| 20 | Label(error, systemImage: "hand.raised") | |
| 21 | .foregroundStyle(.orange) | |
| 22 | .font(.subheadline) | |
| 23 | } | |
| 24 | } | |
| 25 | ||
| 26 | Section("Members") { | |
| 27 | ForEach(loaded.members ?? [], id: \.self) { user in | |
| 28 | Text(user) | |
| 29 | .font(.subheadline) | |
| 30 | .swipeActions { | |
| 31 | Button("Remove", role: .destructive) { | |
| 32 | Task { await model.removeMember(user) } | |
| 33 | } | |
| 34 | } | |
| 35 | } | |
| 36 | HStack { | |
| 37 | TextField("Add org member", text: $newMember) | |
| 38 | .autocorrectionDisabled() | |
| 39 | .textInputAutocapitalization(.never) | |
| 40 | Button { | |
| 41 | let user = newMember.trimmingCharacters(in: .whitespaces) | |
| 42 | newMember = "" | |
| 43 | Task { await model.addMember(user) } | |
| 44 | } label: { | |
| 45 | Image(systemName: "plus.circle.fill") | |
| 46 | } | |
| 47 | .disabled(newMember.trimmingCharacters(in: .whitespaces).isEmpty | |
| 48 | || model.working) | |
| 49 | } | |
| 50 | } | |
| 51 | ||
| 52 | Section { | |
| 53 | ForEach(loaded.grants ?? []) { grant in | |
| 54 | HStack { | |
| 55 | Text(grant.repo) | |
| 56 | .font(.subheadline) | |
| 57 | .lineLimit(1) | |
| 58 | Spacer() | |
| 59 | Text(grant.role) | |
| 60 | .font(.caption) | |
| 61 | .padding(.horizontal, 6) | |
| 62 | .padding(.vertical, 2) | |
| 63 | .background(.quaternary, in: Capsule()) | |
| 64 | } | |
| 65 | .swipeActions { | |
| 66 | Button("Revoke", role: .destructive) { | |
| 67 | Task { await model.revoke(grant) } | |
| 68 | } | |
| 69 | } | |
| 70 | } | |
| 71 | VStack(spacing: 8) { | |
| 72 | TextField("org/repo", text: $grantRepo) | |
| 73 | .autocorrectionDisabled() | |
| 74 | .textInputAutocapitalization(.never) | |
| 75 | HStack { | |
| 76 | Picker("Role", selection: $grantRole) { | |
| 77 | Text("read").tag("read") | |
| 78 | Text("write").tag("write") | |
| 79 | Text("admin").tag("admin") | |
| 80 | } | |
| 81 | .pickerStyle(.segmented) | |
| 82 | Button("Grant") { | |
| 83 | let repo = grantRepo.trimmingCharacters(in: .whitespaces) | |
| 84 | grantRepo = "" | |
| 85 | Task { await model.grant(repo: repo, role: grantRole) } | |
| 86 | } | |
| 87 | .disabled(!grantRepo.contains("/") || model.working) | |
| 88 | } | |
| 89 | } | |
| 90 | } header: { | |
| 91 | Text("Repository grants") | |
| 92 | } footer: { | |
| 93 | Text("A grant gives every team member the role on that org repository.") | |
| 94 | } | |
| 95 | } | |
| 96 | } | |
| 97 | .overlay { LoadStateOverlay(state: model.state) } | |
| 98 | .navigationTitle(model.teamName) | |
| 99 | .navigationBarTitleDisplayMode(.inline) | |
| 100 | .task { await model.load() } | |
| 101 | .refreshable { await model.load() } | |
| 102 | } | |
| 103 | } | |
gitbayTests/AccountTests.swift +7
| @@ -28,6 +28,9 @@ private let pgpJSON = """ | ||
| 28 | 28 | "emails":"[\\"hello@cleberg.net\\"]"}],"exit_code":0} |
| 29 | 29 | """ |
| 30 | 30 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# |
| 31 | private let orgListJSON = """ | |
| 32 | {"protocol_version":1,"data":[{"org":"krz","role":"admin"}],"exit_code":0} | |
| 33 | """ | |
| 31 | 34 | |
| 32 | 35 | @MainActor |
| 33 | 36 | struct AccountViewModelTests { |
| @@ -36,6 +39,7 @@ struct AccountViewModelTests { | ||
| 36 | 39 | let (client, stub) = try makeClient() |
| 37 | 40 | stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys")) |
| 38 | 41 | stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp")) |
| 42 | stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org")) | |
| 39 | 43 | let model = AccountViewModel(client: client) |
| 40 | 44 | await model.load() |
| 41 | 45 | return (model, stub) |
| @@ -55,6 +59,7 @@ struct AccountViewModelTests { | ||
| 55 | 59 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) |
| 56 | 60 | stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys")) |
| 57 | 61 | stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp")) |
| 62 | stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org")) | |
| 58 | 63 | |
| 59 | 64 | await model.addSSHKey("ssh-ed25519 AAAAC3Nza phone\n", scope: "git") |
| 60 | 65 | |
| @@ -70,6 +75,7 @@ struct AccountViewModelTests { | ||
| 70 | 75 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) |
| 71 | 76 | stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys")) |
| 72 | 77 | stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp")) |
| 78 | stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org")) | |
| 73 | 79 | } |
| 74 | 80 | let loaded = try #require(model.state.value) |
| 75 | 81 | |
| @@ -87,6 +93,7 @@ struct AccountViewModelTests { | ||
| 87 | 93 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) |
| 88 | 94 | stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys")) |
| 89 | 95 | stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp")) |
| 96 | stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org")) | |
| 90 | 97 | } |
| 91 | 98 | |
| 92 | 99 | await model.addEmail(" claude@cleberg.net ") |
gitbayTests/OrgTests.swift added +149
| @@ -0,0 +1,149 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 6 | let box = StubProtocol.box() | |
| 7 | let client = GitbayClient( | |
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 9 | token: "test-token", | |
| 10 | session: box.session() | |
| 11 | ) | |
| 12 | return (client, box) | |
| 13 | } | |
| 14 | ||
| 15 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | |
| 16 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | |
| 17 | return try #require(body["argv"] as? [String]) | |
| 18 | } | |
| 19 | ||
| 20 | private let orgShowJSON = """ | |
| 21 | {"protocol_version":1,"data":{"org":"krz","members":[\ | |
| 22 | {"user":"cmc","role":"admin"},{"user":"guest","role":"member"}]},"exit_code":0} | |
| 23 | """ | |
| 24 | private let teamsJSON = #"{"protocol_version":1,"data":["core"],"exit_code":0}"# | |
| 25 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# | |
| 26 | ||
| 27 | @MainActor | |
| 28 | struct OrgViewModelTests { | |
| 29 | ||
| 30 | private func loadedModel() async throws -> (OrgViewModel, StubProtocol.Box) { | |
| 31 | let (client, stub) = try makeClient() | |
| 32 | stub.enqueue(.init(status: 200, json: orgShowJSON, match: "argv=show")) | |
| 33 | stub.enqueue(.init(status: 200, json: teamsJSON, match: "argv=team")) | |
| 34 | let model = OrgViewModel(client: client, orgName: "krz") | |
| 35 | await model.load() | |
| 36 | return (model, stub) | |
| 37 | } | |
| 38 | ||
| 39 | @Test func loadsMembersAndBareTeamNames() async throws { | |
| 40 | let (model, _) = try await loadedModel() | |
| 41 | ||
| 42 | let loaded = try #require(model.state.value) | |
| 43 | #expect(loaded.members.map(\.user) == ["cmc", "guest"]) | |
| 44 | #expect(loaded.teams == ["core"]) | |
| 45 | } | |
| 46 | ||
| 47 | @Test func membershipWritesUseTheRightCommands() async throws { | |
| 48 | let (model, stub) = try await loadedModel() | |
| 49 | for _ in 0..<4 { | |
| 50 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 51 | stub.enqueue(.init(status: 200, json: orgShowJSON, match: "argv=show")) | |
| 52 | stub.enqueue(.init(status: 200, json: teamsJSON, match: "argv=team")) | |
| 53 | } | |
| 54 | let guest = try #require(model.state.value?.members.last) | |
| 55 | ||
| 56 | await model.addMember("newbie", role: "member") | |
| 57 | await model.setRole(guest, role: "admin") | |
| 58 | await model.removeMember(guest) | |
| 59 | await model.createTeam("infra") | |
| 60 | ||
| 61 | let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) | |
| 62 | #expect(writes == [ | |
| 63 | ["org", "members", "add", "krz", "newbie", "--role", "member"], | |
| 64 | ["org", "members", "add", "krz", "guest", "--role", "admin"], | |
| 65 | ["org", "members", "remove", "krz", "guest"], | |
| 66 | ["org", "team", "create", "krz", "infra"], | |
| 67 | ]) | |
| 68 | } | |
| 69 | ||
| 70 | @Test func anOutsiderGetsTheRefusalAsTheScreenState() async throws { | |
| 71 | let (client, stub) = try makeClient() | |
| 72 | stub.enqueue(.init(status: 403, json: | |
| 73 | #"{"protocol_version":1,"error":"you are not a member of krz","exit_code":4}"#, | |
| 74 | match: "argv=show")) | |
| 75 | stub.enqueue(.init(status: 403, json: | |
| 76 | #"{"protocol_version":1,"error":"you are not a member of krz","exit_code":4}"#, | |
| 77 | match: "argv=team")) | |
| 78 | let model = OrgViewModel(client: client, orgName: "krz") | |
| 79 | ||
| 80 | await model.load() | |
| 81 | ||
| 82 | guard case .failed(let message) = model.state else { | |
| 83 | Issue.record("expected .failed, got \(model.state)") | |
| 84 | return | |
| 85 | } | |
| 86 | #expect(message == "you are not a member of krz") | |
| 87 | } | |
| 88 | } | |
| 89 | ||
| 90 | @MainActor | |
| 91 | struct TeamViewModelTests { | |
| 92 | ||
| 93 | private let teamShowJSON = """ | |
| 94 | {"protocol_version":1,"data":{"team":"core","members":["cmc"],\ | |
| 95 | "grants":[{"repo":"krz/gitbay","role":"write"}]},"exit_code":0} | |
| 96 | """ | |
| 97 | ||
| 98 | private func loadedModel() async throws -> (TeamViewModel, StubProtocol.Box) { | |
| 99 | let (client, stub) = try makeClient() | |
| 100 | stub.enqueue(.init(status: 200, json: teamShowJSON, match: "argv=show")) | |
| 101 | let model = TeamViewModel(client: client, orgName: "krz", teamName: "core") | |
| 102 | await model.load() | |
| 103 | return (model, stub) | |
| 104 | } | |
| 105 | ||
| 106 | @Test func loadsMembersAndGrants() async throws { | |
| 107 | let (model, _) = try await loadedModel() | |
| 108 | ||
| 109 | let loaded = try #require(model.state.value) | |
| 110 | #expect(loaded.members == ["cmc"]) | |
| 111 | #expect(loaded.grants?.first?.repo == "krz/gitbay") | |
| 112 | } | |
| 113 | ||
| 114 | @Test func teamWritesTargetOrgTeamAndSubject() async throws { | |
| 115 | let (model, stub) = try await loadedModel() | |
| 116 | for _ in 0..<4 { | |
| 117 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | |
| 118 | stub.enqueue(.init(status: 200, json: teamShowJSON, match: "argv=show")) | |
| 119 | } | |
| 120 | let grant = try #require(model.state.value?.grants?.first) | |
| 121 | ||
| 122 | await model.addMember("guest") | |
| 123 | await model.removeMember("guest") | |
| 124 | await model.grant(repo: "krz/orgo", role: "admin") | |
| 125 | await model.revoke(grant) | |
| 126 | ||
| 127 | let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) | |
| 128 | #expect(writes == [ | |
| 129 | ["org", "team", "add", "krz", "core", "guest"], | |
| 130 | ["org", "team", "remove", "krz", "core", "guest"], | |
| 131 | ["org", "team", "grant", "krz", "core", "krz/orgo", "admin"], | |
| 132 | ["org", "team", "revoke", "krz", "core", "krz/gitbay"], | |
| 133 | ]) | |
| 134 | } | |
| 135 | ||
| 136 | @Test func aMissingTeamIsAnEmptyState() async throws { | |
| 137 | let (client, stub) = try makeClient() | |
| 138 | stub.enqueue(.init(status: 404, json: | |
| 139 | #"{"protocol_version":1,"error":"no team \"core\" in krz","exit_code":3}"#)) | |
| 140 | let model = TeamViewModel(client: client, orgName: "krz", teamName: "core") | |
| 141 | ||
| 142 | await model.load() | |
| 143 | ||
| 144 | guard case .empty = model.state else { | |
| 145 | Issue.record("expected .empty, got \(model.state)") | |
| 146 | return | |
| 147 | } | |
| 148 | } | |
| 149 | } | |
gitbayUITests/LiveSmokeUITests.swift +58
| @@ -415,3 +415,61 @@ extension LiveSmokeUITests { | ||
| 415 | 415 | .waitForExistence(timeout: 15), "bad-code refusal not surfaced") |
| 416 | 416 | } |
| 417 | 417 | } |
| 418 | ||
| 419 | extension LiveSmokeUITests { | |
| 420 | ||
| 421 | /// Orgs: members render, and a team lives a full life — created, | |
| 422 | /// granted a repo, the grant revoked, the team deleted. Everything | |
| 423 | /// this test makes, it removes. | |
| 424 | func testOrgFlows() throws { | |
| 425 | let accountLink = app.descendants(matching: .any) | |
| 426 | .matching(identifier: "account-screen-link").firstMatch | |
| 427 | XCTAssertTrue(accountLink.waitForExistence(timeout: 10)) | |
| 428 | accountLink.tap() | |
| 429 | ||
| 430 | let orgRow = app.staticTexts["krz"].firstMatch | |
| 431 | XCTAssertTrue(orgRow.waitForExistence(timeout: 15), "org list missing") | |
| 432 | orgRow.tap() | |
| 433 | ||
| 434 | // Members render with roles. | |
| 435 | XCTAssertTrue(app.staticTexts["cmc"].firstMatch | |
| 436 | .waitForExistence(timeout: 15), "org members missing") | |
| 437 | ||
| 438 | // Create a team. | |
| 439 | let teamField = app.descendants(matching: .any) | |
| 440 | .matching(identifier: "org-new-team").firstMatch | |
| 441 | XCTAssertTrue(teamField.waitForExistence(timeout: 5)) | |
| 442 | teamField.tap() | |
| 443 | teamField.typeText("ui-smoke") | |
| 444 | app.descendants(matching: .any).matching(identifier: "org-new-team-submit") | |
| 445 | .firstMatch.tap() | |
| 446 | let teamRow = app.staticTexts["ui-smoke"].firstMatch | |
| 447 | XCTAssertTrue(teamRow.waitForExistence(timeout: 15), "created team not listed") | |
| 448 | ||
| 449 | // Grant it a repo, then revoke. | |
| 450 | teamRow.tap() | |
| 451 | let repoField = app.textFields["org/repo"].firstMatch | |
| 452 | XCTAssertTrue(repoField.waitForExistence(timeout: 10)) | |
| 453 | repoField.tap() | |
| 454 | repoField.typeText("krz/gitbay-ios") | |
| 455 | app.buttons["Grant"].firstMatch.tap() | |
| 456 | let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch | |
| 457 | XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed") | |
| 458 | grantRow.swipeLeft() | |
| 459 | app.buttons["Revoke"].firstMatch.tap() | |
| 460 | XCTAssertTrue(waitForDisappearance(grantRow, timeout: 15), "grant not revoked") | |
| 461 | ||
| 462 | back() | |
| 463 | ||
| 464 | // Delete the team, through its confirmation. | |
| 465 | let row = app.staticTexts["ui-smoke"].firstMatch | |
| 466 | XCTAssertTrue(row.waitForExistence(timeout: 10)) | |
| 467 | row.swipeLeft() | |
| 468 | app.buttons["Delete"].firstMatch.tap() | |
| 469 | // The confirmation dialog's destructive Delete. | |
| 470 | let confirm = app.buttons["Delete"].firstMatch | |
| 471 | XCTAssertTrue(confirm.waitForExistence(timeout: 5)) | |
| 472 | confirm.tap() | |
| 473 | XCTAssertTrue(waitForDisappearance(row, timeout: 15), "team not deleted") | |
| 474 | } | |
| 475 | } | |