a native ios client for gitbay

client ios swift

https://gitbay.org

Commit 6c517ed0b5

6c517ed0b5285d49aebb9d0683984cc8db5675a8

parent: 6c661bb62e

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-27T16:16:34Z

orgs: members, roles, teams, repo grants

The last parity group. An org's members and teams live behind its
profile ('Members & Teams') and the Account screen's Organizations
section (org list with your role). Members: add with a role picker,
role changes via members add (it updates), removal behind a
confirmation. Teams: create inline, delete behind a confirmation
naming that grants go with it, and a team screen with members and repo
grants (grant with a read/write/admin picker, revoke by swipe).

org team list returns bare names; org settings members-role has no
read command, so no blind-write UI for it. Org create/rename/delete
stay CLI-only by design.

Live UI smoke (testOrgFlows, self-cleaning): members render on krz,
then a team is created, granted krz/gitbay-ios, the grant revoked, and
the team deleted — verified gone server-side.

Ref #11
gitbay/Account/AccountViewModel.swift +5 −1
@@ -32,6 +32,7 @@ final class AccountViewModel {
3232 nonisolated struct Loaded: Sendable, Hashable {
3333 let sshKeys: [SSHKey]
3434 let pgpKeys: [PGPKey]
35 let orgs: [OrgMembership]
3536 }
3637
3738 private(set) var state: LoadState<Loaded> = .loading
@@ -51,7 +52,10 @@ final class AccountViewModel {
5152 do {
5253 async let ssh = client.readList(["keys", "list"], of: SSHKey.self)
5354 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 ))
5559 } catch {
5660 state = .from(error)
5761 }
gitbay/ContentView.swift +13
@@ -58,6 +58,9 @@ private struct RouteDestinations: ViewModifier {
5858 .navigationDestination(for: ReleaseRoute.self) { route in
5959 destination(route, client: client)
6060 }
61 .navigationDestination(for: OrgRoute.self) { route in
62 destination(route, client: client)
63 }
6164 }
6265
6366 @ViewBuilder
@@ -106,6 +109,16 @@ private struct RouteDestinations: ViewModifier {
106109 }
107110 }
108111
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
109122 @ViewBuilder
110123 private func destination(_ route: ReleaseRoute, client: GitbayClient) -> some View {
111124 switch route {
gitbay/Orgs/OrgViewModels.swift added +162
@@ -0,0 +1,162 @@
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 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
96final 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 {
1919 var body: some View {
2020 List {
2121 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 }
2240 sshSection(loaded.sshKeys)
2341 pgpSection(loaded.pgpKeys)
2442 // Feedback sits beside the email actions: with the
gitbay/Views/Discovery/ProfileView.swift +8
@@ -41,6 +41,14 @@ struct ProfileView: View {
4141 .padding(.vertical, 4)
4242 }
4343
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
4452 if !model.repos.isEmpty {
4553 Section("Repositories") {
4654 ForEach(model.repos) { repo in
gitbay/Views/Orgs/OrgView.swift added +158
@@ -0,0 +1,158 @@
1import SwiftUI
2
3/// An organization's members and teams. Reads need membership the
4/// server's refusal explains itself to outsiders.
5struct 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
155nonisolated 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 @@
1import SwiftUI
2
3/// One team: its members and its repo grants.
4struct 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 = """
2828 "emails":"[\\"hello@cleberg.net\\"]"}],"exit_code":0}
2929 """
3030 private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
31private let orgListJSON = """
32 {"protocol_version":1,"data":[{"org":"krz","role":"admin"}],"exit_code":0}
33 """
3134
3235 @MainActor
3336 struct AccountViewModelTests {
@@ -36,6 +39,7 @@ struct AccountViewModelTests {
3639 let (client, stub) = try makeClient()
3740 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
3841 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
42 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
3943 let model = AccountViewModel(client: client)
4044 await model.load()
4145 return (model, stub)
@@ -55,6 +59,7 @@ struct AccountViewModelTests {
5559 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
5660 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
5761 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
62 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
5863
5964 await model.addSSHKey("ssh-ed25519 AAAAC3Nza phone\n", scope: "git")
6065
@@ -70,6 +75,7 @@ struct AccountViewModelTests {
7075 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
7176 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
7277 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
78 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
7379 }
7480 let loaded = try #require(model.state.value)
7581
@@ -87,6 +93,7 @@ struct AccountViewModelTests {
8793 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
8894 stub.enqueue(.init(status: 200, json: keysJSON, match: "argv=keys"))
8995 stub.enqueue(.init(status: 200, json: pgpJSON, match: "argv=pgp"))
96 stub.enqueue(.init(status: 200, json: orgListJSON, match: "argv=org"))
9097 }
9198
9299 await model.addEmail(" claude@cleberg.net ")
gitbayTests/OrgTests.swift added +149
@@ -0,0 +1,149 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private 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
15private 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
20private let orgShowJSON = """
21 {"protocol_version":1,"data":{"org":"krz","members":[\
22 {"user":"cmc","role":"admin"},{"user":"guest","role":"member"}]},"exit_code":0}
23 """
24private let teamsJSON = #"{"protocol_version":1,"data":["core"],"exit_code":0}"#
25private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
26
27@MainActor
28struct 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
91struct 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 {
415415 .waitForExistence(timeout: 15), "bad-code refusal not surfaced")
416416 }
417417 }
418
419extension 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}