Commit ba72ebf234
Unsigned
Layout: unified · split
gitbay/Repos/RepoSettings.swift +8
| @@ -4,17 +4,24 @@ import Foundation | ||
| 4 | 4 | /// Every field is omitted at its zero value, so absent means default. |
| 5 | 5 | nonisolated struct RepoSettings: Decodable, Sendable, Hashable { |
| 6 | 6 | let protectedBranches: [String]? |
| 7 | /// `path.Match` globs; a matching tag is created once and then | |
| 8 | /// refuses moves and deletion (v1.16.0). | |
| 9 | let protectedTags: [String]? | |
| 7 | 10 | let requireSignedCommits: Bool? |
| 8 | 11 | let requireChecks: Bool? |
| 9 | 12 | let requireApprovals: Int? |
| 10 | 13 | let requireResolved: Bool? |
| 11 | 14 | let requireCodeowners: Bool? |
| 15 | /// Protected branches take changes through merge requests only (v1.16.0). | |
| 16 | let requireMR: Bool? | |
| 12 | 17 | let gitDaemon: Bool? |
| 13 | 18 | let archived: Bool? |
| 14 | 19 | let website: String? |
| 15 | 20 | |
| 16 | 21 | enum CodingKeys: String, CodingKey { |
| 17 | 22 | case protectedBranches = "protected_branches" |
| 23 | case protectedTags = "protected_tags" | |
| 24 | case requireMR = "require_mr" | |
| 18 | 25 | case requireSignedCommits = "require_signed_commits" |
| 19 | 26 | case requireChecks = "require_checks" |
| 20 | 27 | case requireApprovals = "require_approvals" |
| @@ -25,6 +32,7 @@ nonisolated struct RepoSettings: Decodable, Sendable, Hashable { | ||
| 25 | 32 | } |
| 26 | 33 | |
| 27 | 34 | var branches: [String] { protectedBranches ?? [] } |
| 35 | var tags: [String] { protectedTags ?? [] } | |
| 28 | 36 | } |
| 29 | 37 | |
| 30 | 38 | /// One row of `repo access list`: an account and its effective role. |
gitbay/Repos/RepoSettingsViewModel.swift +27
| @@ -30,6 +30,9 @@ final class RepoSettingsViewModel { | ||
| 30 | 30 | private(set) var grants: [AccessGrant]? |
| 31 | 31 | private(set) var grantsError: String? |
| 32 | 32 | |
| 33 | /// Branch names from `repo refs`, for the default-branch picker. | |
| 34 | private(set) var branches: [String] = [] | |
| 35 | ||
| 33 | 36 | private let client: GitbayClient |
| 34 | 37 | let repoPath: String |
| 35 | 38 | |
| @@ -116,6 +119,30 @@ final class RepoSettingsViewModel { | ||
| 116 | 119 | await perform(["repo", "settings", "require-codeowners", repoPath, on ? "on" : "off"]) |
| 117 | 120 | } |
| 118 | 121 | |
| 122 | func setRequireMR(_ on: Bool) async { | |
| 123 | await perform(["repo", "settings", "require-mr", repoPath, on ? "on" : "off"]) | |
| 124 | } | |
| 125 | ||
| 126 | func protectTag(_ glob: String) async { | |
| 127 | await perform(["repo", "settings", "protect-tag", repoPath, glob]) | |
| 128 | } | |
| 129 | ||
| 130 | func unprotectTag(_ glob: String) async { | |
| 131 | await perform(["repo", "settings", "unprotect-tag", repoPath, glob]) | |
| 132 | } | |
| 133 | ||
| 134 | /// `repo settings default-branch`: moves HEAD and the record. The | |
| 135 | /// server refuses a branch that does not exist, so the picker only | |
| 136 | /// offers what `repo refs` listed. | |
| 137 | func setDefaultBranch(_ branch: String) async { | |
| 138 | await perform(["repo", "settings", "default-branch", repoPath, branch]) | |
| 139 | } | |
| 140 | ||
| 141 | func loadBranches() async { | |
| 142 | let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self) | |
| 143 | branches = refs?.branches.map(\.name) ?? [] | |
| 144 | } | |
| 145 | ||
| 119 | 146 | func loadGrants() async { |
| 120 | 147 | grantsError = nil |
| 121 | 148 | grants = nil |
gitbay/Views/Repos/RepoSettingsView.swift +78 −1
| @@ -9,6 +9,7 @@ struct RepoSettingsView: View { | ||
| 9 | 9 | @State private var websiteText = "" |
| 10 | 10 | @State private var newTopic = "" |
| 11 | 11 | @State private var newBranch = "" |
| 12 | @State private var newTagGlob = "" | |
| 12 | 13 | @State private var loadedOnce = false |
| 13 | 14 | @State private var removingTopic: String? |
| 14 | 15 | @State private var newGrantUser = "" |
| @@ -30,7 +31,9 @@ struct RepoSettingsView: View { | ||
| 30 | 31 | aboutSection(loaded) |
| 31 | 32 | topicsSection(loaded) |
| 32 | 33 | visibilitySection(loaded) |
| 34 | defaultBranchSection(loaded) | |
| 33 | 35 | branchesSection(loaded) |
| 36 | tagsSection(loaded) | |
| 34 | 37 | mergeRulesSection(loaded) |
| 35 | 38 | daemonSection(loaded) |
| 36 | 39 | accessSection |
| @@ -51,10 +54,12 @@ struct RepoSettingsView: View { | ||
| 51 | 54 | } |
| 52 | 55 | .task { await model.loadDeps() } |
| 53 | 56 | .task { await model.loadGrants() } |
| 57 | .task { await model.loadBranches() } | |
| 54 | 58 | .refreshable { |
| 55 | 59 | await model.load() |
| 56 | 60 | await model.loadDeps() |
| 57 | 61 | await model.loadGrants() |
| 62 | await model.loadBranches() | |
| 58 | 63 | } |
| 59 | 64 | .confirmationDialog( |
| 60 | 65 | "Revoke \(revokingGrant?.user ?? "")'s \(revokingGrant?.role ?? "") access?", |
| @@ -209,10 +214,82 @@ struct RepoSettingsView: View { | ||
| 209 | 214 | } |
| 210 | 215 | .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working) |
| 211 | 216 | } |
| 217 | toggle("Merge requests only", loaded.settings.requireMR ?? false) { | |
| 218 | await model.setRequireMR($0) | |
| 219 | } | |
| 212 | 220 | } header: { |
| 213 | 221 | Text("Protected branches") |
| 214 | 222 | } footer: { |
| 215 | Text("Protected branches refuse force pushes and deletion.") | |
| 223 | Text("Protected branches refuse force pushes and deletion. Merge requests only also refuses direct pushes to them; mr merge becomes their only writer.") | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | /// `repo settings default-branch`: a menu over the branches the | |
| 228 | /// server listed, since it refuses a name that does not exist. | |
| 229 | private func defaultBranchSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { | |
| 230 | Section { | |
| 231 | Menu { | |
| 232 | ForEach(model.branches, id: \.self) { branch in | |
| 233 | Button(branch) { | |
| 234 | if branch != loaded.detail.defaultBranch { | |
| 235 | Task { await model.setDefaultBranch(branch) } | |
| 236 | } | |
| 237 | } | |
| 238 | } | |
| 239 | } label: { | |
| 240 | HStack { | |
| 241 | Label(loaded.detail.defaultBranch, systemImage: "arrow.triangle.branch") | |
| 242 | .font(.gbSans(.subheadline)) | |
| 243 | Spacer() | |
| 244 | Image(systemName: "chevron.up.chevron.down") | |
| 245 | .font(.gbSans(.caption)) | |
| 246 | .foregroundStyle(.secondary) | |
| 247 | } | |
| 248 | } | |
| 249 | .disabled(model.working || model.branches.isEmpty) | |
| 250 | .accessibilityIdentifier("settings-default-branch-menu") | |
| 251 | } header: { | |
| 252 | Text("Default branch") | |
| 253 | } footer: { | |
| 254 | Text("What a clone checks out, and what merge requests target by default.") | |
| 255 | } | |
| 256 | } | |
| 257 | ||
| 258 | /// `repo settings protect-tag` / `unprotect-tag`: globs, path.Match style. | |
| 259 | private func tagsSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { | |
| 260 | Section { | |
| 261 | ForEach(loaded.settings.tags, id: \.self) { glob in | |
| 262 | HStack { | |
| 263 | Label(glob, systemImage: "tag") | |
| 264 | .font(.gbMono(.caption)) | |
| 265 | Spacer() | |
| 266 | Button("Unprotect") { | |
| 267 | Task { await model.unprotectTag(glob) } | |
| 268 | } | |
| 269 | .font(.gbSans(.caption)) | |
| 270 | .disabled(model.working) | |
| 271 | .accessibilityIdentifier("settings-unprotect-tag-\(glob)") | |
| 272 | } | |
| 273 | } | |
| 274 | HStack { | |
| 275 | TextField("Protect tags matching", text: $newTagGlob) | |
| 276 | .autocorrectionDisabled() | |
| 277 | .textInputAutocapitalization(.never) | |
| 278 | .accessibilityIdentifier("settings-protect-tag") | |
| 279 | Button { | |
| 280 | let glob = newTagGlob.trimmingCharacters(in: .whitespaces) | |
| 281 | newTagGlob = "" | |
| 282 | Task { await model.protectTag(glob) } | |
| 283 | } label: { | |
| 284 | Image(systemName: "plus.circle.fill") | |
| 285 | } | |
| 286 | .disabled(newTagGlob.trimmingCharacters(in: .whitespaces).isEmpty || model.working) | |
| 287 | .accessibilityIdentifier("settings-protect-tag-submit") | |
| 288 | } | |
| 289 | } header: { | |
| 290 | Text("Protected tags") | |
| 291 | } footer: { | |
| 292 | Text("A matching tag is created once and then refuses moves and deletion. Globs such as v*.") | |
| 216 | 293 | } |
| 217 | 294 | } |
| 218 | 295 | |
gitbayTests/RepoManagementTests.swift +26 −2
| @@ -21,7 +21,8 @@ private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"# | ||
| 21 | 21 | |
| 22 | 22 | private let settingsShowJSON = """ |
| 23 | 23 | {"protocol_version":1,"data":{"protected_branches":["main"],"require_approvals":1,\ |
| 24 | "require_resolved":true,"require_codeowners":true,"website":"https://gitbay.org"},"exit_code":0} | |
| 24 | "require_resolved":true,"require_codeowners":true,"require_mr":true,\ | |
| 25 | "protected_tags":["v*"],"website":"https://gitbay.org"},"exit_code":0} | |
| 25 | 26 | """ |
| 26 | 27 | |
| 27 | 28 | private let repoShowJSON = """ |
| @@ -51,6 +52,8 @@ struct RepoSettingsViewModelTests { | ||
| 51 | 52 | // Absent means default, not unknown. |
| 52 | 53 | #expect(loaded.settings.requireChecks == nil) |
| 53 | 54 | #expect(loaded.settings.requireCodeowners == true) |
| 55 | #expect(loaded.settings.requireMR == true) | |
| 56 | #expect(loaded.settings.tags == ["v*"]) | |
| 54 | 57 | #expect(loaded.detail.topics == ["git"]) |
| 55 | 58 | } |
| 56 | 59 | |
| @@ -73,7 +76,7 @@ struct RepoSettingsViewModelTests { | ||
| 73 | 76 | |
| 74 | 77 | @Test func everyKnobSendsItsOwnCommand() async throws { |
| 75 | 78 | let (model, stub) = try await loadedModel() |
| 76 | for _ in 0..<13 { | |
| 79 | for _ in 0..<17 { | |
| 77 | 80 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) |
| 78 | 81 | stub.enqueue(.init(status: 200, json: settingsShowJSON, match: "argv=settings")) |
| 79 | 82 | stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz")) |
| @@ -90,6 +93,10 @@ struct RepoSettingsViewModelTests { | ||
| 90 | 93 | await model.setRequireChecks(true) |
| 91 | 94 | await model.setRequireSigned(true) |
| 92 | 95 | await model.setRequireCodeowners(true) |
| 96 | await model.setRequireMR(true) | |
| 97 | await model.protectTag("v*") | |
| 98 | await model.unprotectTag("v*") | |
| 99 | await model.setDefaultBranch("release") | |
| 93 | 100 | await model.addTopic("ios") |
| 94 | 101 | await model.removeTopic("git") |
| 95 | 102 | |
| @@ -106,11 +113,28 @@ struct RepoSettingsViewModelTests { | ||
| 106 | 113 | ["repo", "settings", "require-checks", "krz/gitbay", "on"], |
| 107 | 114 | ["repo", "settings", "require-signed", "krz/gitbay", "on"], |
| 108 | 115 | ["repo", "settings", "require-codeowners", "krz/gitbay", "on"], |
| 116 | ["repo", "settings", "require-mr", "krz/gitbay", "on"], | |
| 117 | ["repo", "settings", "protect-tag", "krz/gitbay", "v*"], | |
| 118 | ["repo", "settings", "unprotect-tag", "krz/gitbay", "v*"], | |
| 119 | ["repo", "settings", "default-branch", "krz/gitbay", "release"], | |
| 109 | 120 | ["repo", "topics", "add", "krz/gitbay", "ios"], |
| 110 | 121 | ["repo", "topics", "remove", "krz/gitbay", "git"], |
| 111 | 122 | ]) |
| 112 | 123 | } |
| 113 | 124 | |
| 125 | @Test func branchesLoadForTheDefaultBranchPicker() async throws { | |
| 126 | let (model, stub) = try await loadedModel() | |
| 127 | stub.enqueue(.init(status: 200, json: """ | |
| 128 | {"protocol_version":1,"data":{"branches":[{"name":"main","sha":"aa"},\ | |
| 129 | {"name":"release","sha":"bb"}],"tags":[{"name":"v1","sha":"cc"}]},"exit_code":0} | |
| 130 | """, match: "argv=refs")) | |
| 131 | ||
| 132 | await model.loadBranches() | |
| 133 | ||
| 134 | #expect(model.branches == ["main", "release"]) | |
| 135 | #expect(stub.seen.last?.url.query() == "argv=repo&argv=refs&argv=krz/gitbay") | |
| 136 | } | |
| 137 | ||
| 114 | 138 | @Test func accessGrantsLoadSeparately() async throws { |
| 115 | 139 | let (model, stub) = try await loadedModel() |
| 116 | 140 | stub.enqueue(.init(status: 200, json: """ |
gitbayUITests/LiveSmokeUITests.swift +32
| @@ -563,6 +563,37 @@ extension LiveSmokeUITests { | ||
| 563 | 563 | confirmRemove.tap() |
| 564 | 564 | XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove") |
| 565 | 565 | |
| 566 | // Merge-requests-only sits with the protected branches. | |
| 567 | let mergeOnly = app.switches["Merge requests only"].firstMatch | |
| 568 | XCTAssertTrue(scrollTo(mergeOnly), "merge-only toggle not reachable") | |
| 569 | let innerMergeOnly = mergeOnly.switches.firstMatch | |
| 570 | let flipMergeOnly: () -> Void = { | |
| 571 | if innerMergeOnly.exists && innerMergeOnly != mergeOnly { | |
| 572 | innerMergeOnly.tap() | |
| 573 | } else { | |
| 574 | mergeOnly.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap() | |
| 575 | } | |
| 576 | } | |
| 577 | flipMergeOnly() | |
| 578 | XCTAssertTrue(waitForValue(mergeOnly, "1", timeout: 10), "merge-only toggle did not persist on") | |
| 579 | flipMergeOnly() | |
| 580 | XCTAssertTrue(waitForValue(mergeOnly, "0", timeout: 10), "merge-only toggle did not persist off") | |
| 581 | ||
| 582 | // A tag glob is protected, listed, and unprotected. | |
| 583 | let tagGlob = app.descendants(matching: .any) | |
| 584 | .matching(identifier: "settings-protect-tag").firstMatch | |
| 585 | XCTAssertTrue(scrollTo(tagGlob), "protected tags section not reachable") | |
| 586 | focusAndType(tagGlob, "ui-smoke-*") | |
| 587 | app.descendants(matching: .any).matching(identifier: "settings-protect-tag-submit") | |
| 588 | .firstMatch.tap() | |
| 589 | let globRow = app.staticTexts["ui-smoke-*"].firstMatch | |
| 590 | XCTAssertTrue(globRow.waitForExistence(timeout: 10), "tag glob not listed") | |
| 591 | // By identifier: the protected branch above has an Unprotect too. | |
| 592 | app.descendants(matching: .any).matching(identifier: "settings-unprotect-tag-ui-smoke-*") | |
| 593 | .firstMatch.tap() | |
| 594 | XCTAssertTrue(waitForDisappearance(globRow, timeout: 10), "tag glob not unprotected") | |
| 595 | ||
| 596 | // Merge rules sit below the protected branches and tags. | |
| 566 | 597 | let resolved = app.switches["Require threads resolved"].firstMatch |
| 567 | 598 | // The merge-requirements section sits below the fold, and a List |
| 568 | 599 | // does not build rows it has not shown, so it must be scrolled |
| @@ -600,6 +631,7 @@ extension LiveSmokeUITests { | ||
| 600 | 631 | flipCodeowners() |
| 601 | 632 | XCTAssertTrue(waitForValue(codeowners, "0", timeout: 10), "code owner toggle did not persist off") |
| 602 | 633 | |
| 634 | ||
| 603 | 635 | back() // settings -> repo |
| 604 | 636 | |
| 605 | 637 | // --- nothing to trigger without a job file --- |