Commit 63a1edda14

63a1edda1414eb84776aa050c718197c1917d153

parent: 1dab2a3e20

Unsigned

cmc <hello@cleberg.net> · 2026-09-07 17:11 UTC

Require code owner review toggle in repo settings (!66)

Decodes require_codeowners and dispatches repo settings require-codeowners on|off beside the other merge rules. Unit test covers the decode and the argv; the live suite flips the toggle.

Layout: unified · split

gitbay/Repos/RepoSettings.swift +2
@@ -8,6 +8,7 @@ nonisolated struct RepoSettings: Decodable, Sendable, Hashable {
8 let requireChecks: Bool? 8 let requireChecks: Bool?
9 let requireApprovals: Int? 9 let requireApprovals: Int?
10 let requireResolved: Bool? 10 let requireResolved: Bool?
11 let requireCodeowners: Bool?
11 let gitDaemon: Bool? 12 let gitDaemon: Bool?
12 let archived: Bool? 13 let archived: Bool?
13 let website: String? 14 let website: String?
@@ -18,6 +19,7 @@ nonisolated struct RepoSettings: Decodable, Sendable, Hashable {
18 case requireChecks = "require_checks" 19 case requireChecks = "require_checks"
19 case requireApprovals = "require_approvals" 20 case requireApprovals = "require_approvals"
20 case requireResolved = "require_resolved" 21 case requireResolved = "require_resolved"
22 case requireCodeowners = "require_codeowners"
21 case gitDaemon = "git_daemon" 23 case gitDaemon = "git_daemon"
22 case archived, website 24 case archived, website
23 } 25 }
gitbay/Repos/RepoSettingsViewModel.swift +4
@@ -107,6 +107,10 @@ final class RepoSettingsViewModel {
107 await perform(["repo", "settings", "require-signed", repoPath, on ? "on" : "off"]) 107 await perform(["repo", "settings", "require-signed", repoPath, on ? "on" : "off"])
108 } 108 }
109 109
110 func setRequireCodeowners(_ on: Bool) async {
111 await perform(["repo", "settings", "require-codeowners", repoPath, on ? "on" : "off"])
112 }
113
110 func addTopic(_ topic: String) async { 114 func addTopic(_ topic: String) async {
111 await perform(["repo", "topics", "add", repoPath, topic]) 115 await perform(["repo", "topics", "add", repoPath, topic])
112 } 116 }
gitbay/Views/Repos/RepoSettingsView.swift +3
@@ -210,6 +210,9 @@ struct RepoSettingsView: View {
210 toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) { 210 toggle("Require signed commits", loaded.settings.requireSignedCommits ?? false) {
211 await model.setRequireSigned($0) 211 await model.setRequireSigned($0)
212 } 212 }
213 toggle("Require code owner review", loaded.settings.requireCodeowners ?? false) {
214 await model.setRequireCodeowners($0)
215 }
213 } header: { 216 } header: {
214 Text("Merge requirements") 217 Text("Merge requirements")
215 } footer: { 218 } footer: {
gitbayTests/RepoManagementTests.swift +5 −2
@@ -21,7 +21,7 @@ private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
21 21
22private let settingsShowJSON = """ 22private let settingsShowJSON = """
23 {"protocol_version":1,"data":{"protected_branches":["main"],"require_approvals":1,\ 23 {"protocol_version":1,"data":{"protected_branches":["main"],"require_approvals":1,\
24 "require_resolved":true,"website":"https://gitbay.org"},"exit_code":0} 24 "require_resolved":true,"require_codeowners":true,"website":"https://gitbay.org"},"exit_code":0}
25 """ 25 """
26 26
27private let repoShowJSON = """ 27private let repoShowJSON = """
@@ -50,6 +50,7 @@ struct RepoSettingsViewModelTests {
50 #expect(loaded.settings.requireResolved == true) 50 #expect(loaded.settings.requireResolved == true)
51 // Absent means default, not unknown. 51 // Absent means default, not unknown.
52 #expect(loaded.settings.requireChecks == nil) 52 #expect(loaded.settings.requireChecks == nil)
53 #expect(loaded.settings.requireCodeowners == true)
53 #expect(loaded.detail.topics == ["git"]) 54 #expect(loaded.detail.topics == ["git"])
54 } 55 }
55 56
@@ -72,7 +73,7 @@ struct RepoSettingsViewModelTests {
72 73
73 @Test func everyKnobSendsItsOwnCommand() async throws { 74 @Test func everyKnobSendsItsOwnCommand() async throws {
74 let (model, stub) = try await loadedModel() 75 let (model, stub) = try await loadedModel()
75 for _ in 0..<12 { 76 for _ in 0..<13 {
76 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) 77 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
77 stub.enqueue(.init(status: 200, json: settingsShowJSON, match: "argv=settings")) 78 stub.enqueue(.init(status: 200, json: settingsShowJSON, match: "argv=settings"))
78 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz")) 79 stub.enqueue(.init(status: 200, json: repoShowJSON, match: "argv=show&argv=krz"))
@@ -88,6 +89,7 @@ struct RepoSettingsViewModelTests {
88 await model.setRequireResolved(false) 89 await model.setRequireResolved(false)
89 await model.setRequireChecks(true) 90 await model.setRequireChecks(true)
90 await model.setRequireSigned(true) 91 await model.setRequireSigned(true)
92 await model.setRequireCodeowners(true)
91 await model.addTopic("ios") 93 await model.addTopic("ios")
92 await model.removeTopic("git") 94 await model.removeTopic("git")
93 95
@@ -103,6 +105,7 @@ struct RepoSettingsViewModelTests {
103 ["repo", "settings", "require-resolved", "krz/gitbay", "off"], 105 ["repo", "settings", "require-resolved", "krz/gitbay", "off"],
104 ["repo", "settings", "require-checks", "krz/gitbay", "on"], 106 ["repo", "settings", "require-checks", "krz/gitbay", "on"],
105 ["repo", "settings", "require-signed", "krz/gitbay", "on"], 107 ["repo", "settings", "require-signed", "krz/gitbay", "on"],
108 ["repo", "settings", "require-codeowners", "krz/gitbay", "on"],
106 ["repo", "topics", "add", "krz/gitbay", "ios"], 109 ["repo", "topics", "add", "krz/gitbay", "ios"],
107 ["repo", "topics", "remove", "krz/gitbay", "git"], 110 ["repo", "topics", "remove", "krz/gitbay", "git"],
108 ]) 111 ])
gitbayUITests/LiveSmokeUITests.swift +16
@@ -479,6 +479,22 @@ extension LiveSmokeUITests {
479 control() 479 control()
480 XCTAssertTrue(waitForValue(resolved, "0", timeout: 10), "toggle did not persist off") 480 XCTAssertTrue(waitForValue(resolved, "0", timeout: 10), "toggle did not persist off")
481 481
482 // The code-owner rule is one more toggle in the same section.
483 let codeowners = app.switches["Require code owner review"].firstMatch
484 XCTAssertTrue(scrollTo(codeowners), "code owner toggle not reachable")
485 let innerCodeowners = codeowners.switches.firstMatch
486 let flipCodeowners: () -> Void = {
487 if innerCodeowners.exists && innerCodeowners != codeowners {
488 innerCodeowners.tap()
489 } else {
490 codeowners.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
491 }
492 }
493 flipCodeowners()
494 XCTAssertTrue(waitForValue(codeowners, "1", timeout: 10), "code owner toggle did not persist on")
495 flipCodeowners()
496 XCTAssertTrue(waitForValue(codeowners, "0", timeout: 10), "code owner toggle did not persist off")
497
482 back() // settings -> repo 498 back() // settings -> repo
483 499
484 // --- nothing to trigger without a job file --- 500 // --- nothing to trigger without a job file ---