Commit 2a248c5e42

2a248c5e42989d3e53bd7a14aed05d4b17860396

parent: 3efee558e0

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-07 00:49 UTC

Dependency check status and toggle

Layout: unified · split

gitbay/Repos/DepsStatus.swift added +32
@@ -0,0 +1,32 @@
1import Foundation
2
3/// `repo deps status <owner/name>` — dependency-check status. `lastCheck`,
4/// `lastError` and `issueNumber` are `omitempty` server-side: a repository
5/// whose checks were just switched on carries none of them, which is the
6/// ordinary case, not an edge case.
7nonisolated struct DepsStatus: Decodable, Sendable, Hashable {
8 let enabled: Bool
9 let lastCheck: Date?
10 let lastError: String?
11 let issueNumber: Int64?
12 let behind: [DepBehind]
13
14 enum CodingKeys: String, CodingKey {
15 case enabled, behind
16 case lastCheck = "last_check"
17 case lastError = "last_error"
18 case issueNumber = "issue_number"
19 }
20}
21
22/// One outdated dependency. `id` includes the ecosystem because two
23/// ecosystems can carry the same package name, and a colliding id makes
24/// SwiftUI reuse one row for the other.
25nonisolated struct DepBehind: Decodable, Sendable, Hashable, Identifiable {
26 let ecosystem: String
27 let name: String
28 let current: String
29 let latest: String
30
31 var id: String { "\(ecosystem)/\(name)" }
32}
gitbay/Repos/RepoSettingsViewModel.swift +34 −1
@@ -17,6 +17,14 @@ final class RepoSettingsViewModel {
17 private(set) var actionError: String? 17 private(set) var actionError: String?
18 private(set) var working = false 18 private(set) var working = false
19 19
20 /// `repo deps status`, exposed as an optional plus a separate error
21 /// string rather than folded into one state: `nil` with no error means
22 /// "not loaded yet", `nil` with an error means "the read failed". The
23 /// settings screen must never render "checks are off" when it means
24 /// "we could not ask".
25 private(set) var deps: DepsStatus?
26 private(set) var depsError: String?
27
20 private let client: GitbayClient 28 private let client: GitbayClient
21 let repoPath: String 29 let repoPath: String
22 30
@@ -36,6 +44,27 @@ final class RepoSettingsViewModel {
36 } 44 }
37 } 45 }
38 46
47 /// `repo deps status` — three words, unlike every other `repo` command
48 /// in this file, which is exactly why it's easy to get wrong.
49 func loadDeps() async {
50 depsError = nil
51 deps = nil
52 do {
53 deps = try await client.read(["repo", "deps", "status", repoPath], as: DepsStatus.self)
54 } catch let error as GitbayError {
55 depsError = error.userFacingMessage
56 } catch {
57 depsError = GitbayError.transport(error).userFacingMessage
58 }
59 }
60
61 /// `enable`/`disable` are two distinct commands, not one command with a
62 /// flag. Reloads the status afterward so the report reflects the
63 /// change; a refused write leaves `deps` untouched.
64 func setDepsEnabled(_ on: Bool) async {
65 await perform(["repo", "deps", on ? "enable" : "disable", repoPath], reload: loadDeps)
66 }
67
39 // MARK: - Writes, one command per knob 68 // MARK: - Writes, one command per knob
40 69
41 func setDescription(_ text: String) async { 70 func setDescription(_ text: String) async {
@@ -87,12 +116,16 @@ final class RepoSettingsViewModel {
87 } 116 }
88 117
89 private func perform(_ argv: [String]) async { 118 private func perform(_ argv: [String]) async {
119 await perform(argv, reload: load)
120 }
121
122 private func perform(_ argv: [String], reload: () async -> Void) async {
90 working = true 123 working = true
91 actionError = nil 124 actionError = nil
92 defer { working = false } 125 defer { working = false }
93 do { 126 do {
94 try await client.run(argv) 127 try await client.run(argv)
95 await load() 128 await reload()
96 } catch let error as GitbayError { 129 } catch let error as GitbayError {
97 actionError = error.userFacingMessage 130 actionError = error.userFacingMessage
98 } catch { 131 } catch {
gitbayTests/DepsTests.swift added +173
@@ -0,0 +1,173 @@
1import Foundation
2import Testing
3
4@testable import gitbay
5
6private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
7 let box = StubProtocol.box()
8 let client = GitbayClient(
9 instance: try GitbayInstance(url: "https://gitbay.org"),
10 token: "test-token",
11 session: box.session()
12 )
13 return (client, box)
14}
15
16private func argvFrom(_ url: URL) -> [String] {
17 URLComponents(url: url, resolvingAgainstBaseURL: false)?
18 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
19}
20
21private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
22 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
23 return try #require(body["argv"] as? [String])
24}
25
26private let fullStatus = """
27 {"protocol_version":1,"data":{"enabled":true,\
28 "last_check":"2026-09-06T09:47:07.905Z","issue_number":140,\
29 "behind":[{"ecosystem":"go","name":"github.com/yuin/goldmark",\
30 "current":"v1.8.5","latest":"v1.8.6"}]},"exit_code":0}
31 """
32
33struct DepsDecodingTests {
34
35 private func decode(_ json: String) throws -> DepsStatus {
36 let decoder = JSONDecoder()
37 decoder.dateDecodingStrategy = .iso8601
38 return try decoder.decode(DepsStatus.self, from: Data(json.utf8))
39 }
40
41 /// A repository whose checks were just switched on has never been
42 /// checked, so it carries none of the three omitempty fields. That is
43 /// the ordinary case, not an edge case.
44 @Test func aNeverCheckedRepositoryHasNoTimestampErrorOrIssue() throws {
45 let status = try decode("""
46 {"enabled":true,"behind":[]}
47 """)
48 #expect(status.enabled)
49 #expect(status.lastCheck == nil)
50 #expect(status.lastError == nil)
51 #expect(status.issueNumber == nil)
52 #expect(status.behind.isEmpty)
53 }
54
55 @Test func aCheckedRepositoryDecodesEverything() throws {
56 let status = try decode("""
57 {"enabled":true,"last_check":"2026-09-06T09:47:07Z","issue_number":140,\
58 "behind":[{"ecosystem":"go","name":"modernc.org/sqlite",\
59 "current":"v1.57.0","latest":"v1.58.0"}]}
60 """)
61 #expect(status.lastCheck != nil)
62 #expect(status.issueNumber == 140)
63 #expect(status.behind.first?.name == "modernc.org/sqlite")
64 #expect(status.behind.first?.latest == "v1.58.0")
65 }
66
67 @Test func aFailedCheckCarriesItsError() throws {
68 let status = try decode("""
69 {"enabled":true,"last_error":"could not read go.mod","behind":[]}
70 """)
71 #expect(status.lastError == "could not read go.mod")
72 }
73
74 /// Two ecosystems can carry the same package name, so the id must
75 /// include the ecosystem or SwiftUI reuses one row for the other.
76 @Test func behindRowsAreUniqueAcrossEcosystems() throws {
77 let status = try decode("""
78 {"enabled":true,"behind":[\
79 {"ecosystem":"go","name":"x","current":"1","latest":"2"},\
80 {"ecosystem":"npm","name":"x","current":"1","latest":"2"}]}
81 """)
82 let ids = status.behind.map(\.id)
83 #expect(Set(ids).count == 2)
84 }
85}
86
87@MainActor
88struct DepsActionTests {
89
90 /// All three are THREE-word paths. The app sends two-word `repo`
91 /// commands everywhere else, so this is easy to get wrong.
92 @Test func statusUsesTheThreeWordPath() async throws {
93 let (client, stub) = try makeClient()
94 stub.enqueue(.init(status: 200, json: fullStatus))
95 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
96 await model.loadDeps()
97
98 #expect(argvFrom(try #require(stub.seen.last).url)
99 == ["repo", "deps", "status", "krz/gitbay"])
100 #expect(model.deps?.enabled == true)
101 #expect(model.deps?.behind.count == 1)
102 }
103
104 @Test func enableAndDisableAreDifferentCommands() async throws {
105 let (client, stub) = try makeClient()
106 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
107
108 stub.enqueue(.init(status: 200, json: """
109 {"protocol_version":1,"exit_code":0}
110 """))
111 stub.enqueue(.init(status: 200, json: fullStatus))
112 await model.setDepsEnabled(true)
113 var write = try #require(stub.seen.first { $0.method == "POST" })
114 #expect(try argvOf(write) == ["repo", "deps", "enable", "krz/gitbay"])
115
116 let (client2, stub2) = try makeClient()
117 let model2 = RepoSettingsViewModel(client: client2, repoPath: "krz/gitbay")
118 stub2.enqueue(.init(status: 200, json: """
119 {"protocol_version":1,"exit_code":0}
120 """))
121 stub2.enqueue(.init(status: 200, json: fullStatus))
122 await model2.setDepsEnabled(false)
123 write = try #require(stub2.seen.first { $0.method == "POST" })
124 #expect(try argvOf(write) == ["repo", "deps", "disable", "krz/gitbay"])
125 }
126
127 /// "Could not ask" must not render as "checks are off".
128 @Test func aFailedStatusReadIsDistinguishableFromChecksBeingOff() async throws {
129 let (client, stub) = try makeClient()
130 stub.enqueue(.init(status: 200, json: """
131 {"protocol_version":1,"error":"denied","exit_code":4}
132 """))
133 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
134 await model.loadDeps()
135
136 #expect(model.deps == nil)
137 #expect(model.depsError?.isEmpty == false)
138 }
139
140 @Test func aRefusedWriteSurfacesAndLeavesTheStatusAlone() async throws {
141 let (client, stub) = try makeClient()
142 stub.enqueue(.init(status: 200, json: fullStatus))
143 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
144 await model.loadDeps()
145 #expect(model.deps?.enabled == true)
146
147 stub.enqueue(.init(status: 200, json: """
148 {"protocol_version":1,"error":"turning dependency checks on needs admin","exit_code":4}
149 """))
150 await model.setDepsEnabled(false)
151
152 #expect(model.actionError?.isEmpty == false)
153 // The write failed, so the last known status still says enabled.
154 #expect(model.deps?.enabled == true)
155 }
156
157 /// A successful load after a failed one must clear the error.
158 @Test func depsErrorClearsOnALaterSuccess() async throws {
159 let (client, stub) = try makeClient()
160 stub.enqueue(.init(status: 200, json: """
161 {"protocol_version":1,"error":"denied","exit_code":4}
162 """))
163 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
164 await model.loadDeps()
165 #expect(model.depsError != nil)
166
167 stub.enqueue(.init(status: 200, json: fullStatus))
168 await model.loadDeps()
169
170 #expect(model.depsError == nil)
171 #expect(model.deps?.enabled == true)
172 }
173}