Required status contexts in repository settings !158
4 files changed, +161 −1
Layout: unified · split
gitbay/Repos/RepoSettings.swift +5
| @@ -9,6 +9,9 @@ nonisolated struct RepoSettings: Decodable, Sendable, Hashable { | |||
| 9 | let protectedTags: [String]? | 9 | let protectedTags: [String]? |
| 10 | let requireSignedCommits: Bool? | 10 | let requireSignedCommits: Bool? |
| 11 | let requireChecks: Bool? | 11 | let requireChecks: Bool? |
| 12 | /// The checks the checks gate waits on by name; with none, every | ||
| 13 | /// check reported on the head must pass (v1.37.0). | ||
| 14 | let requiredContexts: [String]? | ||
| 12 | let requireApprovals: Int? | 15 | let requireApprovals: Int? |
| 13 | let requireResolved: Bool? | 16 | let requireResolved: Bool? |
| 14 | let requireCodeowners: Bool? | 17 | let requireCodeowners: Bool? |
| @@ -24,6 +27,7 @@ nonisolated struct RepoSettings: Decodable, Sendable, Hashable { | |||
| 24 | case requireMR = "require_mr" | 27 | case requireMR = "require_mr" |
| 25 | case requireSignedCommits = "require_signed_commits" | 28 | case requireSignedCommits = "require_signed_commits" |
| 26 | case requireChecks = "require_checks" | 29 | case requireChecks = "require_checks" |
| 30 | case requiredContexts = "required_contexts" | ||
| 27 | case requireApprovals = "require_approvals" | 31 | case requireApprovals = "require_approvals" |
| 28 | case requireResolved = "require_resolved" | 32 | case requireResolved = "require_resolved" |
| 29 | case requireCodeowners = "require_codeowners" | 33 | case requireCodeowners = "require_codeowners" |
| @@ -33,6 +37,7 @@ nonisolated struct RepoSettings: Decodable, Sendable, Hashable { | |||
| 33 | 37 | ||
| 34 | var branches: [String] { protectedBranches ?? [] } | 38 | var branches: [String] { protectedBranches ?? [] } |
| 35 | var tags: [String] { protectedTags ?? [] } | 39 | var tags: [String] { protectedTags ?? [] } |
| 40 | var contexts: [String] { requiredContexts ?? [] } | ||
| 36 | } | 41 | } |
| 37 | 42 | ||
| 38 | /// One row of `repo access list`: an account and its effective role. | 43 | /// One row of `repo access list`: an account and its effective role. |
gitbay/Repos/RepoSettingsViewModel.swift +23
| @@ -111,6 +111,29 @@ final class RepoSettingsViewModel { | |||
| 111 | await perform(["repo", "settings", "require-checks", repoPath, on ? "on" : "off"]) | 111 | await perform(["repo", "settings", "require-checks", repoPath, on ? "on" : "off"]) |
| 112 | } | 112 | } |
| 113 | 113 | ||
| 114 | /// `repo settings require-contexts` replaces the list, so the whole | ||
| 115 | /// list goes each time. Naming one turns the checks gate on. | ||
| 116 | /// Returns whether the field can be cleared: the server took it, or | ||
| 117 | /// it was already listed. A refused name stays in the field to fix. | ||
| 118 | @discardableResult | ||
| 119 | func addRequiredContext(_ context: String) async -> Bool { | ||
| 120 | let context = context.trimmingCharacters(in: .whitespaces) | ||
| 121 | let current = state.value?.settings.contexts ?? [] | ||
| 122 | guard !context.isEmpty, !working else { return false } | ||
| 123 | guard !current.contains(context) else { return true } | ||
| 124 | await setRequiredContexts(current + [context]) | ||
| 125 | return actionError == nil | ||
| 126 | } | ||
| 127 | |||
| 128 | /// Removing the last one leaves the gate as it was, waiting on every check. | ||
| 129 | func removeRequiredContext(_ context: String) async { | ||
| 130 | await setRequiredContexts((state.value?.settings.contexts ?? []).filter { $0 != context }) | ||
| 131 | } | ||
| 132 | |||
| 133 | private func setRequiredContexts(_ contexts: [String]) async { | ||
| 134 | await perform(["repo", "settings", "require-contexts", repoPath] + contexts) | ||
| 135 | } | ||
| 136 | |||
| 114 | func setRequireSigned(_ on: Bool) async { | 137 | func setRequireSigned(_ on: Bool) async { |
| 115 | await perform(["repo", "settings", "require-signed", repoPath, on ? "on" : "off"]) | 138 | await perform(["repo", "settings", "require-signed", repoPath, on ? "on" : "off"]) |
| 116 | } | 139 | } |
gitbay/Views/Repos/RepoSettingsView.swift +39 −1
| @@ -10,6 +10,7 @@ struct RepoSettingsView: View { | |||
| 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 newTagGlob = "" |
| 13 | @State private var newContext = "" | ||
| 13 | @State private var renaming = false | 14 | @State private var renaming = false |
| 14 | @State private var newName = "" | 15 | @State private var newName = "" |
| 15 | @State private var renameConfirm = "" | 16 | @State private var renameConfirm = "" |
| @@ -351,6 +352,29 @@ struct RepoSettingsView: View { | |||
| 351 | toggle("Require green checks", loaded.settings.requireChecks ?? false) { | 352 | toggle("Require green checks", loaded.settings.requireChecks ?? false) { |
| 352 | await model.setRequireChecks($0) | 353 | await model.setRequireChecks($0) |
| 353 | } | 354 | } |
| 355 | VStack(alignment: .leading, spacing: 6) { | ||
| 356 | Text(contextsHeading(loaded.settings)) | ||
| 357 | .font(.gbSans(.subheadline)) | ||
| 358 | RemovableChips(items: loaded.settings.contexts, idPrefix: "settings-context", | ||
| 359 | disabled: model.working, | ||
| 360 | remove: { context in Task { await model.removeRequiredContext(context) } }) | ||
| 361 | HStack { | ||
| 362 | TextField("ci/test", text: $newContext) | ||
| 363 | .font(.gbMono(.subheadline)) | ||
| 364 | .autocorrectionDisabled() | ||
| 365 | .textInputAutocapitalization(.never) | ||
| 366 | .onSubmit { if !model.working { addContext() } } | ||
| 367 | .accessibilityIdentifier("settings-context-field") | ||
| 368 | Button { | ||
| 369 | addContext() | ||
| 370 | } label: { | ||
| 371 | Image(systemName: "plus.circle.fill") | ||
| 372 | } | ||
| 373 | .accessibilityLabel("Require check") | ||
| 374 | .disabled(newContext.trimmingCharacters(in: .whitespaces).isEmpty || model.working) | ||
| 375 | .accessibilityIdentifier("settings-context-add") | ||
| 376 | } | ||
| 377 | } | ||
| 354 | toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) { | 378 | toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) { |
| 355 | await model.setRequireSigned($0) | 379 | await model.setRequireSigned($0) |
| 356 | } | 380 | } |
| @@ -360,8 +384,22 @@ struct RepoSettingsView: View { | |||
| 360 | } header: { | 384 | } header: { |
| 361 | Text("Merge requirements") | 385 | Text("Merge requirements") |
| 362 | } footer: { | 386 | } footer: { |
| 363 | Text("The server enforces these on every merge, whatever the surface.") | 387 | Text("The server enforces these on every merge, whatever the surface. Naming a check turns the checks gate on.") |
| 388 | } | ||
| 389 | } | ||
| 390 | |||
| 391 | private func addContext() { | ||
| 392 | let context = newContext | ||
| 393 | Task { | ||
| 394 | if await model.addRequiredContext(context), newContext == context { newContext = "" } | ||
| 395 | } | ||
| 396 | } | ||
| 397 | |||
| 398 | private func contextsHeading(_ settings: RepoSettings) -> String { | ||
| 399 | if !(settings.requireChecks ?? false) { | ||
| 400 | return settings.contexts.isEmpty ? "Name a check to require it" : "Named checks, not required while the gate is off" | ||
| 364 | } | 401 | } |
| 402 | return settings.contexts.isEmpty ? "Every check on the head must pass" : "These checks must pass" | ||
| 365 | } | 403 | } |
| 366 | 404 | ||
| 367 | private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { | 405 | private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { |
gitbayTests/RequiredContextsTests.swift added +94
| @@ -0,0 +1,94 @@ | |||
| 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 postedArgv(_ stub: StubProtocol.Box) throws -> [[String]] { | ||
| 16 | try stub.seen.filter { $0.method == "POST" }.map { seen in | ||
| 17 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 18 | return try #require(body["argv"] as? [String]) | ||
| 19 | } | ||
| 20 | } | ||
| 21 | |||
| 22 | private let settingsJSON = """ | ||
| 23 | {"protocol_version":1,"data":{"protected_branches":["main"],"require_checks":true,\ | ||
| 24 | "required_contexts":["ci/test","ext/lint"]},"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"},"exit_code":0} | ||
| 30 | """ | ||
| 31 | |||
| 32 | private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"# | ||
| 33 | |||
| 34 | @MainActor | ||
| 35 | struct RequiredContextsTests { | ||
| 36 | |||
| 37 | private func loaded() async throws -> (RepoSettingsViewModel, StubProtocol.Box) { | ||
| 38 | let (client, stub) = try makeClient() | ||
| 39 | stub.enqueue(.init(status: 200, json: settingsJSON, match: "argv=settings")) | ||
| 40 | stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz")) | ||
| 41 | let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 42 | await model.load() | ||
| 43 | return (model, stub) | ||
| 44 | } | ||
| 45 | |||
| 46 | @Test func requiredContextsDecode() async throws { | ||
| 47 | let (model, _) = try await loaded() | ||
| 48 | #expect(model.state.value?.settings.contexts == ["ci/test", "ext/lint"]) | ||
| 49 | } | ||
| 50 | |||
| 51 | @Test func addingAContextSendsTheWholeListWithItAppended() async throws { | ||
| 52 | let (model, stub) = try await loaded() | ||
| 53 | stub.enqueue(.init(status: 200, json: ok, match: "cmd")) | ||
| 54 | |||
| 55 | let cleared = await model.addRequiredContext(" ci/build ") | ||
| 56 | |||
| 57 | #expect(cleared) | ||
| 58 | #expect(try postedArgv(stub) == [["repo", "settings", "require-contexts", "krz/gitbay", | ||
| 59 | "ci/test", "ext/lint", "ci/build"]]) | ||
| 60 | } | ||
| 61 | |||
| 62 | @Test func addingOneAlreadyListedSendsNothing() async throws { | ||
| 63 | let (model, stub) = try await loaded() | ||
| 64 | |||
| 65 | await model.addRequiredContext("ci/test") | ||
| 66 | |||
| 67 | #expect(try postedArgv(stub).isEmpty) | ||
| 68 | } | ||
| 69 | |||
| 70 | @Test func aRefusedContextIsNotClearedFromTheField() async throws { | ||
| 71 | let (model, stub) = try await loaded() | ||
| 72 | stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"at most 20 required contexts","exit_code":2}"#, match: "cmd")) | ||
| 73 | |||
| 74 | let cleared = await model.addRequiredContext("ci/more") | ||
| 75 | |||
| 76 | #expect(!cleared) | ||
| 77 | #expect(model.actionError != nil) | ||
| 78 | } | ||
| 79 | |||
| 80 | @Test func removingTheLastContextSendsAnEmptyList() async throws { | ||
| 81 | let (client, stub) = try makeClient() | ||
| 82 | stub.enqueue(.init(status: 200, json: """ | ||
| 83 | {"protocol_version":1,"data":{"require_checks":true,"required_contexts":["ci/test"]},"exit_code":0} | ||
| 84 | """, match: "argv=settings")) | ||
| 85 | stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz")) | ||
| 86 | let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") | ||
| 87 | await model.load() | ||
| 88 | stub.enqueue(.init(status: 200, json: ok, match: "cmd")) | ||
| 89 | |||
| 90 | await model.removeRequiredContext("ci/test") | ||
| 91 | |||
| 92 | #expect(try postedArgv(stub) == [["repo", "settings", "require-contexts", "krz/gitbay"]]) | ||
| 93 | } | ||
| 94 | } | ||