Commit ba72ebf234
Unsigned
Layout: unified · split
gitbay/Repos/RepoSettings.swift +8
| @@ -4,17 +4,24 @@ import Foundation | |||
| 4 | /// Every field is omitted at its zero value, so absent means default. | 4 | /// Every field is omitted at its zero value, so absent means default. |
| 5 | nonisolated struct RepoSettings: Decodable, Sendable, Hashable { | 5 | nonisolated struct RepoSettings: Decodable, Sendable, Hashable { |
| 6 | let protectedBranches: [String]? | 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 | let requireSignedCommits: Bool? | 10 | let requireSignedCommits: Bool? |
| 8 | let requireChecks: Bool? | 11 | let requireChecks: Bool? |
| 9 | let requireApprovals: Int? | 12 | let requireApprovals: Int? |
| 10 | let requireResolved: Bool? | 13 | let requireResolved: Bool? |
| 11 | let requireCodeowners: Bool? | 14 | let requireCodeowners: Bool? |
| 15 | /// Protected branches take changes through merge requests only (v1.16.0). | ||
| 16 | let requireMR: Bool? | ||
| 12 | let gitDaemon: Bool? | 17 | let gitDaemon: Bool? |
| 13 | let archived: Bool? | 18 | let archived: Bool? |
| 14 | let website: String? | 19 | let website: String? |
| 15 | 20 | ||
| 16 | enum CodingKeys: String, CodingKey { | 21 | enum CodingKeys: String, CodingKey { |
| 17 | case protectedBranches = "protected_branches" | 22 | case protectedBranches = "protected_branches" |
| 23 | case protectedTags = "protected_tags" | ||
| 24 | case requireMR = "require_mr" | ||
| 18 | case requireSignedCommits = "require_signed_commits" | 25 | case requireSignedCommits = "require_signed_commits" |
| 19 | case requireChecks = "require_checks" | 26 | case requireChecks = "require_checks" |
| 20 | case requireApprovals = "require_approvals" | 27 | case requireApprovals = "require_approvals" |
| @@ -25,6 +32,7 @@ nonisolated struct RepoSettings: Decodable, Sendable, Hashable { | |||
| 25 | } | 32 | } |
| 26 | 33 | ||
| 27 | var branches: [String] { protectedBranches ?? [] } | 34 | var branches: [String] { protectedBranches ?? [] } |
| 35 | var tags: [String] { protectedTags ?? [] } | ||
| 28 | } | 36 | } |
| 29 | 37 | ||
| 30 | /// One row of `repo access list`: an account and its effective role. | 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 | private(set) var grants: [AccessGrant]? | 30 | private(set) var grants: [AccessGrant]? |
| 31 | private(set) var grantsError: String? | 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 | private let client: GitbayClient | 36 | private let client: GitbayClient |
| 34 | let repoPath: String | 37 | let repoPath: String |
| 35 | 38 | ||
| @@ -116,6 +119,30 @@ final class RepoSettingsViewModel { | |||
| 116 | await perform(["repo", "settings", "require-codeowners", repoPath, on ? "on" : "off"]) | 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 | func loadGrants() async { | 146 | func loadGrants() async { |
| 120 | grantsError = nil | 147 | grantsError = nil |
| 121 | grants = nil | 148 | grants = nil |
gitbay/Views/Repos/RepoSettingsView.swift +78 −1
| @@ -9,6 +9,7 @@ struct RepoSettingsView: View { | |||
| 9 | @State private var websiteText = "" | 9 | @State private var websiteText = "" |
| 10 | @State private var newTopic = "" | 10 | @State private var newTopic = "" |
| 11 | @State private var newBranch = "" | 11 | @State private var newBranch = "" |
| 12 | @State private var newTagGlob = "" | ||
| 12 | @State private var loadedOnce = false | 13 | @State private var loadedOnce = false |
| 13 | @State private var removingTopic: String? | 14 | @State private var removingTopic: String? |
| 14 | @State private var newGrantUser = "" | 15 | @State private var newGrantUser = "" |
| @@ -30,7 +31,9 @@ struct RepoSettingsView: View { | |||
| 30 | aboutSection(loaded) | 31 | aboutSection(loaded) |
| 31 | topicsSection(loaded) | 32 | topicsSection(loaded) |
| 32 | visibilitySection(loaded) | 33 | visibilitySection(loaded) |
| 34 | defaultBranchSection(loaded) | ||
| 33 | branchesSection(loaded) | 35 | branchesSection(loaded) |
| 36 | tagsSection(loaded) | ||
| 34 | mergeRulesSection(loaded) | 37 | mergeRulesSection(loaded) |
| 35 | daemonSection(loaded) | 38 | daemonSection(loaded) |
| 36 | accessSection | 39 | accessSection |
| @@ -51,10 +54,12 @@ struct RepoSettingsView: View { | |||
| 51 | } | 54 | } |
| 52 | .task { await model.loadDeps() } | 55 | .task { await model.loadDeps() } |
| 53 | .task { await model.loadGrants() } | 56 | .task { await model.loadGrants() } |
| 57 | .task { await model.loadBranches() } | ||
| 54 | .refreshable { | 58 | .refreshable { |
| 55 | await model.load() | 59 | await model.load() |
| 56 | await model.loadDeps() | 60 | await model.loadDeps() |
| 57 | await model.loadGrants() | 61 | await model.loadGrants() |
| 62 | await model.loadBranches() | ||
| 58 | } | 63 | } |
| 59 | .confirmationDialog( | 64 | .confirmationDialog( |
| 60 | "Revoke \(revokingGrant?.user ?? "")'s \(revokingGrant?.role ?? "") access?", | 65 | "Revoke \(revokingGrant?.user ?? "")'s \(revokingGrant?.role ?? "") access?", |
| @@ -209,10 +214,82 @@ struct RepoSettingsView: View { | |||
| 209 | } | 214 | } |
| 210 | .disabled(newBranch.trimmingCharacters(in: .whitespaces).isEmpty || model.working) | 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 | } header: { | 220 | } header: { |
| 213 | Text("Protected branches") | 221 | Text("Protected branches") |
| 214 | } footer: { | 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 | private let settingsShowJSON = """ | 22 | private let settingsShowJSON = """ |
| 23 | {"protocol_version":1,"data":{"protected_branches":["main"],"require_approvals":1,\ | 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 | private let repoShowJSON = """ | 28 | private let repoShowJSON = """ |
| @@ -51,6 +52,8 @@ struct RepoSettingsViewModelTests { | |||
| 51 | // Absent means default, not unknown. | 52 | // Absent means default, not unknown. |
| 52 | #expect(loaded.settings.requireChecks == nil) | 53 | #expect(loaded.settings.requireChecks == nil) |
| 53 | #expect(loaded.settings.requireCodeowners == true) | 54 | #expect(loaded.settings.requireCodeowners == true) |
| 55 | #expect(loaded.settings.requireMR == true) | ||
| 56 | #expect(loaded.settings.tags == ["v*"]) | ||
| 54 | #expect(loaded.detail.topics == ["git"]) | 57 | #expect(loaded.detail.topics == ["git"]) |
| 55 | } | 58 | } |
| 56 | 59 | ||
| @@ -73,7 +76,7 @@ struct RepoSettingsViewModelTests { | |||
| 73 | 76 | ||
| 74 | @Test func everyKnobSendsItsOwnCommand() async throws { | 77 | @Test func everyKnobSendsItsOwnCommand() async throws { |
| 75 | let (model, stub) = try await loadedModel() | 78 | let (model, stub) = try await loadedModel() |
| 76 | for _ in 0..<13 { | 79 | for _ in 0..<17 { |
| 77 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | 80 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) |
| 78 | stub.enqueue(.init(status: 200, json: settingsShowJSON, match: "argv=settings")) | 81 | stub.enqueue(.init(status: 200, json: settingsShowJSON, match: "argv=settings")) |
| 79 | stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz")) | 82 | stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz")) |
| @@ -90,6 +93,10 @@ struct RepoSettingsViewModelTests { | |||
| 90 | await model.setRequireChecks(true) | 93 | await model.setRequireChecks(true) |
| 91 | await model.setRequireSigned(true) | 94 | await model.setRequireSigned(true) |
| 92 | await model.setRequireCodeowners(true) | 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 | await model.addTopic("ios") | 100 | await model.addTopic("ios") |
| 94 | await model.removeTopic("git") | 101 | await model.removeTopic("git") |
| 95 | 102 | ||
| @@ -106,11 +113,28 @@ struct RepoSettingsViewModelTests { | |||
| 106 | ["repo", "settings", "require-checks", "krz/gitbay", "on"], | 113 | ["repo", "settings", "require-checks", "krz/gitbay", "on"], |
| 107 | ["repo", "settings", "require-signed", "krz/gitbay", "on"], | 114 | ["repo", "settings", "require-signed", "krz/gitbay", "on"], |
| 108 | ["repo", "settings", "require-codeowners", "krz/gitbay", "on"], | 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 | ["repo", "topics", "add", "krz/gitbay", "ios"], | 120 | ["repo", "topics", "add", "krz/gitbay", "ios"], |
| 110 | ["repo", "topics", "remove", "krz/gitbay", "git"], | 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 | @Test func accessGrantsLoadSeparately() async throws { | 138 | @Test func accessGrantsLoadSeparately() async throws { |
| 115 | let (model, stub) = try await loadedModel() | 139 | let (model, stub) = try await loadedModel() |
| 116 | stub.enqueue(.init(status: 200, json: """ | 140 | stub.enqueue(.init(status: 200, json: """ |
gitbayUITests/LiveSmokeUITests.swift +32
| @@ -563,6 +563,37 @@ extension LiveSmokeUITests { | |||
| 563 | confirmRemove.tap() | 563 | confirmRemove.tap() |
| 564 | XCTAssertTrue(waitForDisappearance(chip, timeout: 10), "topic did not remove") | 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 | let resolved = app.switches["Require threads resolved"].firstMatch | 597 | let resolved = app.switches["Require threads resolved"].firstMatch |
| 567 | // The merge-requirements section sits below the fold, and a List | 598 | // The merge-requirements section sits below the fold, and a List |
| 568 | // does not build rows it has not shown, so it must be scrolled | 599 | // does not build rows it has not shown, so it must be scrolled |
| @@ -600,6 +631,7 @@ extension LiveSmokeUITests { | |||
| 600 | flipCodeowners() | 631 | flipCodeowners() |
| 601 | XCTAssertTrue(waitForValue(codeowners, "0", timeout: 10), "code owner toggle did not persist off") | 632 | XCTAssertTrue(waitForValue(codeowners, "0", timeout: 10), "code owner toggle did not persist off") |
| 602 | 633 | ||
| 634 | |||
| 603 | back() // settings -> repo | 635 | back() // settings -> repo |
| 604 | 636 | ||
| 605 | // --- nothing to trigger without a job file --- | 637 | // --- nothing to trigger without a job file --- |