a native ios client for gitbay

client ios swift

https://gitbay.org

Commit b55fbb39ba

b55fbb39ba7c4d60e950ff563fa3c0e3cda63ce8

parent: 530c076c61

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-27T15:23:29Z

repo management: pin, settings, create, archive, build trigger

The Repositories parity rows the web already has:

- Pin and unpin from the repo screen's actions menu; pin state read
  from the dashboard aggregate, since that is the only place pins are
  readable.
- A settings screen behind repo settings show (admin-gated; non-admins
  see the refusal): description, website, visibility, topics, protected
  branches, require-approvals/-resolved/-checks/-signed, git-daemon.
  One command per knob, reload after each. Toggles and the stepper hold
  real local state mirrored from the server — a computed Binding whose
  setter spawns a Task dropped writes under synthesized taps.
- repo create from the list (+): owner/name with the username
  prefilled, --private toggle.
- Archive/unarchive behind a confirmation dialog; build trigger from
  the builds screen with the job name prefilled from the latest build.

Live UI smoke extended (testRepoManagementFlows): create a scratch
repo (runner deletes it over SSH — deletion is CLI-only by design),
pin/unpin round-trip, topic round-trip, merge-rule toggle round-trip,
and the trigger refusal path on a repo with no CI config. Settings is
a links-section row, not a menu item — NavigationLink inside Menu does
not navigate.

Ref #11
gitbay/Builds/BuildListViewModel.swift +22
@@ -7,6 +7,8 @@ import Observation
77 final class BuildListViewModel {
88
99 private(set) var state: LoadState<[Build]> = .loading
10 private(set) var actionError: String?
11 private(set) var working = false
1012
1113 private let client: GitbayClient
1214 let repoPath: String
@@ -26,4 +28,24 @@ final class BuildListViewModel {
2628 state = .from(error)
2729 }
2830 }
31
32 /// The most recent job name, to prefill the trigger sheet.
33 var latestJob: String? {
34 state.value?.first?.job
35 }
36
37 /// `build trigger <owner/name> <job>` queue a job now.
38 func trigger(job: String) async {
39 working = true
40 actionError = nil
41 defer { working = false }
42 do {
43 try await client.run(["build", "trigger", repoPath, job])
44 await load()
45 } catch let error as GitbayError {
46 actionError = error.userFacingMessage
47 } catch {
48 actionError = GitbayError.transport(error).userFacingMessage
49 }
50 }
2951 }
gitbay/ContentView.swift +2
@@ -62,6 +62,8 @@ private struct RouteDestinations: ViewModifier {
6262 FileView(client: client, repo: repo, path: path, ref: ref)
6363 case .log(let repo):
6464 LogView(client: client, repo: repo)
65 case .settings(let repo):
66 RepoSettingsView(client: client, repo: repo)
6567 case .addAccount:
6668 SignInView()
6769 }
gitbay/Repos/RepoCreateViewModel.swift added +39
@@ -0,0 +1,39 @@
1import Foundation
2import Observation
3
4/// `repo create <owner/name> [--private]`.
5@Observable
6@MainActor
7final class RepoCreateViewModel {
8
9 private(set) var working = false
10 private(set) var errorMessage: String?
11
12 private let client: GitbayClient
13
14 init(client: GitbayClient) {
15 self.client = client
16 }
17
18 nonisolated private struct Created: Decodable, Sendable {
19 let path: String
20 }
21
22 /// Returns the created repo's path, or nil with `errorMessage` set.
23 func create(path: String, isPrivate: Bool) async -> String? {
24 working = true
25 errorMessage = nil
26 defer { working = false }
27 do {
28 var argv = ["repo", "create", path.trimmingCharacters(in: .whitespaces)]
29 if isPrivate { argv.append("--private") }
30 let created = try await client.run(argv, as: Created.self)
31 return created?.path
32 } catch let error as GitbayError {
33 errorMessage = error.userFacingMessage
34 } catch {
35 errorMessage = GitbayError.transport(error).userFacingMessage
36 }
37 return nil
38 }
39}
gitbay/Repos/RepoDetailViewModel.swift +40
@@ -10,6 +10,11 @@ final class RepoDetailViewModel {
1010 private(set) var state: LoadState<RepoDetail> = .loading
1111 /// README markdown, when the root tree has one. Absent is normal.
1212 private(set) var readme: String?
13 /// Whether this repo is on the account's dashboard. nil until known
14 /// pin state only exists in the dashboard aggregate.
15 private(set) var isPinned: Bool?
16 private(set) var actionError: String?
17 private(set) var working = false
1318
1419 private let client: GitbayClient
1520 private let path: String
@@ -27,9 +32,44 @@ final class RepoDetailViewModel {
2732 state = .from(error)
2833 return
2934 }
35 await loadPinned()
3036 await loadReadme()
3137 }
3238
39 private func loadPinned() async {
40 guard let dashboard = try? await client.read(["dashboard"], as: DashboardData.self) else {
41 return
42 }
43 isPinned = dashboard.pinned.contains { $0.path == path }
44 }
45
46 // MARK: - Management actions
47
48 func setPinned(_ pinned: Bool) async {
49 await perform(["repo", pinned ? "pin" : "unpin", path])
50 await loadPinned()
51 }
52
53 func setArchived(_ archived: Bool) async {
54 await perform(["repo", archived ? "archive" : "unarchive", path])
55 }
56
57 private func perform(_ argv: [String]) async {
58 working = true
59 actionError = nil
60 defer { working = false }
61 do {
62 try await client.run(argv)
63 if let detail = try? await client.read(["repo", "show", path], as: RepoDetail.self) {
64 state = .loaded(detail)
65 }
66 } catch let error as GitbayError {
67 actionError = error.userFacingMessage
68 } catch {
69 actionError = GitbayError.transport(error).userFacingMessage
70 }
71 }
72
3373 private func loadReadme() async {
3474 // The README is whatever the root tree calls one, matched the way
3575 // the web UI matches: README, README.md, readme.org, and so on.
gitbay/Repos/RepoSettings.swift added +26
@@ -0,0 +1,26 @@
1import Foundation
2
3/// `repo settings show` admin-only view of the repo's control knobs.
4/// Every field is omitted at its zero value, so absent means default.
5nonisolated struct RepoSettings: Decodable, Sendable, Hashable {
6 let protectedBranches: [String]?
7 let requireSignedCommits: Bool?
8 let requireChecks: Bool?
9 let requireApprovals: Int?
10 let requireResolved: Bool?
11 let gitDaemon: Bool?
12 let archived: Bool?
13 let website: String?
14
15 enum CodingKeys: String, CodingKey {
16 case protectedBranches = "protected_branches"
17 case requireSignedCommits = "require_signed_commits"
18 case requireChecks = "require_checks"
19 case requireApprovals = "require_approvals"
20 case requireResolved = "require_resolved"
21 case gitDaemon = "git_daemon"
22 case archived, website
23 }
24
25 var branches: [String] { protectedBranches ?? [] }
26}
gitbay/Repos/RepoSettingsViewModel.swift added +102
@@ -0,0 +1,102 @@
1import Foundation
2import Observation
3
4/// The repo settings screen: `repo settings show` for the admin knobs,
5/// `repo show` for description/topics/visibility, and one write command
6/// per control. Non-admins get the server's refusal verbatim.
7@Observable
8@MainActor
9final class RepoSettingsViewModel {
10
11 nonisolated struct Loaded: Sendable, Hashable {
12 let settings: RepoSettings
13 let detail: RepoDetail
14 }
15
16 private(set) var state: LoadState<Loaded> = .loading
17 private(set) var actionError: String?
18 private(set) var working = false
19
20 private let client: GitbayClient
21 let repoPath: String
22
23 init(client: GitbayClient, repoPath: String) {
24 self.client = client
25 self.repoPath = repoPath
26 }
27
28 func load() async {
29 do {
30 async let settings = client.read(
31 ["repo", "settings", "show", repoPath], as: RepoSettings.self)
32 async let detail = client.read(["repo", "show", repoPath], as: RepoDetail.self)
33 state = .loaded(Loaded(settings: try await settings, detail: try await detail))
34 } catch {
35 state = .from(error)
36 }
37 }
38
39 // MARK: - Writes, one command per knob
40
41 func setDescription(_ text: String) async {
42 await perform(["repo", "settings", "description", repoPath, text])
43 }
44
45 func setWebsite(_ url: String) async {
46 await perform(["repo", "settings", "website", repoPath, url])
47 }
48
49 func setVisibility(_ visibility: String) async {
50 await perform(["repo", "settings", "visibility", repoPath, visibility])
51 }
52
53 func setGitDaemon(_ on: Bool) async {
54 await perform(["repo", "settings", "git-daemon", repoPath, on ? "on" : "off"])
55 }
56
57 func protectBranch(_ branch: String) async {
58 await perform(["repo", "settings", "protect", repoPath, branch])
59 }
60
61 func unprotectBranch(_ branch: String) async {
62 await perform(["repo", "settings", "unprotect", repoPath, branch])
63 }
64
65 func setRequireApprovals(_ count: Int) async {
66 await perform(["repo", "settings", "require-approvals", repoPath, String(count)])
67 }
68
69 func setRequireResolved(_ on: Bool) async {
70 await perform(["repo", "settings", "require-resolved", repoPath, on ? "on" : "off"])
71 }
72
73 func setRequireChecks(_ on: Bool) async {
74 await perform(["repo", "settings", "require-checks", repoPath, on ? "on" : "off"])
75 }
76
77 func setRequireSigned(_ on: Bool) async {
78 await perform(["repo", "settings", "require-signed", repoPath, on ? "on" : "off"])
79 }
80
81 func addTopic(_ topic: String) async {
82 await perform(["repo", "topics", "add", repoPath, topic])
83 }
84
85 func removeTopic(_ topic: String) async {
86 await perform(["repo", "topics", "remove", repoPath, topic])
87 }
88
89 private func perform(_ argv: [String]) async {
90 working = true
91 actionError = nil
92 defer { working = false }
93 do {
94 try await client.run(argv)
95 await load()
96 } catch let error as GitbayError {
97 actionError = error.userFacingMessage
98 } catch {
99 actionError = GitbayError.transport(error).userFacingMessage
100 }
101 }
102}
gitbay/Views/Builds/BuildListView.swift +32
@@ -3,6 +3,8 @@ import SwiftUI
33 struct BuildListView: View {
44
55 @State private var model: BuildListViewModel
6 @State private var triggering = false
7 @State private var jobName = ""
68
79 init(client: GitbayClient, repo: String) {
810 _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo))
@@ -10,6 +12,13 @@ struct BuildListView: View {
1012
1113 var body: some View {
1214 List {
15 if let error = model.actionError {
16 Section {
17 Label(error, systemImage: "hand.raised")
18 .foregroundStyle(.orange)
19 .font(.subheadline)
20 }
21 }
1322 ForEach(model.state.value ?? []) { build in
1423 NavigationLink(value: BuildRoute.log(repo: model.repoPath, number: build.number)) {
1524 BuildRow(build: build)
@@ -19,6 +28,29 @@ struct BuildListView: View {
1928 .overlay { LoadStateOverlay(state: model.state) }
2029 .navigationTitle("Builds")
2130 .navigationBarTitleDisplayMode(.inline)
31 .toolbar {
32 ToolbarItem(placement: .topBarTrailing) {
33 Button {
34 jobName = model.latestJob ?? ""
35 triggering = true
36 } label: {
37 Image(systemName: "play.circle")
38 }
39 .disabled(model.working)
40 .accessibilityIdentifier("build-trigger-button")
41 }
42 }
43 .alert("Trigger a build", isPresented: $triggering) {
44 TextField("Job name", text: $jobName)
45 .autocorrectionDisabled()
46 .textInputAutocapitalization(.never)
47 Button("Trigger") {
48 Task { await model.trigger(job: jobName.trimmingCharacters(in: .whitespaces)) }
49 }
50 Button("Cancel", role: .cancel) {}
51 } message: {
52 Text("Runs the named job from .gitbay/ at the default branch head.")
53 }
2254 .task { await model.load() }
2355 .refreshable { await model.load() }
2456 }
gitbay/Views/Repos/RepoListView.swift +93 −1
@@ -4,9 +4,12 @@ struct RepoListView: View {
44
55 @Environment(SessionStore.self) private var session
66 @State private var model: RepoListViewModel
7 @State private var createModel: RepoCreateViewModel
8 @State private var composing = false
79
810 init(client: GitbayClient) {
911 _model = State(initialValue: RepoListViewModel(client: client))
12 _createModel = State(initialValue: RepoCreateViewModel(client: client))
1013 }
1114
1215 var body: some View {
@@ -23,12 +26,101 @@ struct RepoListView: View {
2326 .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) }
2427 .searchable(text: Bindable(model).searchText, prompt: "Filter repositories")
2528 .navigationTitle("Repositories")
26 .toolbar { AccountMenu() }
29 .toolbar {
30 ToolbarItem(placement: .topBarTrailing) {
31 Button {
32 composing = true
33 } label: {
34 Image(systemName: "plus")
35 }
36 .accessibilityIdentifier("repo-create-button")
37 }
38 AccountMenu()
39 }
40 .sheet(isPresented: $composing) {
41 RepoCreateSheet(
42 model: createModel,
43 ownerPrefix: session.current?.username ?? ""
44 ) {
45 composing = false
46 Task { await model.load() }
47 }
48 }
2749 .task { await model.load() }
2850 .refreshable { await model.load() }
2951 }
3052 }
3153
54/// `repo create <owner/name> [--private]` the name carries the owner,
55/// so org repos are created by typing org/name.
56private struct RepoCreateSheet: View {
57
58 let model: RepoCreateViewModel
59 let ownerPrefix: String
60 let onCreated: () -> Void
61
62 @Environment(\.dismiss) private var dismiss
63 @State private var path = ""
64 @State private var isPrivate = false
65 @State private var seeded = false
66
67 var body: some View {
68 NavigationStack {
69 Form {
70 Section {
71 TextField("owner/name", text: $path)
72 .autocorrectionDisabled()
73 .textInputAutocapitalization(.never)
74 .accessibilityIdentifier("repo-create-path")
75 } footer: {
76 Text("Use org/name to create under an organization you can write to.")
77 }
78 Section {
79 Toggle("Private", isOn: $isPrivate)
80 }
81 if let error = model.errorMessage {
82 Section {
83 Label(error, systemImage: "exclamationmark.triangle")
84 .foregroundStyle(.red)
85 .font(.subheadline)
86 }
87 }
88 }
89 .navigationTitle("New Repository")
90 .navigationBarTitleDisplayMode(.inline)
91 .toolbar {
92 ToolbarItem(placement: .cancellationAction) {
93 Button("Cancel") { dismiss() }
94 }
95 ToolbarItem(placement: .confirmationAction) {
96 if model.working {
97 ProgressView()
98 } else {
99 Button("Create") {
100 Task {
101 if await model.create(path: path, isPrivate: isPrivate) != nil {
102 onCreated()
103 }
104 }
105 }
106 .disabled(!path.contains("/")
107 || path.hasSuffix("/")
108 || path.hasPrefix("/"))
109 .accessibilityIdentifier("repo-create-submit")
110 }
111 }
112 }
113 .interactiveDismissDisabled(model.working)
114 .onAppear {
115 if !seeded {
116 path = ownerPrefix.isEmpty ? "" : ownerPrefix + "/"
117 seeded = true
118 }
119 }
120 }
121 }
122}
123
32124 private struct RepoRow: View {
33125 let repo: RepoSummary
34126
gitbay/Views/Repos/RepoRoute.swift +1
@@ -7,5 +7,6 @@ nonisolated enum RepoRoute: Hashable {
77 case tree(repo: String, directory: String, ref: String?)
88 case file(repo: String, path: String, ref: String?)
99 case log(repo: String)
10 case settings(repo: String)
1011 case addAccount
1112 }
gitbay/Views/Repos/RepoSettingsView.swift added +274
@@ -0,0 +1,274 @@
1import SwiftUI
2
3/// Admin knobs, one command per control. A non-admin sees the server's
4/// refusal instead of a half-working form.
5struct RepoSettingsView: View {
6
7 @State private var model: RepoSettingsViewModel
8 @State private var descriptionText = ""
9 @State private var websiteText = ""
10 @State private var newTopic = ""
11 @State private var newBranch = ""
12 @State private var loadedOnce = false
13
14 init(client: GitbayClient, repo: String) {
15 _model = State(initialValue: RepoSettingsViewModel(client: client, repoPath: repo))
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 aboutSection(loaded)
29 topicsSection(loaded)
30 visibilitySection(loaded)
31 branchesSection(loaded)
32 mergeRulesSection(loaded)
33 daemonSection(loaded)
34 }
35 }
36 .overlay { LoadStateOverlay(state: model.state) }
37 .navigationTitle("Settings")
38 .navigationBarTitleDisplayMode(.inline)
39 .task {
40 await model.load()
41 if !loadedOnce, let loaded = model.state.value {
42 descriptionText = loaded.detail.description ?? ""
43 websiteText = loaded.settings.website ?? ""
44 loadedOnce = true
45 }
46 }
47 .refreshable { await model.load() }
48 }
49
50 // MARK: - Sections
51
52 private func aboutSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
53 Section("About") {
54 HStack {
55 TextField("Description", text: $descriptionText, axis: .vertical)
56 .lineLimit(1...3)
57 .accessibilityIdentifier("settings-description")
58 if descriptionText != (loaded.detail.description ?? "") {
59 Button("Save") {
60 Task { await model.setDescription(descriptionText) }
61 }
62 .font(.caption)
63 .disabled(model.working)
64 .accessibilityIdentifier("settings-description-save")
65 }
66 }
67 HStack {
68 TextField("Website", text: $websiteText)
69 .keyboardType(.URL)
70 .autocorrectionDisabled()
71 .textInputAutocapitalization(.never)
72 .accessibilityIdentifier("settings-website")
73 if websiteText != (loaded.settings.website ?? "") {
74 Button("Save") {
75 Task { await model.setWebsite(websiteText) }
76 }
77 .font(.caption)
78 .disabled(model.working)
79 }
80 }
81 }
82 }
83
84 private func topicsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
85 Section("Topics") {
86 if let topics = loaded.detail.topics, !topics.isEmpty {
87 ScrollView(.horizontal, showsIndicators: false) {
88 HStack(spacing: 6) {
89 ForEach(topics, id: \.self) { topic in
90 HStack(spacing: 3) {
91 Text(topic)
92 Button {
93 Task { await model.removeTopic(topic) }
94 } label: {
95 Image(systemName: "xmark.circle.fill")
96 .foregroundStyle(.tertiary)
97 }
98 .disabled(model.working)
99 }
100 .font(.caption)
101 .padding(.horizontal, 8)
102 .padding(.vertical, 3)
103 .background(.quaternary, in: Capsule())
104 }
105 }
106 }
107 }
108 HStack {
109 TextField("Add topic", text: $newTopic)
110 .autocorrectionDisabled()
111 .textInputAutocapitalization(.never)
112 .accessibilityIdentifier("settings-add-topic")
113 Button {
114 let topic = newTopic.trimmingCharacters(in: .whitespaces)
115 newTopic = ""
116 Task { await model.addTopic(topic) }
117 } label: {
118 Image(systemName: "plus.circle.fill")
119 }
120 .disabled(newTopic.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
121 .accessibilityIdentifier("settings-add-topic-submit")
122 }
123 }
124 }
125
126 private func visibilitySection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
127 Section {
128 Picker("Visibility", selection: Binding(
129 get: { loaded.detail.visibility },
130 set: { newValue in Task { await model.setVisibility(newValue) } }
131 )) {
132 Text("Public").tag("public")
133 Text("Private").tag("private")
134 }
135 .disabled(model.working)
136 } footer: {
137 Text("Private repositories are visible only to the owner and granted accounts.")
138 }
139 }
140
141 private func branchesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
142 Section {
143 ForEach(loaded.settings.branches, id: \.self) { branch in
144 HStack {
145 Label(branch, systemImage: "lock.shield")
146 .font(.subheadline)
147 Spacer()
148 Button("Unprotect") {
149 Task { await model.unprotectBranch(branch) }
150 }
151 .font(.caption)
152 .disabled(model.working)
153 }
154 }
155 HStack {
156 TextField("Protect branch", text: $newBranch)
157 .autocorrectionDisabled()
158 .textInputAutocapitalization(.never)
159 Button {
160 let branch = newBranch.trimmingCharacters(in: .whitespaces)
161 newBranch = ""
162 Task { await model.protectBranch(branch) }
163 } label: {
164 Image(systemName: "plus.circle.fill")
165 }
166 .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
167 }
168 } header: {
169 Text("Protected branches")
170 } footer: {
171 Text("Protected branches refuse force pushes and deletion.")
172 }
173 }
174
175 private func mergeRulesSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
176 Section {
177 ApprovalsStepper(
178 serverValue: loaded.settings.requireApprovals ?? 0
179 ) { newValue in
180 await model.setRequireApprovals(newValue)
181 }
182 .disabled(model.working)
183 toggle("Require threads resolved", loaded.settings.requireResolved ?? false) {
184 await model.setRequireResolved($0)
185 }
186 toggle("Require green checks", loaded.settings.requireChecks ?? false) {
187 await model.setRequireChecks($0)
188 }
189 toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) {
190 await model.setRequireSigned($0)
191 }
192 } header: {
193 Text("Merge requirements")
194 } footer: {
195 Text("The server enforces these on every merge, whatever the surface.")
196 }
197 }
198
199 private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
200 Section {
201 toggle("git:// daemon", loaded.settings.gitDaemon ?? false) {
202 await model.setGitDaemon($0)
203 }
204 } footer: {
205 Text("Anonymous, unauthenticated read access over the git protocol.")
206 }
207 }
208
209 private func toggle(
210 _ title: String,
211 _ value: Bool,
212 set: @escaping (Bool) async -> Void
213 ) -> some View {
214 SettingToggle(title: title, serverValue: value, write: set)
215 .disabled(model.working)
216 }
217}
218
219/// Same local-state mirror as SettingToggle, for the approvals count.
220private struct ApprovalsStepper: View {
221
222 let serverValue: Int
223 let write: (Int) async -> Void
224
225 @State private var count = 0
226 @State private var seeded = false
227
228 var body: some View {
229 Stepper("Required approvals: \(count)", value: $count, in: 0...10)
230 .onAppear {
231 if !seeded {
232 count = serverValue
233 seeded = true
234 }
235 }
236 .onChange(of: serverValue) { _, newValue in
237 count = newValue
238 }
239 .onChange(of: count) { _, newValue in
240 guard newValue != serverValue else { return }
241 Task { await write(newValue) }
242 }
243 }
244}
245
246/// A Toggle backed by real local state that mirrors the server value.
247/// A computed Binding whose setter spawns a Task proved unreliable under
248/// synthesized taps; a plain @State toggle is not.
249private struct SettingToggle: View {
250
251 let title: String
252 let serverValue: Bool
253 let write: (Bool) async -> Void
254
255 @State private var isOn = false
256 @State private var seeded = false
257
258 var body: some View {
259 Toggle(title, isOn: $isOn)
260 .onAppear {
261 if !seeded {
262 isOn = serverValue
263 seeded = true
264 }
265 }
266 .onChange(of: serverValue) { _, newValue in
267 isOn = newValue
268 }
269 .onChange(of: isOn) { _, newValue in
270 guard newValue != serverValue else { return }
271 Task { await write(newValue) }
272 }
273 }
274}
gitbay/Views/Repos/RepoView.swift +60
@@ -10,9 +10,18 @@ struct RepoView: View {
1010 _model = State(initialValue: RepoDetailViewModel(client: client, path: path))
1111 }
1212
13 @State private var confirmingArchive = false
14
1315 var body: some View {
1416 List {
1517 if let detail = 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 }
1625 header(detail)
1726
1827 Section {
@@ -31,6 +40,9 @@ struct RepoView: View {
3140 NavigationLink(value: BuildRoute.list(repo: path)) {
3241 Label("Builds", systemImage: "hammer")
3342 }
43 NavigationLink(value: RepoRoute.settings(repo: path)) {
44 Label("Settings", systemImage: "gearshape")
45 }
3446 }
3547
3648 if let readme = model.readme {
@@ -44,8 +56,56 @@ struct RepoView: View {
4456 .overlay { LoadStateOverlay(state: model.state) }
4557 .navigationTitle(String(path.split(separator: "/").last ?? ""))
4658 .navigationBarTitleDisplayMode(.inline)
59 .toolbar { toolbar }
4760 .task { await model.load() }
4861 .refreshable { await model.load() }
62 .confirmationDialog(
63 model.state.value?.isArchived == true
64 ? "Unarchive \(path)?"
65 : "Archive \(path)? Pushes and issue/MR writes will be refused.",
66 isPresented: $confirmingArchive
67 ) {
68 Button(
69 model.state.value?.isArchived == true ? "Unarchive" : "Archive",
70 role: model.state.value?.isArchived == true ? nil : .destructive
71 ) {
72 Task { await model.setArchived(!(model.state.value?.isArchived ?? false)) }
73 }
74 Button("Cancel", role: .cancel) {}
75 }
76 }
77
78 @ToolbarContentBuilder
79 private var toolbar: some ToolbarContent {
80 ToolbarItem(placement: .topBarTrailing) {
81 if let detail = model.state.value {
82 Menu {
83 if let pinned = model.isPinned {
84 Button {
85 Task { await model.setPinned(!pinned) }
86 } label: {
87 Label(pinned ? "Unpin" : "Pin",
88 systemImage: pinned ? "pin.slash" : "pin")
89 }
90 }
91 Divider()
92 Button(role: detail.isArchived ? nil : .destructive) {
93 confirmingArchive = true
94 } label: {
95 Label(detail.isArchived ? "Unarchive" : "Archive",
96 systemImage: "archivebox")
97 }
98 } label: {
99 if model.working {
100 ProgressView()
101 } else {
102 Image(systemName: "ellipsis.circle")
103 }
104 }
105 .disabled(model.working)
106 .accessibilityIdentifier("repo-actions-menu")
107 }
108 }
49109 }
50110
51111 @ViewBuilder
gitbayTests/RepoManagementTests.swift added +270
@@ -0,0 +1,270 @@
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 okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
21
22private let settingsShowJSON = """
23 {"protocol_version":1,"data":{"protected_branches":["main"],"require_approvals":1,\
24 "require_resolved":true,"website":"https://gitbay.org"},"exit_code":0}
25 """
26
27private let repoShowJSON = """
28 {"protocol_version":1,"data":{"path":"krz/gitbay","description":"a forge",\
29 "visibility":"public","default_branch":"main","topics":["git"]},"exit_code":0}
30 """
31
32@MainActor
33struct RepoSettingsViewModelTests {
34
35 private func loadedModel() async throws -> (RepoSettingsViewModel, StubProtocol.Box) {
36 let (client, stub) = try makeClient()
37 stub.enqueue(.init(status: 200, json: settingsShowJSON, match: "argv=settings"))
38 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz"))
39 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
40 await model.load()
41 return (model, stub)
42 }
43
44 @Test func loadsSettingsAndDetailTogether() async throws {
45 let (model, _) = try await loadedModel()
46
47 let loaded = try #require(model.state.value)
48 #expect(loaded.settings.branches == ["main"])
49 #expect(loaded.settings.requireApprovals == 1)
50 #expect(loaded.settings.requireResolved == true)
51 // Absent means default, not unknown.
52 #expect(loaded.settings.requireChecks == nil)
53 #expect(loaded.detail.topics == ["git"])
54 }
55
56 @Test func aNonAdminGetsTheRefusalAsTheScreenState() async throws {
57 let (client, stub) = try makeClient()
58 stub.enqueue(.init(status: 403, json:
59 #"{"protocol_version":1,"error":"admin access required","exit_code":4}"#,
60 match: "argv=settings"))
61 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz"))
62 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
63
64 await model.load()
65
66 guard case .failed(let message) = model.state else {
67 Issue.record("expected .failed, got \(model.state)")
68 return
69 }
70 #expect(message == "admin access required")
71 }
72
73 @Test func everyKnobSendsItsOwnCommand() async throws {
74 let (model, stub) = try await loadedModel()
75 for _ in 0..<12 {
76 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
77 stub.enqueue(.init(status: 200, json: settingsShowJSON, match: "argv=settings"))
78 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz"))
79 }
80
81 await model.setDescription("new text")
82 await model.setWebsite("https://x.example")
83 await model.setVisibility("private")
84 await model.setGitDaemon(true)
85 await model.protectBranch("release")
86 await model.unprotectBranch("main")
87 await model.setRequireApprovals(2)
88 await model.setRequireResolved(false)
89 await model.setRequireChecks(true)
90 await model.setRequireSigned(true)
91 await model.addTopic("ios")
92 await model.removeTopic("git")
93
94 let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf)
95 #expect(writes == [
96 ["repo", "settings", "description", "krz/gitbay", "new text"],
97 ["repo", "settings", "website", "krz/gitbay", "https://x.example"],
98 ["repo", "settings", "visibility", "krz/gitbay", "private"],
99 ["repo", "settings", "git-daemon", "krz/gitbay", "on"],
100 ["repo", "settings", "protect", "krz/gitbay", "release"],
101 ["repo", "settings", "unprotect", "krz/gitbay", "main"],
102 ["repo", "settings", "require-approvals", "krz/gitbay", "2"],
103 ["repo", "settings", "require-resolved", "krz/gitbay", "off"],
104 ["repo", "settings", "require-checks", "krz/gitbay", "on"],
105 ["repo", "settings", "require-signed", "krz/gitbay", "on"],
106 ["repo", "topics", "add", "krz/gitbay", "ios"],
107 ["repo", "topics", "remove", "krz/gitbay", "git"],
108 ])
109 }
110
111 @Test func aDeniedKnobSurfacesAndKeepsTheScreen() async throws {
112 let (model, stub) = try await loadedModel()
113 stub.enqueue(.init(status: 403, json:
114 #"{"protocol_version":1,"error":"only admins can change visibility","exit_code":4}"#,
115 match: "cmd"))
116
117 await model.setVisibility("private")
118
119 #expect(model.actionError == "only admins can change visibility")
120 #expect(model.state.value != nil)
121 }
122}
123
124@MainActor
125struct RepoPinArchiveTests {
126
127 private let dashboardPinnedJSON = """
128 {"protocol_version":1,"data":{"pinned":[{"path":"krz/gitbay","visibility":"public"}],\
129 "open_mrs":[],"assigned_issues":[],"builds":[]},"exit_code":0}
130 """
131 private let dashboardEmptyJSON = """
132 {"protocol_version":1,"data":{"pinned":[],"open_mrs":[],"assigned_issues":[],"builds":[]},\
133 "exit_code":0}
134 """
135 private let treeJSON = """
136 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"","entries":[]},\
137 "exit_code":0}
138 """
139
140 @Test func pinStateComesFromTheDashboardAggregate() async throws {
141 let (client, stub) = try makeClient()
142 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
143 stub.enqueue(.init(status: 200, json: dashboardPinnedJSON, match: "argv=dashboard"))
144 stub.enqueue(.init(status: 200, json: treeJSON, match: "argv=tree"))
145 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
146
147 await model.load()
148
149 #expect(model.isPinned == true)
150 }
151
152 @Test func unpinSendsTheCommandAndRefreshesState() async throws {
153 let (client, stub) = try makeClient()
154 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
155 stub.enqueue(.init(status: 200, json: dashboardPinnedJSON, match: "argv=dashboard"))
156 stub.enqueue(.init(status: 200, json: treeJSON, match: "argv=tree"))
157 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
158 await model.load()
159
160 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
161 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
162 stub.enqueue(.init(status: 200, json: dashboardEmptyJSON, match: "argv=dashboard"))
163
164 await model.setPinned(false)
165
166 let write = try #require(stub.seen.first { $0.method == "POST" })
167 #expect(try argvOf(write) == ["repo", "unpin", "krz/gitbay"])
168 #expect(model.isPinned == false)
169 }
170
171 @Test func archiveSendsTheCommand() async throws {
172 let (client, stub) = try makeClient()
173 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
174 stub.enqueue(.init(status: 200, json: dashboardEmptyJSON, match: "argv=dashboard"))
175 stub.enqueue(.init(status: 200, json: treeJSON, match: "argv=tree"))
176 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
177 await model.load()
178
179 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
180 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
181
182 await model.setArchived(true)
183
184 let write = try #require(stub.seen.first { $0.method == "POST" })
185 #expect(try argvOf(write) == ["repo", "archive", "krz/gitbay"])
186 }
187}
188
189@MainActor
190struct RepoCreateViewModelTests {
191
192 @Test func createSendsPathAndPrivateFlag() async throws {
193 let (client, stub) = try makeClient()
194 stub.enqueue(.init(status: 200, json:
195 #"{"protocol_version":1,"data":{"path":"cmc/notes","visibility":"private","ssh_url":"ssh://git@gitbay.org/cmc/notes.git"},"exit_code":0}"#))
196 let model = RepoCreateViewModel(client: client)
197
198 let path = await model.create(path: " cmc/notes ", isPrivate: true)
199
200 #expect(path == "cmc/notes")
201 let write = try #require(stub.seen.first)
202 #expect(try argvOf(write) == ["repo", "create", "cmc/notes", "--private"])
203 }
204
205 @Test func publicCreateOmitsTheFlag() async throws {
206 let (client, stub) = try makeClient()
207 stub.enqueue(.init(status: 200, json:
208 #"{"protocol_version":1,"data":{"path":"cmc/notes","visibility":"public","ssh_url":"x"},"exit_code":0}"#))
209 let model = RepoCreateViewModel(client: client)
210
211 _ = await model.create(path: "cmc/notes", isPrivate: false)
212
213 #expect(try argvOf(try #require(stub.seen.first)) == ["repo", "create", "cmc/notes"])
214 }
215
216 @Test func aRefusalSurfaces() async throws {
217 let (client, stub) = try makeClient()
218 stub.enqueue(.init(status: 403, json:
219 #"{"protocol_version":1,"error":"you cannot create repositories under krz","exit_code":4}"#))
220 let model = RepoCreateViewModel(client: client)
221
222 let path = await model.create(path: "krz/nope", isPrivate: false)
223
224 #expect(path == nil)
225 #expect(model.errorMessage == "you cannot create repositories under krz")
226 }
227}
228
229@MainActor
230struct BuildTriggerTests {
231
232 private let buildListJSON = """
233 {"protocol_version":1,"data":[\
234 {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\
235 "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z"}],"exit_code":0}
236 """
237
238 @Test func triggerSendsTheJobAndReloads() async throws {
239 let (client, stub) = try makeClient()
240 stub.enqueue(.init(status: 200, json: buildListJSON, match: "argv=build&argv=list"))
241 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
242 await model.load()
243 #expect(model.latestJob == "ci")
244
245 stub.enqueue(.init(status: 200, json:
246 #"{"protocol_version":1,"data":{"build":4,"job":"ci","sha":"aa"},"exit_code":0}"#,
247 match: "cmd"))
248 stub.enqueue(.init(status: 200, json: buildListJSON, match: "argv=build&argv=list"))
249
250 await model.trigger(job: "ci")
251
252 let write = try #require(stub.seen.first { $0.method == "POST" })
253 #expect(try argvOf(write) == ["build", "trigger", "krz/gitbay", "ci"])
254 #expect(model.actionError == nil)
255 }
256
257 @Test func anUnknownJobSurfacesAsAnError() async throws {
258 let (client, stub) = try makeClient()
259 stub.enqueue(.init(status: 200, json: buildListJSON, match: "argv=build&argv=list"))
260 let model = BuildListViewModel(client: client, repoPath: "krz/gitbay")
261 await model.load()
262 stub.enqueue(.init(status: 404, json:
263 #"{"protocol_version":1,"error":"no job \"deploy\" in .gitbay/ci.yml","exit_code":3}"#,
264 match: "cmd"))
265
266 await model.trigger(job: "deploy")
267
268 #expect(model.actionError == #"no job "deploy" in .gitbay/ci.yml"#)
269 }
270}
gitbayTests/RepoViewModelTests.swift +14 −6
@@ -20,6 +20,11 @@ private let repoListJSON = """
2020 ]},"exit_code":0}
2121 """
2222
23private let emptyDashboardJSON = """
24 {"protocol_version":1,"data":{"pinned":[],"open_mrs":[],"assigned_issues":[],"builds":[]},\
25 "exit_code":0}
26 """
27
2328 private let repoShowJSON = """
2429 {"protocol_version":1,"data":{"path":"krz/gitbay","description":"a CLI-first git forge",\
2530 "visibility":"public","default_branch":"main","topics":["git","forge"],\
@@ -107,9 +112,10 @@ struct RepoDetailViewModelTests {
107112
108113 @Test func loadsHeaderThenFindsAndFetchesReadme() async throws {
109114 let (client, stub) = try makeClient()
110 stub.enqueue(.init(status: 200, json: repoShowJSON))
111 stub.enqueue(.init(status: 200, json: rootTreeJSON))
112 stub.enqueue(.init(status: 200, json: readmeJSON))
115 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
116 stub.enqueue(.init(status: 200, json: emptyDashboardJSON, match: "argv=dashboard"))
117 stub.enqueue(.init(status: 200, json: rootTreeJSON, match: "argv=tree"))
118 stub.enqueue(.init(status: 200, json: readmeJSON, match: "argv=cat"))
113119 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
114120
115121 await model.load()
@@ -118,6 +124,7 @@ struct RepoDetailViewModelTests {
118124 #expect(detail.defaultBranch == "main")
119125 #expect(detail.topics == ["git", "forge"])
120126 #expect(model.readme == "# gitbay\n\na forge")
127 #expect(model.isPinned == false)
121128 // The README was fetched by name from the tree listing.
122129 let catRequest = try #require(stub.seen.last)
123130 #expect(catRequest.url.query()?.contains("argv=README.md") == true)
@@ -125,19 +132,20 @@ struct RepoDetailViewModelTests {
125132
126133 @Test func aRepoWithoutAReadmeIsFine() async throws {
127134 let (client, stub) = try makeClient()
128 stub.enqueue(.init(status: 200, json: repoShowJSON))
135 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
136 stub.enqueue(.init(status: 200, json: emptyDashboardJSON, match: "argv=dashboard"))
129137 stub.enqueue(.init(status: 200, json: """
130138 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"",\
131139 "entries":[{"name":"main.go","type":"blob","mode":"100644","sha":"ccc3","size":300}]},\
132140 "exit_code":0}
133 """))
141 """, match: "argv=tree"))
134142 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
135143
136144 await model.load()
137145
138146 #expect(model.state.value != nil)
139147 #expect(model.readme == nil)
140 #expect(stub.seen.count == 2) // no cat issued
148 #expect(!stub.seen.contains { ($0.url.query() ?? "").contains("argv=cat") })
141149 }
142150
143151 @Test func aMissingRepoIsAnEmptyState() async throws {
gitbayUITests/LiveSmokeUITests.swift +116
@@ -140,3 +140,119 @@ final class LiveSmokeUITests: XCTestCase {
140140 return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
141141 }
142142 }
143
144extension LiveSmokeUITests {
145
146 /// Repo management, kept reversible: create a scratch repo (deleted
147 /// by the runner afterwards, deletion is CLI-only by design), pin and
148 /// unpin, a topic round-trip, a merge-rule toggle round-trip, and the
149 /// build-trigger error path on a repo with no job config.
150 func testRepoManagementFlows() throws {
151 // --- repo create first: it ends in the list's search state,
152 // which the next step reuses. (Scratch repo; the runner deletes
153 // it over SSH afterwards deletion is CLI-only by design.)
154 let tab = app.buttons["Repositories"].firstMatch
155 XCTAssertTrue(tab.waitForExistence(timeout: 10))
156 tab.tap()
157 app.descendants(matching: .any).matching(identifier: "repo-create-button")
158 .firstMatch.tap()
159 let pathField = app.descendants(matching: .any)
160 .matching(identifier: "repo-create-path").firstMatch
161 XCTAssertTrue(pathField.waitForExistence(timeout: 5))
162 pathField.tap()
163 pathField.typeText("ui-smoke")
164 app.switches.firstMatch.tap() // Private on
165 app.descendants(matching: .any).matching(identifier: "repo-create-submit")
166 .firstMatch.tap()
167 // The sheet dismissing proves the create round-tripped; rows are
168 // lazy, so find the new repo through the filter.
169 XCTAssertTrue(waitForDisappearance(pathField, timeout: 15),
170 "create sheet did not dismiss")
171 let search = app.searchFields.firstMatch
172 XCTAssertTrue(search.waitForExistence(timeout: 10))
173 search.tap()
174 search.typeText("ui-smoke")
175 XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch
176 .waitForExistence(timeout: 15), "created repo not in the list")
177
178 // --- pin / unpin round-trip on krz/gitbay-ios ---
179 // Reuse the open search to get there.
180 let clear = search.buttons.firstMatch
181 if clear.exists { clear.tap() } else { search.tap() }
182 search.typeText("krz/gitbay-ios")
183 let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch
184 XCTAssertTrue(repoRow.waitForExistence(timeout: 15))
185 repoRow.tap()
186 XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10))
187 let menu = app.descendants(matching: .any)
188 .matching(identifier: "repo-actions-menu").firstMatch
189 XCTAssertTrue(menu.waitForExistence(timeout: 10))
190 menu.tap()
191 let pin = app.buttons["Pin"].firstMatch
192 XCTAssertTrue(pin.waitForExistence(timeout: 5), "Pin action missing")
193 pin.tap()
194 // State refreshed from the dashboard: the menu now offers Unpin.
195 menu.tap()
196 let unpin = app.buttons["Unpin"].firstMatch
197 XCTAssertTrue(unpin.waitForExistence(timeout: 10), "pin did not take")
198 unpin.tap()
199
200 // --- settings: topic and merge-rule round-trips ---
201 app.staticTexts["Settings"].firstMatch.tap()
202 let addTopic = app.descendants(matching: .any)
203 .matching(identifier: "settings-add-topic").firstMatch
204 XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load")
205 addTopic.tap()
206 addTopic.typeText("ios")
207 app.descendants(matching: .any).matching(identifier: "settings-add-topic-submit")
208 .firstMatch.tap()
209 let chip = app.staticTexts["ios"].firstMatch
210 XCTAssertTrue(chip.waitForExistence(timeout: 10), "topic did not appear")
211 // Remove it again: the chip's own x button is the next button.
212 app.scrollViews.buttons.firstMatch.tap()
213 XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove")
214
215 let resolved = app.switches["Require threads resolved"].firstMatch
216 XCTAssertTrue(resolved.waitForExistence(timeout: 5))
217 // SwiftUI exposes the row as a switch that wraps the real
218 // control; tap the innermost switch when there is one, else the
219 // right edge of the row.
220 let inner = resolved.switches.firstMatch
221 let control: () -> Void = {
222 if inner.exists && inner != resolved {
223 inner.tap()
224 } else {
225 resolved.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
226 }
227 }
228 control()
229 XCTAssertTrue(waitForValue(resolved, "1", timeout: 10), "toggle did not persist on")
230 control()
231 XCTAssertTrue(waitForValue(resolved, "0", timeout: 10), "toggle did not persist off")
232
233 back() // settings -> repo
234
235 // --- build trigger error path (no .gitbay job config here) ---
236 app.staticTexts["Builds"].firstMatch.tap()
237 app.descendants(matching: .any).matching(identifier: "build-trigger-button")
238 .firstMatch.tap()
239 let jobField = app.textFields.firstMatch
240 XCTAssertTrue(jobField.waitForExistence(timeout: 5), "trigger alert missing")
241 jobField.tap()
242 jobField.typeText("ci")
243 app.buttons["Trigger"].firstMatch.tap()
244 // "no job X" for an unknown job, "has no .gitbay/ci.yml" when the
245 // repo has no CI config at all.
246 let refusal = app.staticTexts
247 .containing(NSPredicate(format: "label CONTAINS 'no job' OR label CONTAINS 'no .gitbay'")).firstMatch
248 XCTAssertTrue(refusal.waitForExistence(timeout: 15),
249 "trigger refusal not surfaced")
250 }
251
252 private func waitForValue(_ element: XCUIElement, _ value: String,
253 timeout: TimeInterval) -> Bool {
254 let predicate = NSPredicate(format: "value == %@", value)
255 let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element)
256 return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
257 }
258}