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 /// `repo settings require-contexts` replaces the list, so the whole 114 /// `repo settings require-contexts` replaces the list, so the whole
115 /// list goes each time. Naming one turns the checks gate on. 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 let context = context.trimmingCharacters(in: .whitespaces) 120 let context = context.trimmingCharacters(in: .whitespaces)
118 let current = state.value?.settings.contexts ?? [] 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 await setRequiredContexts(current + [context]) 124 await setRequiredContexts(current + [context])
125 return actionError == nil
121 } 126 }
122 127
123 /// Removing the last one leaves the gate as it was, waiting on every check. 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 await model.setRequireChecks($0) 353 await model.setRequireChecks($0)
354 } 354 }
355 VStack(alignment: .leading, spacing: 6) { 355 VStack(alignment: .leading, spacing: 6) {
356 Text(loaded.settings.contexts.isEmpty 356 Text(contextsHeading(loaded.settings))
357 ? "Checks required: every check on the head"
358 : "Checks required by name")
359 .font(.gbSans(.subheadline)) 357 .font(.gbSans(.subheadline))
360 RemovableChips(items: loaded.settings.contexts, idPrefix: "settings-context", 358 RemovableChips(items: loaded.settings.contexts, idPrefix: "settings-context",
361 disabled: model.working, 359 disabled: model.working,
@@ -365,7 +363,7 @@ struct RepoSettingsView: View {
365 .font(.gbMono(.subheadline)) 363 .font(.gbMono(.subheadline))
366 .autocorrectionDisabled() 364 .autocorrectionDisabled()
367 .textInputAutocapitalization(.never) 365 .textInputAutocapitalization(.never)
368 .onSubmit { addContext() } 366 .onSubmit { if !model.working { addContext() } }
369 .accessibilityIdentifier("settings-context-field") 367 .accessibilityIdentifier("settings-context-field")
370 Button { 368 Button {
371 addContext() 369 addContext()
@@ -392,8 +390,16 @@ struct RepoSettingsView: View {
392 390
393 private func addContext() { 391 private func addContext() {
394 let context = newContext 392 let context = newContext
395 newContext = "" 393 Task {
396 Task { await model.addRequiredContext(context) } 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 private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { 405 private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
gitbayTests/RequiredContextsTests.swift +12 −1
@@ -52,8 +52,9 @@ struct RequiredContextsTests {
52 let (model, stub) = try await loaded() 52 let (model, stub) = try await loaded()
53 stub.enqueue(.init(status: 200, json: ok, match: "cmd")) 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 #expect(try postedArgv(stub) == [["repo", "settings", "require-contexts", "krz/gitbay", 58 #expect(try postedArgv(stub) == [["repo", "settings", "require-contexts", "krz/gitbay",
58 "ci/test", "ext/lint", "ci/build"]]) 59 "ci/test", "ext/lint", "ci/build"]])
59 } 60 }
@@ -66,6 +67,16 @@ struct RequiredContextsTests {
66 #expect(try postedArgv(stub).isEmpty) 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 @Test func removingTheLastContextSendsAnEmptyList() async throws { 80 @Test func removingTheLastContextSendsAnEmptyList() async throws {
70 let (client, stub) = try makeClient() 81 let (client, stub) = try makeClient()
71 stub.enqueue(.init(status: 200, json: """ 82 stub.enqueue(.init(status: 200, json: """