Required status contexts in repository settings !158

merged merged by cmc on 2026-10-14 05:28 UTC · krz/gitbay-ios:require-contexts into main

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 @@
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 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}