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 {
99 let protectedTags: [String]?
1010 let requireSignedCommits: Bool?
1111 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]?
1215 let requireApprovals: Int?
1316 let requireResolved: Bool?
1417 let requireCodeowners: Bool?
@@ -24,6 +27,7 @@ nonisolated struct RepoSettings: Decodable, Sendable, Hashable {
2427 case requireMR = "require_mr"
2528 case requireSignedCommits = "require_signed_commits"
2629 case requireChecks = "require_checks"
30 case requiredContexts = "required_contexts"
2731 case requireApprovals = "require_approvals"
2832 case requireResolved = "require_resolved"
2933 case requireCodeowners = "require_codeowners"
@@ -33,6 +37,7 @@ nonisolated struct RepoSettings: Decodable, Sendable, Hashable {
3337
3438 var branches: [String] { protectedBranches ?? [] }
3539 var tags: [String] { protectedTags ?? [] }
40 var contexts: [String] { requiredContexts ?? [] }
3641}
3742
3843/// One row of `repo access list`: an account and its effective role.
gitbay/Repos/RepoSettingsViewModel.swift +18
@@ -111,6 +111,24 @@ final class RepoSettingsViewModel {
111111 await perform(["repo", "settings", "require-checks", repoPath, on ? "on" : "off"])
112112 }
113113
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
114132 func setRequireSigned(_ on: Bool) async {
115133 await perform(["repo", "settings", "require-signed", repoPath, on ? "on" : "off"])
116134 }
gitbay/Views/Repos/RepoSettingsView.swift +33 −1
@@ -10,6 +10,7 @@ struct RepoSettingsView: View {
1010 @State private var newTopic = ""
1111 @State private var newBranch = ""
1212 @State private var newTagGlob = ""
13 @State private var newContext = ""
1314 @State private var renaming = false
1415 @State private var newName = ""
1516 @State private var renameConfirm = ""
@@ -351,6 +352,31 @@ struct RepoSettingsView: View {
351352 toggle("Require green checks", loaded.settings.requireChecks ?? false) {
352353 await model.setRequireChecks($0)
353354 }
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 }
354380 toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) {
355381 await model.setRequireSigned($0)
356382 }
@@ -360,10 +386,16 @@ struct RepoSettingsView: View {
360386 } header: {
361387 Text("Merge requirements")
362388 } 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.")
364390 }
365391 }
366392
393 private func addContext() {
394 let context = newContext
395 newContext = ""
396 Task { await model.addRequiredContext(context) }
397 }
398
367399 private func daemonSection(_ loaded: RepoSettingsViewModel.Loaded) -> some View {
368400 Section {
369401 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}