Commit c266338b03

c266338b030ada699b98620a4378338b55f12541

parent: 7a968e514f

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 05:05 UTC

Required status contexts in repository settings

Under Require green checks, the checks the gate waits on by name, with
a field to add one and a remove button on each. The command replaces
the list, so each change sends the whole of it.

Closes #25

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 +18
@@ -111,6 +111,24 @@ 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 func addRequiredContext(_ context: String) async {
117 let context = context.trimmingCharacters(in: .whitespaces)
118 let current = state.value?.settings.contexts ?? []
119 guard !context.isEmpty, !current.contains(context) else { return }
120 await setRequiredContexts(current + [context])
121 }
122
123 /// Removing the last one leaves the gate as it was, waiting on every check.
124 func removeRequiredContext(_ context: String) async {
125 await setRequiredContexts((state.value?.settings.contexts ?? []).filter { $0 != context })
126 }
127
128 private func setRequiredContexts(_ contexts: [String]) async {
129 await perform(["repo", "settings", "require-contexts", repoPath] + contexts)
130 }
131
114 func setRequireSigned(_ on: Bool) async { 132 func setRequireSigned(_ on: Bool) async {
115 await perform(["repo", "settings", "require-signed", repoPath, on ? "on" : "off"]) 133 await perform(["repo", "settings", "require-signed", repoPath, on ? "on" : "off"])
116 } 134 }
gitbay/Views/Repos/RepoSettingsView.swift +33 −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,31 @@ 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(loaded.settings.contexts.isEmpty
357 ? "Checks required: every check on the head"
358 : "Checks required by name")
359 .font(.gbSans(.subheadline))
360 RemovableChips(items: loaded.settings.contexts, idPrefix: "settings-context",
361 disabled: model.working,
362 remove: { context in Task { await model.removeRequiredContext(context) } })
363 HStack {
364 TextField("ci/test", text: $newContext)
365 .font(.gbMono(.subheadline))
366 .autocorrectionDisabled()
367 .textInputAutocapitalization(.never)
368 .onSubmit { addContext() }
369 .accessibilityIdentifier("settings-context-field")
370 Button {
371 addContext()
372 } label: {
373 Image(systemName: "plus.circle.fill")
374 }
375 .accessibilityLabel("Require check")
376 .disabled(newContext.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
377 .accessibilityIdentifier("settings-context-add")
378 }
379 }
354 toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) { 380 toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) {
355 await model.setRequireSigned($0) 381 await model.setRequireSigned($0)
356 } 382 }
@@ -360,10 +386,16 @@ struct RepoSettingsView: View {
360 } header: { 386 } header: {
361 Text("Merge requirements") 387 Text("Merge requirements")
362 } footer: { 388 } footer: {
363 Text("The server enforces these on every merge, whatever the surface.") 389 Text("The server enforces these on every merge, whatever the surface. Naming a check turns the checks gate on.")
364 } 390 }
365 } 391 }
366 392
393 private func addContext() {
394 let context = newContext
395 newContext = ""
396 Task { await model.addRequiredContext(context) }
397 }
398
367 private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View { 399 private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
368 Section { 400 Section {
369 toggle("git:// daemon", loaded.settings.gitDaemon ?? false) { 401 toggle("git:// daemon", loaded.settings.gitDaemon ?? false) {
gitbayTests/RequiredContextsTests.swift added +83
@@ -0,0 +1,83 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private 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
15private 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
22private 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
27private let repoShowJSON = """
28 {"protocol_version":1,"data":{"path":"krz/gitbay","description":"a forge",\
29 "visibility":"public","default_branch":"main"},"exit_code":0}
30 """
31
32private let ok = #"{"protocol_version":1,"data":{},"exit_code":0}"#
33
34@MainActor
35struct 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 await model.addRequiredContext(" ci/build ")
56
57 #expect(try postedArgv(stub) == [["repo", "settings", "require-contexts", "krz/gitbay",
58 "ci/test", "ext/lint", "ci/build"]])
59 }
60
61 @Test func addingOneAlreadyListedSendsNothing() async throws {
62 let (model, stub) = try await loaded()
63
64 await model.addRequiredContext("ci/test")
65
66 #expect(try postedArgv(stub).isEmpty)
67 }
68
69 @Test func removingTheLastContextSendsAnEmptyList() async throws {
70 let (client, stub) = try makeClient()
71 stub.enqueue(.init(status: 200, json: """
72 {"protocol_version":1,"data":{"require_checks":true,"required_contexts":["ci/test"]},"exit_code":0}
73 """, match: "argv=settings"))
74 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz"))
75 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
76 await model.load()
77 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
78
79 await model.removeRequiredContext("ci/test")
80
81 #expect(try postedArgv(stub) == [["repo", "settings", "require-contexts", "krz/gitbay"]])
82 }
83}