Commit bf403d4e28
bf403d4e281d8ae8c83d9f7c634a9720ed4207f9
parent: c266338b03
Verified · cmc
cmc <hello@cleberg.net> · 2026-10-14 05:20 UTC
Required contexts: say whether the gate is on; keep a refused name
The heading under Require green checks follows the toggle, so named
checks do not read as required while the gate is off. Adding waits for
the server and clears the field only once it takes the name, and a
second add cannot start while one is in flight.
Ref #25
Layout: unified · split
gitbay/Repos/RepoSettingsViewModel.swift
+7 −2
| @@ -113,11 +113,16 @@ final class RepoSettingsViewModel { |
| 113 | 113 | |
| 114 | 114 | /// `repo settings require-contexts` replaces the list, so the whole |
| 115 | 115 | /// list goes each time. Naming one turns the checks gate on. |
| 116 | | func addRequiredContext(_ context: String) async { |
| 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 { |
| 117 | 120 | let context = context.trimmingCharacters(in: .whitespaces) |
| 118 | 121 | let current = state.value?.settings.contexts ?? [] |
| 119 | | guard !context.isEmpty, !current.contains(context) else { return } |
| 122 | guard !context.isEmpty, !working else { return false } |
| 123 | guard !current.contains(context) else { return true } |
| 120 | 124 | await setRequiredContexts(current + [context]) |
| 125 | return actionError == nil |
| 121 | 126 | } |
| 122 | 127 | |
| 123 | 128 | /// Removing the last one leaves the gate as it was, waiting on every check. |
gitbay/Views/Repos/RepoSettingsView.swift
+12 −6
| @@ -353,9 +353,7 @@ struct RepoSettingsView: View { |
| 353 | 353 | await model.setRequireChecks($0) |
| 354 | 354 | } |
| 355 | 355 | VStack(alignment: .leading, spacing: 6) { |
| 356 | | Text(loaded.settings.contexts.isEmpty |
| 357 | | ? "Checks required: every check on the head" |
| 358 | | : "Checks required by name") |
| 356 | Text(contextsHeading(loaded.settings)) |
| 359 | 357 | .font(.gbSans(.subheadline)) |
| 360 | 358 | RemovableChips(items: loaded.settings.contexts, idPrefix: "settings-context", |
| 361 | 359 | disabled: model.working, |
| @@ -365,7 +363,7 @@ struct RepoSettingsView: View { |
| 365 | 363 | .font(.gbMono(.subheadline)) |
| 366 | 364 | .autocorrectionDisabled() |
| 367 | 365 | .textInputAutocapitalization(.never) |
| 368 | | .onSubmit { addContext() } |
| 366 | .onSubmit { if !model.working { addContext() } } |
| 369 | 367 | .accessibilityIdentifier("settings-context-field") |
| 370 | 368 | Button { |
| 371 | 369 | addContext() |
| @@ -392,8 +390,16 @@ struct RepoSettingsView: View { |
| 392 | 390 | |
| 393 | 391 | private func addContext() { |
| 394 | 392 | let context = newContext |
| 395 | | newContext = "" |
| 396 | | Task { await model.addRequiredContext(context) } |
| 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" |
| 401 | } |
| 402 | return settings.contexts.isEmpty ? "Every check on the head must pass" : "These checks must pass" |
| 397 | 403 | } |
| 398 | 404 | |
| 399 | 405 | private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { |
gitbayTests/RequiredContextsTests.swift
+12 −1
| @@ -52,8 +52,9 @@ struct RequiredContextsTests { |
| 52 | 52 | let (model, stub) = try await loaded() |
| 53 | 53 | stub.enqueue(.init(status: 200, json: ok, match: "cmd")) |
| 54 | 54 | |
| 55 | | await model.addRequiredContext(" ci/build ") |
| 55 | let cleared = await model.addRequiredContext(" ci/build ") |
| 56 | 56 | |
| 57 | #expect(cleared) |
| 57 | 58 | #expect(try postedArgv(stub) == [["repo", "settings", "require-contexts", "krz/gitbay", |
| 58 | 59 | "ci/test", "ext/lint", "ci/build"]]) |
| 59 | 60 | } |
| @@ -66,6 +67,16 @@ struct RequiredContextsTests { |
| 66 | 67 | #expect(try postedArgv(stub).isEmpty) |
| 67 | 68 | } |
| 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 | |
| 69 | 80 | @Test func removingTheLastContextSendsAnEmptyList() async throws { |
| 70 | 81 | let (client, stub) = try makeClient() |
| 71 | 82 | stub.enqueue(.init(status: 200, json: """ |