Commit b55fbb39ba
Verified · cmc
gitbay/Builds/BuildListViewModel.swift +22
| @@ -7,6 +7,8 @@ import Observation | ||
| 7 | 7 | final class BuildListViewModel { |
| 8 | 8 | |
| 9 | 9 | private(set) var state: LoadState<[Build]> = .loading |
| 10 | private(set) var actionError: String? | |
| 11 | private(set) var working = false | |
| 10 | 12 | |
| 11 | 13 | private let client: GitbayClient |
| 12 | 14 | let repoPath: String |
| @@ -26,4 +28,24 @@ final class BuildListViewModel { | ||
| 26 | 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 | 62 | FileView(client: client, repo: repo, path: path, ref: ref) |
| 63 | 63 | case .log(let repo): |
| 64 | 64 | LogView(client: client, repo: repo) |
| 65 | case .settings(let repo): | |
| 66 | RepoSettingsView(client: client, repo: repo) | |
| 65 | 67 | case .addAccount: |
| 66 | 68 | SignInView() |
| 67 | 69 | } |
gitbay/Repos/RepoCreateViewModel.swift added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `repo create <owner/name> [--private]`. | |
| 5 | @Observable | |
| 6 | @MainActor | |
| 7 | final 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 | 10 | private(set) var state: LoadState<RepoDetail> = .loading |
| 11 | 11 | /// README markdown, when the root tree has one. Absent is normal. |
| 12 | 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 | 19 | private let client: GitbayClient |
| 15 | 20 | private let path: String |
| @@ -27,9 +32,44 @@ final class RepoDetailViewModel { | ||
| 27 | 32 | state = .from(error) |
| 28 | 33 | return |
| 29 | 34 | } |
| 35 | await loadPinned() | |
| 30 | 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 | 73 | private func loadReadme() async { |
| 34 | 74 | // The README is whatever the root tree calls one, matched the way |
| 35 | 75 | // the web UI matches: README, README.md, readme.org, and so on. |
gitbay/Repos/RepoSettings.swift added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | import 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. | |
| 5 | nonisolated 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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 | |
| 9 | final 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 | ||
| 3 | 3 | struct BuildListView: View { |
| 4 | 4 | |
| 5 | 5 | @State private var model: BuildListViewModel |
| 6 | @State private var triggering = false | |
| 7 | @State private var jobName = "" | |
| 6 | 8 | |
| 7 | 9 | init(client: GitbayClient, repo: String) { |
| 8 | 10 | _model = State(initialValue: BuildListViewModel(client: client, repoPath: repo)) |
| @@ -10,6 +12,13 @@ struct BuildListView: View { | ||
| 10 | 12 | |
| 11 | 13 | var body: some View { |
| 12 | 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 | 22 | ForEach(model.state.value ?? []) { build in |
| 14 | 23 | NavigationLink(value: BuildRoute.log(repo: model.repoPath, number: build.number)) { |
| 15 | 24 | BuildRow(build: build) |
| @@ -19,6 +28,29 @@ struct BuildListView: View { | ||
| 19 | 28 | .overlay { LoadStateOverlay(state: model.state) } |
| 20 | 29 | .navigationTitle("Builds") |
| 21 | 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 | 54 | .task { await model.load() } |
| 23 | 55 | .refreshable { await model.load() } |
| 24 | 56 | } |
gitbay/Views/Repos/RepoListView.swift +93 −1
| @@ -4,9 +4,12 @@ struct RepoListView: View { | ||
| 4 | 4 | |
| 5 | 5 | @Environment(SessionStore.self) private var session |
| 6 | 6 | @State private var model: RepoListViewModel |
| 7 | @State private var createModel: RepoCreateViewModel | |
| 8 | @State private var composing = false | |
| 7 | 9 | |
| 8 | 10 | init(client: GitbayClient) { |
| 9 | 11 | _model = State(initialValue: RepoListViewModel(client: client)) |
| 12 | _createModel = State(initialValue: RepoCreateViewModel(client: client)) | |
| 10 | 13 | } |
| 11 | 14 | |
| 12 | 15 | var body: some View { |
| @@ -23,12 +26,101 @@ struct RepoListView: View { | ||
| 23 | 26 | .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) } |
| 24 | 27 | .searchable(text: Bindable(model).searchText, prompt: "Filter repositories") |
| 25 | 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 | 49 | .task { await model.load() } |
| 28 | 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. | |
| 56 | private 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 | ||
| 32 | 124 | private struct RepoRow: View { |
| 33 | 125 | let repo: RepoSummary |
| 34 | 126 | |
gitbay/Views/Repos/RepoRoute.swift +1
| @@ -7,5 +7,6 @@ nonisolated enum RepoRoute: Hashable { | ||
| 7 | 7 | case tree(repo: String, directory: String, ref: String?) |
| 8 | 8 | case file(repo: String, path: String, ref: String?) |
| 9 | 9 | case log(repo: String) |
| 10 | case settings(repo: String) | |
| 10 | 11 | case addAccount |
| 11 | 12 | } |
gitbay/Views/Repos/RepoSettingsView.swift added +274
| @@ -0,0 +1,274 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// Admin knobs, one command per control. A non-admin sees the server's | |
| 4 | /// refusal instead of a half-working form. | |
| 5 | struct 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. | |
| 220 | private 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. | |
| 249 | private 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 | 10 | _model = State(initialValue: RepoDetailViewModel(client: client, path: path)) |
| 11 | 11 | } |
| 12 | 12 | |
| 13 | @State private var confirmingArchive = false | |
| 14 | ||
| 13 | 15 | var body: some View { |
| 14 | 16 | List { |
| 15 | 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 | 25 | header(detail) |
| 17 | 26 | |
| 18 | 27 | Section { |
| @@ -31,6 +40,9 @@ struct RepoView: View { | ||
| 31 | 40 | NavigationLink(value: BuildRoute.list(repo: path)) { |
| 32 | 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 | 48 | if let readme = model.readme { |
| @@ -44,8 +56,56 @@ struct RepoView: View { | ||
| 44 | 56 | .overlay { LoadStateOverlay(state: model.state) } |
| 45 | 57 | .navigationTitle(String(path.split(separator: "/").last ?? "")) |
| 46 | 58 | .navigationBarTitleDisplayMode(.inline) |
| 59 | .toolbar { toolbar } | |
| 47 | 60 | .task { await model.load() } |
| 48 | 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 | 111 | @ViewBuilder |
gitbayTests/RepoManagementTests.swift added +270
| @@ -0,0 +1,270 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 6 | let box = StubProtocol.box() | |
| 7 | let client = GitbayClient( | |
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 9 | token: "test-token", | |
| 10 | session: box.session() | |
| 11 | ) | |
| 12 | return (client, box) | |
| 13 | } | |
| 14 | ||
| 15 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | |
| 16 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | |
| 17 | return try #require(body["argv"] as? [String]) | |
| 18 | } | |
| 19 | ||
| 20 | private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# | |
| 21 | ||
| 22 | private 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 | ||
| 27 | private 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 | |
| 33 | struct 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 | |
| 125 | struct 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 | |
| 190 | struct 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 | |
| 230 | struct 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 | 20 | ]},"exit_code":0} |
| 21 | 21 | """ |
| 22 | 22 | |
| 23 | private let emptyDashboardJSON = """ | |
| 24 | {"protocol_version":1,"data":{"pinned":[],"open_mrs":[],"assigned_issues":[],"builds":[]},\ | |
| 25 | "exit_code":0} | |
| 26 | """ | |
| 27 | ||
| 23 | 28 | private let repoShowJSON = """ |
| 24 | 29 | {"protocol_version":1,"data":{"path":"krz/gitbay","description":"a CLI-first git forge",\ |
| 25 | 30 | "visibility":"public","default_branch":"main","topics":["git","forge"],\ |
| @@ -107,9 +112,10 @@ struct RepoDetailViewModelTests { | ||
| 107 | 112 | |
| 108 | 113 | @Test func loadsHeaderThenFindsAndFetchesReadme() async throws { |
| 109 | 114 | 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")) | |
| 113 | 119 | let model = RepoDetailViewModel(client: client, path: "krz/gitbay") |
| 114 | 120 | |
| 115 | 121 | await model.load() |
| @@ -118,6 +124,7 @@ struct RepoDetailViewModelTests { | ||
| 118 | 124 | #expect(detail.defaultBranch == "main") |
| 119 | 125 | #expect(detail.topics == ["git", "forge"]) |
| 120 | 126 | #expect(model.readme == "# gitbay\n\na forge") |
| 127 | #expect(model.isPinned == false) | |
| 121 | 128 | // The README was fetched by name from the tree listing. |
| 122 | 129 | let catRequest = try #require(stub.seen.last) |
| 123 | 130 | #expect(catRequest.url.query()?.contains("argv=README.md") == true) |
| @@ -125,19 +132,20 @@ struct RepoDetailViewModelTests { | ||
| 125 | 132 | |
| 126 | 133 | @Test func aRepoWithoutAReadmeIsFine() async throws { |
| 127 | 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 | 137 | stub.enqueue(.init(status: 200, json: """ |
| 130 | 138 | {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"",\ |
| 131 | 139 | "entries":[{"name":"main.go","type":"blob","mode":"100644","sha":"ccc3","size":300}]},\ |
| 132 | 140 | "exit_code":0} |
| 133 | """)) | |
| 141 | """, match: "argv=tree")) | |
| 134 | 142 | let model = RepoDetailViewModel(client: client, path: "krz/gitbay") |
| 135 | 143 | |
| 136 | 144 | await model.load() |
| 137 | 145 | |
| 138 | 146 | #expect(model.state.value != nil) |
| 139 | 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 | 151 | @Test func aMissingRepoIsAnEmptyState() async throws { |
gitbayUITests/LiveSmokeUITests.swift +116
| @@ -140,3 +140,119 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 140 | 140 | return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed |
| 141 | 141 | } |
| 142 | 142 | } |
| 143 | ||
| 144 | extension 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 | } | |