Commit 21369dd604

21369dd6042af3ee0da3bd6d7e2b5201688acc0b

parent: fffc51c75c

Unsigned

cmc <hello@cleberg.net> · 2026-08-27 15:23 UTC

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

Layout: unified · split

gitbay/Builds/BuildListViewModel.swift +22
@@ -7,6 +7,8 @@ import Observation
7final class BuildListViewModel { 7final class BuildListViewModel {
8 8
9 private(set) var state: LoadState<[Build]> = .loading 9 private(set) var state: LoadState<[Build]> = .loading
10 private(set) var actionError: String?
11 private(set) var working = false
10 12
11 private let client: GitbayClient 13 private let client: GitbayClient
12 let repoPath: String 14 let repoPath: String
@@ -26,4 +28,24 @@ final class BuildListViewModel {
26 state = .from(error) 28 state = .from(error)
27 } 29 }
28 } 30 }
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 }
29} 51}
gitbay/ContentView.swift +2
@@ -62,6 +62,8 @@ private struct RouteDestinations: ViewModifier {
62 FileView(client: client, repo: repo, path: path, ref: ref) 62 FileView(client: client, repo: repo, path: path, ref: ref)
63 case .log(let repo): 63 case .log(let repo):
64 LogView(client: client, repo: repo) 64 LogView(client: client, repo: repo)
65 case .settings(let repo):
66 RepoSettingsView(client: client, repo: repo)
65 case .addAccount: 67 case .addAccount:
66 SignInView() 68 SignInView()
67 } 69 }
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 {
10 private(set) var state: LoadState<RepoDetail> = .loading 10 private(set) var state: LoadState<RepoDetail> = .loading
11 /// README markdown, when the root tree has one. Absent is normal. 11 /// README markdown, when the root tree has one. Absent is normal.
12 private(set) var readme: String? 12 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
13 18
14 private let client: GitbayClient 19 private let client: GitbayClient
15 private let path: String 20 private let path: String
@@ -27,9 +32,44 @@ final class RepoDetailViewModel {
27 state = .from(error) 32 state = .from(error)
28 return 33 return
29 } 34 }
35 await loadPinned()
30 await loadReadme() 36 await loadReadme()
31 } 37 }
32 38
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
33 private func loadReadme() async { 73 private func loadReadme() async {
34 // The README is whatever the root tree calls one, matched the way 74 // The README is whatever the root tree calls one, matched the way
35 // the web UI matches: README, README.md, readme.org, and so on. 75 // 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
3struct BuildListView: View { 3struct BuildListView: View {
4 4
5 @State private var model: BuildListViewModel 5 @State private var model: BuildListViewModel
6 @State private var triggering = false
7 @State private var jobName = ""
6 8
7 init(client: GitbayClient, repo: String) { 9 init(client: GitbayClient, repo: String) {
8 _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo)) 10 _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo))
@@ -10,6 +12,13 @@ struct BuildListView: View {
10 12
11 var body: some View { 13 var body: some View {
12 List { 14 List {
15 if let error = model.actionError {
16 Section {
17 Label(error, systemImage: "hand.raised")
18 .foregroundStyle(.orange)
19 .font(.subheadline)
20 }
21 }
13 ForEach(model.state.value ?? []) { build in 22 ForEach(model.state.value ?? []) { build in
14 NavigationLink(value: BuildRoute.log(repo: model.repoPath, number: build.number)) { 23 NavigationLink(value: BuildRoute.log(repo: model.repoPath, number: build.number)) {
15 BuildRow(build: build) 24 BuildRow(build: build)
@@ -19,6 +28,29 @@ struct BuildListView: View {
19 .overlay { LoadStateOverlay(state: model.state) } 28 .overlay { LoadStateOverlay(state: model.state) }
20 .navigationTitle("Builds") 29 .navigationTitle("Builds")
21 .navigationBarTitleDisplayMode(.inline) 30 .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 }
22 .task { await model.load() } 54 .task { await model.load() }
23 .refreshable { await model.load() } 55 .refreshable { await model.load() }
24 } 56 }
gitbay/Views/Repos/RepoListView.swift +93 −1
@@ -4,9 +4,12 @@ struct RepoListView: View {
4 4
5 @Environment(SessionStore.self) private var session 5 @Environment(SessionStore.self) private var session
6 @State private var model: RepoListViewModel 6 @State private var model: RepoListViewModel
7 @State private var createModel: RepoCreateViewModel
8 @State private var composing = false
7 9
8 init(client: GitbayClient) { 10 init(client: GitbayClient) {
9 _model = State(initialValue: RepoListViewModel(client: client)) 11 _model = State(initialValue: RepoListViewModel(client: client))
12 _createModel = State(initialValue: RepoCreateViewModel(client: client))
10 } 13 }
11 14
12 var body: some View { 15 var body: some View {
@@ -23,12 +26,101 @@ struct RepoListView: View {
23 .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) } 26 .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) }
24 .searchable(text: Bindable(model).searchText, prompt: "Filter repositories") 27 .searchable(text: Bindable(model).searchText, prompt: "Filter repositories")
25 .navigationTitle("Repositories") 28 .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 }
27 .task { await model.load() } 49 .task { await model.load() }
28 .refreshable { await model.load() } 50 .refreshable { await model.load() }
29 } 51 }
30} 52}
31 53
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
32private struct RepoRow: View { 124private struct RepoRow: View {
33 let repo: RepoSummary 125 let repo: RepoSummary
34 126
gitbay/Views/Repos/RepoRoute.swift +1
@@ -7,5 +7,6 @@ nonisolated enum RepoRoute: Hashable {
7 case tree(repo: String, directory: String, ref: String?) 7 case tree(repo: String, directory: String, ref: String?)
8 case file(repo: String, path: String, ref: String?) 8 case file(repo: String, path: String, ref: String?)
9 case log(repo: String) 9 case log(repo: String)
10 case settings(repo: String)
10 case addAccount 11 case addAccount
11} 12}
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 {
10 _model = State(initialValue: RepoDetailViewModel(client: client, path: path)) 10 _model = State(initialValue: RepoDetailViewModel(client: client, path: path))
11 } 11 }
12 12
13 @State private var confirmingArchive = false
14
13 var body: some View { 15 var body: some View {
14 List { 16 List {
15 if let detail = model.state.value { 17 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 }
16 header(detail) 25 header(detail)
17 26
18 Section { 27 Section {
@@ -31,6 +40,9 @@ struct RepoView: View {
31 NavigationLink(value: BuildRoute.list(repo: path)) { 40 NavigationLink(value: BuildRoute.list(repo: path)) {
32 Label("Builds", systemImage: "hammer") 41 Label("Builds", systemImage: "hammer")
33 } 42 }
43 NavigationLink(value: RepoRoute.settings(repo: path)) {
44 Label("Settings", systemImage: "gearshape")
45 }
34 } 46 }
35 47
36 if let readme = model.readme { 48 if let readme = model.readme {
@@ -44,8 +56,56 @@ struct RepoView: View {
44 .overlay { LoadStateOverlay(state: model.state) } 56 .overlay { LoadStateOverlay(state: model.state) }
45 .navigationTitle(String(path.split(separator: "/").last ?? "")) 57 .navigationTitle(String(path.split(separator: "/").last ?? ""))
46 .navigationBarTitleDisplayMode(.inline) 58 .navigationBarTitleDisplayMode(.inline)
59 .toolbar { toolbar }
47 .task { await model.load() } 60 .task { await model.load() }
48 .refreshable { await model.load() } 61 .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 }
49 } 109 }
50 110
51 @ViewBuilder 111 @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 = """
20 ]},"exit_code":0} 20 ]},"exit_code":0}
21 """ 21 """
22 22
23private let emptyDashboardJSON = """
24 {"protocol_version":1,"data":{"pinned":[],"open_mrs":[],"assigned_issues":[],"builds":[]},\
25 "exit_code":0}
26 """
27
23private let repoShowJSON = """ 28private let repoShowJSON = """
24 {"protocol_version":1,"data":{"path":"krz/gitbay","description":"a CLI-first git forge",\ 29 {"protocol_version":1,"data":{"path":"krz/gitbay","description":"a CLI-first git forge",\
25 "visibility":"public","default_branch":"main","topics":["git","forge"],\ 30 "visibility":"public","default_branch":"main","topics":["git","forge"],\
@@ -107,9 +112,10 @@ struct RepoDetailViewModelTests {
107 112
108 @Test func loadsHeaderThenFindsAndFetchesReadme() async throws { 113 @Test func loadsHeaderThenFindsAndFetchesReadme() async throws {
109 let (client, stub) = try makeClient() 114 let (client, stub) = try makeClient()
110 stub.enqueue(.init(status: 200, json: repoShowJSON)) 115 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show"))
111 stub.enqueue(.init(status: 200, json: rootTreeJSON)) 116 stub.enqueue(.init(status: 200, json: emptyDashboardJSON, match: "argv=dashboard"))
112 stub.enqueue(.init(status: 200, json: readmeJSON)) 117 stub.enqueue(.init(status: 200, json: rootTreeJSON, match: "argv=tree"))
118 stub.enqueue(.init(status: 200, json: readmeJSON, match: "argv=cat"))
113 let model = RepoDetailViewModel(client: client, path: "krz/gitbay") 119 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
114 120
115 await model.load() 121 await model.load()
@@ -118,6 +124,7 @@ struct RepoDetailViewModelTests {
118 #expect(detail.defaultBranch == "main") 124 #expect(detail.defaultBranch == "main")
119 #expect(detail.topics == ["git", "forge"]) 125 #expect(detail.topics == ["git", "forge"])
120 #expect(model.readme == "# gitbay\n\na forge") 126 #expect(model.readme == "# gitbay\n\na forge")
127 #expect(model.isPinned == false)
121 // The README was fetched by name from the tree listing. 128 // The README was fetched by name from the tree listing.
122 let catRequest = try #require(stub.seen.last) 129 let catRequest = try #require(stub.seen.last)
123 #expect(catRequest.url.query()?.contains("argv=README.md") == true) 130 #expect(catRequest.url.query()?.contains("argv=README.md") == true)
@@ -125,19 +132,20 @@ struct RepoDetailViewModelTests {
125 132
126 @Test func aRepoWithoutAReadmeIsFine() async throws { 133 @Test func aRepoWithoutAReadmeIsFine() async throws {
127 let (client, stub) = try makeClient() 134 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"))
129 stub.enqueue(.init(status: 200, json: """ 137 stub.enqueue(.init(status: 200, json: """
130 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"",\ 138 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"",\
131 "entries":[{"name":"main.go","type":"blob","mode":"100644","sha":"ccc3","size":300}]},\ 139 "entries":[{"name":"main.go","type":"blob","mode":"100644","sha":"ccc3","size":300}]},\
132 "exit_code":0} 140 "exit_code":0}
133 """)) 141 """, match: "argv=tree"))
134 let model = RepoDetailViewModel(client: client, path: "krz/gitbay") 142 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
135 143
136 await model.load() 144 await model.load()
137 145
138 #expect(model.state.value != nil) 146 #expect(model.state.value != nil)
139 #expect(model.readme == nil) 147 #expect(model.readme == nil)
140 #expect(stub.seen.count == 2) // no cat issued 148 #expect(!stub.seen.contains { ($0.url.query() ?? "").contains("argv=cat") })
141 } 149 }
142 150
143 @Test func aMissingRepoIsAnEmptyState() async throws { 151 @Test func aMissingRepoIsAnEmptyState() async throws {
gitbayUITests/LiveSmokeUITests.swift +116
@@ -140,3 +140,119 @@ final class LiveSmokeUITests: XCTestCase {
140 return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed 140 return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed
141 } 141 }
142} 142}
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}