Commit 76468e95d3

76468e95d32ee16880cb671aa70e0a0e52dd0b6c

parent: 014ea9f840

Unsigned

cmc <hello@cleberg.net> · 2026-09-07 01:10 UTC

Dependency checks in repository settings (!55)

`repo deps status` reaches the app, with `repo deps enable` and
`repo deps disable` behind a toggle in repository settings — where the
web puts them.

Checks are off until a repository's admin turns them on, and the caption
says why someone might decline: the check tells a public registry what
the repository depends on.

The report distinguishes the states that matter. A repository whose
checks were just switched on says "never checked" rather than showing
nothing; one that is up to date says so rather than rendering an empty
list; and a status read that failed says so rather than drawing a switch
in the off position, which would read as "checks are off" when it means
"could not ask". The toggle renders only once the server has reported a
value.

The worker's tracking issue links through, and the server's own last
error surfaces when a check failed.

Closes two parity rows: dependency checks on/off, and dependency status.

Layout: unified · split

docs/superpowers/plans/2026-09-06-mr09-deps.md added +311
@@ -0,0 +1,311 @@
1# MR 9: Dependency checks Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Close two parity rows — `dependency checks on/off` and `dependency status`.
6
7**Architecture:** A toggle and a report, both inside the existing repository settings screen, which is where the web puts them. One read, two writes, no new screen.
8
9**Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md`
10
11## Global Constraints
12
13- Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s.
14- Swift Testing only — never XCTest.
15- `gitbayTests` is hermetic and offline; network goes through `StubProtocol`.
16- New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit.
17- The label model is `IssueLabel`; the notification model is `InboxNotification`.
18- Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer.
19- Never commit to `main`.
20
21**The commands, verbatim from the registry:**
22
23```
24repo deps enable <owner/name>
25repo deps disable <owner/name>
26repo deps status <owner/name>
27```
28
29All three are **three-word paths** — `["repo", "deps", "status", repoPath]`. The app already sends two-word `repo` commands everywhere, so a two-word slip here is the same class of mistake as MR 4's `mr review request`.
30
31**The status shape**, from `internal/control/deps.go:32-44` and confirmed against the live instance:
32
33```json
34{"enabled":true,"last_check":"2026-09-06T09:47:07.905Z","issue_number":140,
35 "behind":[{"ecosystem":"go","name":"github.com/yuin/goldmark",
36 "current":"v1.8.5","latest":"v1.8.6"}]}
37```
38
39`enabled` and `behind` are always present. **`last_check`, `last_error` and `issue_number` are `omitempty`** — a repository that has never been checked carries none of them, and that is the ordinary case for a repository whose checks were just switched on.
40
41**Counting tests — use `xcresulttool`, not grep:**
42
43```bash
44RES=$(ls -td ~/Library/Developer/Xcode/DerivedData/gitbay-*/Logs/Test/*.xcresult | head -1)
45xcrun xcresulttool get test-results summary --path "$RES"
46```
47
48Baseline: **321 total, 320 passed, 1 skipped, 0 failed.**
49
50---
51
52## File Structure
53
54| File | Responsibility |
55|------|----------------|
56| `gitbay/Repos/DepsStatus.swift` (create) | `DepsStatus` and `DepBehind` wire models |
57| `gitbay/Repos/RepoSettingsViewModel.swift` (modify) | `deps`, `depsError`, `loadDeps()`, `setDepsEnabled(_:)` |
58| `gitbayTests/DepsTests.swift` (create) | Every test in this plan |
59| `gitbay/Views/Repos/RepoSettingsView.swift` (modify) | The toggle and the report |
60
61---
62
63### Task 1: The model and the two actions
64
65**Files:** Create `gitbay/Repos/DepsStatus.swift`; modify `gitbay/Repos/RepoSettingsViewModel.swift`; test in `gitbayTests/DepsTests.swift`
66
67**Interfaces produced:**
68- `nonisolated struct DepsStatus: Decodable, Sendable, Hashable` — `enabled: Bool`, `lastCheck: Date?`, `lastError: String?`, `issueNumber: Int64?`, `behind: [DepBehind]`
69- `nonisolated struct DepBehind: Decodable, Sendable, Hashable, Identifiable` — `ecosystem: String`, `name: String`, `current: String`, `latest: String`; `id` unique per row
70- `RepoSettingsViewModel.deps: DepsStatus?`, `.depsError: String?`, `func loadDeps() async`, `func setDepsEnabled(_ on: Bool) async`
71
72Behaviour, each pinned by a test:
731. All three commands are **three-word paths**: `["repo","deps","status",repo]`, `["repo","deps","enable",repo]`, `["repo","deps","disable",repo]`.
742. `setDepsEnabled(true)` sends `enable`, `setDepsEnabled(false)` sends `disable` — two commands, not a flag.
753. `last_check`, `last_error` and `issue_number` are absent for a never-checked repository. Decoding must not require them; `behind` may be present and empty.
764. **`deps` is `DepsStatus?` and `depsError` is separate.** `nil` deps with no error means "not loaded yet"; `nil` deps with an error means "the read failed". They must be distinguishable — this codebase has hit the collapsed-three-state bug repeatedly, and the settings screen must not render "checks are off" when it means "we could not ask".
775. A refused write surfaces into the existing `actionError` and does not flip the toggle's backing state.
786. `DepBehind.id` is unique across ecosystems — two ecosystems can carry the same package name.
79
80- [ ] **Step 1: Write the failing tests**
81
82```swift
83import Foundation
84import Testing
85@testable import gitbay
86
87private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
88 let box = StubProtocol.box()
89 let client = GitbayClient(
90 instance: try GitbayInstance(url: "https://gitbay.org"),
91 token: "test-token",
92 session: box.session()
93 )
94 return (client, box)
95}
96
97private func argvFrom(_ url: URL) -> [String] {
98 URLComponents(url: url, resolvingAgainstBaseURL: false)?
99 .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
100}
101
102private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
103 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
104 return try #require(body["argv"] as? [String])
105}
106
107private let fullStatus = """
108 {"protocol_version":1,"data":{"enabled":true,\
109 "last_check":"2026-09-06T09:47:07.905Z","issue_number":140,\
110 "behind":[{"ecosystem":"go","name":"github.com/yuin/goldmark",\
111 "current":"v1.8.5","latest":"v1.8.6"}]},"exit_code":0}
112 """
113
114struct DepsDecodingTests {
115
116 private func decode(_ json: String) throws -> DepsStatus {
117 let decoder = JSONDecoder()
118 decoder.dateDecodingStrategy = .iso8601
119 return try decoder.decode(DepsStatus.self, from: Data(json.utf8))
120 }
121
122 /// A repository whose checks were just switched on has never been
123 /// checked, so it carries none of the three omitempty fields. That is
124 /// the ordinary case, not an edge case.
125 @Test func aNeverCheckedRepositoryHasNoTimestampErrorOrIssue() throws {
126 let status = try decode("""
127 {"enabled":true,"behind":[]}
128 """)
129 #expect(status.enabled)
130 #expect(status.lastCheck == nil)
131 #expect(status.lastError == nil)
132 #expect(status.issueNumber == nil)
133 #expect(status.behind.isEmpty)
134 }
135
136 @Test func aCheckedRepositoryDecodesEverything() throws {
137 let status = try decode("""
138 {"enabled":true,"last_check":"2026-09-06T09:47:07Z","issue_number":140,\
139 "behind":[{"ecosystem":"go","name":"modernc.org/sqlite",\
140 "current":"v1.57.0","latest":"v1.58.0"}]}
141 """)
142 #expect(status.lastCheck != nil)
143 #expect(status.issueNumber == 140)
144 #expect(status.behind.first?.name == "modernc.org/sqlite")
145 #expect(status.behind.first?.latest == "v1.58.0")
146 }
147
148 @Test func aFailedCheckCarriesItsError() throws {
149 let status = try decode("""
150 {"enabled":true,"last_error":"could not read go.mod","behind":[]}
151 """)
152 #expect(status.lastError == "could not read go.mod")
153 }
154
155 /// Two ecosystems can carry the same package name, so the id must
156 /// include the ecosystem or SwiftUI reuses one row for the other.
157 @Test func behindRowsAreUniqueAcrossEcosystems() throws {
158 let status = try decode("""
159 {"enabled":true,"behind":[\
160 {"ecosystem":"go","name":"x","current":"1","latest":"2"},\
161 {"ecosystem":"npm","name":"x","current":"1","latest":"2"}]}
162 """)
163 let ids = status.behind.map(\.id)
164 #expect(Set(ids).count == 2)
165 }
166}
167
168@MainActor
169struct DepsActionTests {
170
171 /// All three are THREE-word paths. The app sends two-word `repo`
172 /// commands everywhere else, so this is easy to get wrong.
173 @Test func statusUsesTheThreeWordPath() async throws {
174 let (client, stub) = try makeClient()
175 stub.enqueue(.init(status: 200, json: fullStatus))
176 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
177 await model.loadDeps()
178
179 #expect(argvFrom(try #require(stub.seen.last).url)
180 == ["repo", "deps", "status", "krz/gitbay"])
181 #expect(model.deps?.enabled == true)
182 #expect(model.deps?.behind.count == 1)
183 }
184
185 @Test func enableAndDisableAreDifferentCommands() async throws {
186 let (client, stub) = try makeClient()
187 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
188
189 stub.enqueue(.init(status: 200, json: """
190 {"protocol_version":1,"exit_code":0}
191 """))
192 stub.enqueue(.init(status: 200, json: fullStatus))
193 await model.setDepsEnabled(true)
194 var write = try #require(stub.seen.first { $0.method == "POST" })
195 #expect(try argvOf(write) == ["repo", "deps", "enable", "krz/gitbay"])
196
197 let (client2, stub2) = try makeClient()
198 let model2 = RepoSettingsViewModel(client: client2, repoPath: "krz/gitbay")
199 stub2.enqueue(.init(status: 200, json: """
200 {"protocol_version":1,"exit_code":0}
201 """))
202 stub2.enqueue(.init(status: 200, json: fullStatus))
203 await model2.setDepsEnabled(false)
204 write = try #require(stub2.seen.first { $0.method == "POST" })
205 #expect(try argvOf(write) == ["repo", "deps", "disable", "krz/gitbay"])
206 }
207
208 /// "Could not ask" must not render as "checks are off".
209 @Test func aFailedStatusReadIsDistinguishableFromChecksBeingOff() async throws {
210 let (client, stub) = try makeClient()
211 stub.enqueue(.init(status: 200, json: """
212 {"protocol_version":1,"error":"denied","exit_code":4}
213 """))
214 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
215 await model.loadDeps()
216
217 #expect(model.deps == nil)
218 #expect(model.depsError?.isEmpty == false)
219 }
220
221 @Test func aRefusedWriteSurfacesAndLeavesTheStatusAlone() async throws {
222 let (client, stub) = try makeClient()
223 stub.enqueue(.init(status: 200, json: fullStatus))
224 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
225 await model.loadDeps()
226 #expect(model.deps?.enabled == true)
227
228 stub.enqueue(.init(status: 200, json: """
229 {"protocol_version":1,"error":"turning dependency checks on needs admin","exit_code":4}
230 """))
231 await model.setDepsEnabled(false)
232
233 #expect(model.actionError?.isEmpty == false)
234 // The write failed, so the last known status still says enabled.
235 #expect(model.deps?.enabled == true)
236 }
237
238 /// A successful load after a failed one must clear the error.
239 @Test func depsErrorClearsOnALaterSuccess() async throws {
240 let (client, stub) = try makeClient()
241 stub.enqueue(.init(status: 200, json: """
242 {"protocol_version":1,"error":"denied","exit_code":4}
243 """))
244 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
245 await model.loadDeps()
246 #expect(model.depsError != nil)
247
248 stub.enqueue(.init(status: 200, json: fullStatus))
249 await model.loadDeps()
250
251 #expect(model.depsError == nil)
252 #expect(model.deps?.enabled == true)
253 }
254}
255```
256
257**Check `RepoSettingsViewModel`'s real initialiser before writing these** and match it; do not change it to suit a test.
258
259- [ ] **Step 2: Run to verify failure.**
260- [ ] **Step 3: Implement.** `setDepsEnabled` routes through the file's existing `perform(argv:)`, then reloads the status so the report reflects the change. `loadDeps` sets exactly one of `deps` or `depsError` per attempt and clears the other.
261- [ ] **Step 4: Run the tests.**
262- [ ] **Step 5: Commit** — `git commit -m "Dependency check status and toggle"`
263
264---
265
266### Task 2: The toggle and the report in settings
267
268**Files:** Modify `gitbay/Views/Repos/RepoSettingsView.swift`
269
270No unit tests — UI.
271
272Add a `Dependencies` section after the existing ones (`aboutSection`, `topicsSection`, `visibilitySection`, `branchesSection`, `mergeRulesSection`, `daemonSection`) and match how they are built — each is a `private func …Section(_ loaded:) -> some View` returning a `Section`.
273
274The section shows, in this order:
275
2761. **A toggle** bound to `deps?.enabled`, disabled while the status is unknown. Checks are **off until a repository's admin turns them on**, and the check tells a public registry what the repository depends on — say that under the toggle, because it is the reason someone would decline.
2772. **When enabled**, the report:
278 - `last_check` as a relative time, or "never checked" when absent — the ordinary state right after switching on.
279 - `last_error` when present, as a `GBNotice` warning.
280 - A link to the tracking issue when `issue_number` is present, routing to `IssueRoute.issue(repo:number:)`. The worker opens, rewrites and closes that issue, and it is the same copy every other surface reads.
281 - The `behind` rows: ecosystem, name, current → latest. An empty `behind` with a `last_check` means everything is current — say so rather than showing an empty list.
2823. **Three distinct states for the read itself**: not loaded (spinner), failed (`depsError` as a notice, so "could not ask" never reads as "checks are off"), and loaded.
283
284Load with `.task`, and reuse the screen's existing notice mechanism for `actionError`.
285
286- [ ] **Step 1: The section with its toggle and caption**
287- [ ] **Step 2: The report, including the never-checked and everything-current cases**
288- [ ] **Step 3: The three read states**
289- [ ] **Step 4: Build and run the full suite.** No drop from 320 passed.
290- [ ] **Step 5: Commit** — `git commit -m "Dependency section in repository settings"`
291
292---
293
294### Task 3: Flip two parity rows and open the merge request
295
296- [ ] **Step 1:** Set `dependency checks on/off` and `dependency status` to `yes` for iOS. Touch no other row.
297
298Land it via a worktree off `origin/main` in `krz/gitbay`, merged `--strategy ff`. That repo requires signed commits, so squash and rebase merges are refused, and its working tree usually holds unrelated work — never switch its branch. **Read the merge request number back from `mr create`'s JSON rather than assuming it.**
299
300- [ ] **Step 2:** Run the full suite via `xcresulttool`; record the real numbers.
301- [ ] **Step 3:** Open the merge request.
302
303---
304
305## Notes for whoever executes this
306
307**All three commands are three-word paths.** `["repo","deps","status",…]`. Every other `repo` command in this app is two words, which is exactly why this is easy to get wrong — and a wrong path returns a usage error the screen would render as a failed read.
308
309**Never-checked is the ordinary case.** `last_check`, `last_error` and `issue_number` are all `omitempty`. A repository whose checks were switched on a moment ago has none of them.
310
311**"Could not ask" is not "checks are off".** Keep `deps` and `depsError` separate and render three states. This codebase has collapsed that distinction repeatedly.
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 {
gitbay/Views/Repos/RepoSettingsView.swift +66 −1
@@ -29,6 +29,7 @@ struct RepoSettingsView: View {
29 branchesSection(loaded) 29 branchesSection(loaded)
30 mergeRulesSection(loaded) 30 mergeRulesSection(loaded)
31 daemonSection(loaded) 31 daemonSection(loaded)
32 depsSection()
32 } 33 }
33 } 34 }
34 .overlay { LoadStateOverlay(state: model.state) } 35 .overlay { LoadStateOverlay(state: model.state) }
@@ -42,7 +43,11 @@ struct RepoSettingsView: View {
42 loadedOnce = true 43 loadedOnce = true
43 } 44 }
44 } 45 }
45 .refreshable { await model.load() } 46 .task { await model.loadDeps() }
47 .refreshable {
48 await model.load()
49 await model.loadDeps()
50 }
46 } 51 }
47 52
48 // MARK: - Sections 53 // MARK: - Sections
@@ -206,6 +211,66 @@ struct RepoSettingsView: View {
206 } 211 }
207 } 212 }
208 213
214 private func depsSection() -> some View {
215 Section {
216 if let deps = model.deps {
217 SettingToggle(title: "Dependency checks", serverValue: deps.enabled) {
218 await model.setDepsEnabled($0)
219 }
220 .disabled(model.working)
221
222 if deps.enabled {
223 if let lastCheck = deps.lastCheck {
224 (Text("Last checked ") + Text(lastCheck, format: .relative(presentation: .named)))
225 .font(.gbSans(.caption))
226 .foregroundStyle(.secondary)
227 } else {
228 Text("Never checked")
229 .font(.gbSans(.caption))
230 .foregroundStyle(.secondary)
231 }
232 if let lastError = deps.lastError {
233 GBNotice(lastError, .gbWarn)
234 }
235 if let issueNumber = deps.issueNumber {
236 NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issueNumber)) {
237 Text("Tracking issue #\(issueNumber)")
238 }
239 }
240 if deps.behind.isEmpty {
241 if deps.lastCheck != nil {
242 Text("Everything is current.")
243 .font(.gbSans(.caption))
244 .foregroundStyle(.secondary)
245 }
246 } else {
247 ForEach(deps.behind) { dep in
248 VStack(alignment: .leading, spacing: 2) {
249 Text(dep.name)
250 .font(.gbSans(.subheadline))
251 Text("\(dep.ecosystem) · \(dep.current) → \(dep.latest)")
252 .font(.gbSans(.caption))
253 .foregroundStyle(.secondary)
254 }
255 }
256 }
257 }
258 } else if let depsError = model.depsError {
259 GBNotice(depsError, .gbWarn)
260 } else {
261 HStack {
262 Spacer()
263 ProgressView()
264 Spacer()
265 }
266 }
267 } header: {
268 Text("Dependencies")
269 } footer: {
270 Text("Checks tell a public registry what this repository depends on.")
271 }
272 }
273
209 private func toggle( 274 private func toggle(
210 _ title: String, 275 _ title: String,
211 _ value: Bool, 276 _ value: Bool,
gitbayTests/DepsTests.swift added +195
@@ -0,0 +1,195 @@
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 // The write must reload deps specifically, not the whole settings
116 // screen — otherwise this enqueued status stub goes unconsumed.
117 #expect(model.deps?.enabled == true)
118
119 let (client2, stub2) = try makeClient()
120 let model2 = RepoSettingsViewModel(client: client2, repoPath: "krz/gitbay")
121 stub2.enqueue(.init(status: 200, json: """
122 {"protocol_version":1,"exit_code":0}
123 """))
124 stub2.enqueue(.init(status: 200, json: fullStatus))
125 await model2.setDepsEnabled(false)
126 write = try #require(stub2.seen.first { $0.method == "POST" })
127 #expect(try argvOf(write) == ["repo", "deps", "disable", "krz/gitbay"])
128 #expect(model2.deps?.enabled == true)
129 }
130
131 /// "Could not ask" must not render as "checks are off".
132 @Test func aFailedStatusReadIsDistinguishableFromChecksBeingOff() async throws {
133 let (client, stub) = try makeClient()
134 stub.enqueue(.init(status: 200, json: """
135 {"protocol_version":1,"error":"denied","exit_code":4}
136 """))
137 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
138 await model.loadDeps()
139
140 #expect(model.deps == nil)
141 #expect(model.depsError?.isEmpty == false)
142 }
143
144 @Test func aRefusedWriteSurfacesAndLeavesTheStatusAlone() async throws {
145 let (client, stub) = try makeClient()
146 stub.enqueue(.init(status: 200, json: fullStatus))
147 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
148 await model.loadDeps()
149 #expect(model.deps?.enabled == true)
150
151 stub.enqueue(.init(status: 200, json: """
152 {"protocol_version":1,"error":"turning dependency checks on needs admin","exit_code":4}
153 """))
154 await model.setDepsEnabled(false)
155
156 #expect(model.actionError?.isEmpty == false)
157 // The write failed, so the last known status still says enabled.
158 #expect(model.deps?.enabled == true)
159 }
160
161 /// A successful load after a failed one must clear the error.
162 @Test func depsErrorClearsOnALaterSuccess() async throws {
163 let (client, stub) = try makeClient()
164 stub.enqueue(.init(status: 200, json: """
165 {"protocol_version":1,"error":"denied","exit_code":4}
166 """))
167 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
168 await model.loadDeps()
169 #expect(model.depsError != nil)
170
171 stub.enqueue(.init(status: 200, json: fullStatus))
172 await model.loadDeps()
173
174 #expect(model.depsError == nil)
175 #expect(model.deps?.enabled == true)
176 }
177
178 /// The converse of the above: a later failure must drop the stale
179 /// status, not leave it sitting alongside the new error.
180 @Test func depsClearsOnALaterFailureAfterASuccess() async throws {
181 let (client, stub) = try makeClient()
182 stub.enqueue(.init(status: 200, json: fullStatus))
183 let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay")
184 await model.loadDeps()
185 #expect(model.deps?.enabled == true)
186
187 stub.enqueue(.init(status: 200, json: """
188 {"protocol_version":1,"error":"denied","exit_code":4}
189 """))
190 await model.loadDeps()
191
192 #expect(model.deps == nil)
193 #expect(model.depsError?.isEmpty == false)
194 }
195}