# MR 9: Dependency checks Implementation Plan > **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. **Goal:** Close two parity rows — `dependency checks on/off` and `dependency status`. **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. **Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md` ## Global Constraints - Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s. - Swift Testing only — never XCTest. - `gitbayTests` is hermetic and offline; network goes through `StubProtocol`. - New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit. - The label model is `IssueLabel`; the notification model is `InboxNotification`. - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer. - Never commit to `main`. **The commands, verbatim from the registry:** ``` repo deps enable repo deps disable repo deps status ``` All 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`. **The status shape**, from `internal/control/deps.go:32-44` and confirmed against the live instance: ```json {"enabled":true,"last_check":"2026-09-06T09:47:07.905Z","issue_number":140, "behind":[{"ecosystem":"go","name":"github.com/yuin/goldmark", "current":"v1.8.5","latest":"v1.8.6"}]} ``` `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. **Counting tests — use `xcresulttool`, not grep:** ```bash RES=$(ls -td ~/Library/Developer/Xcode/DerivedData/gitbay-*/Logs/Test/*.xcresult | head -1) xcrun xcresulttool get test-results summary --path "$RES" ``` Baseline: **321 total, 320 passed, 1 skipped, 0 failed.** --- ## File Structure | File | Responsibility | |------|----------------| | `gitbay/Repos/DepsStatus.swift` (create) | `DepsStatus` and `DepBehind` wire models | | `gitbay/Repos/RepoSettingsViewModel.swift` (modify) | `deps`, `depsError`, `loadDeps()`, `setDepsEnabled(_:)` | | `gitbayTests/DepsTests.swift` (create) | Every test in this plan | | `gitbay/Views/Repos/RepoSettingsView.swift` (modify) | The toggle and the report | --- ### Task 1: The model and the two actions **Files:** Create `gitbay/Repos/DepsStatus.swift`; modify `gitbay/Repos/RepoSettingsViewModel.swift`; test in `gitbayTests/DepsTests.swift` **Interfaces produced:** - `nonisolated struct DepsStatus: Decodable, Sendable, Hashable` — `enabled: Bool`, `lastCheck: Date?`, `lastError: String?`, `issueNumber: Int64?`, `behind: [DepBehind]` - `nonisolated struct DepBehind: Decodable, Sendable, Hashable, Identifiable` — `ecosystem: String`, `name: String`, `current: String`, `latest: String`; `id` unique per row - `RepoSettingsViewModel.deps: DepsStatus?`, `.depsError: String?`, `func loadDeps() async`, `func setDepsEnabled(_ on: Bool) async` Behaviour, each pinned by a test: 1. All three commands are **three-word paths**: `["repo","deps","status",repo]`, `["repo","deps","enable",repo]`, `["repo","deps","disable",repo]`. 2. `setDepsEnabled(true)` sends `enable`, `setDepsEnabled(false)` sends `disable` — two commands, not a flag. 3. `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. 4. **`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". 5. A refused write surfaces into the existing `actionError` and does not flip the toggle's backing state. 6. `DepBehind.id` is unique across ecosystems — two ecosystems can carry the same package name. - [ ] **Step 1: Write the failing tests** ```swift import Foundation import Testing @testable import gitbay private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { let box = StubProtocol.box() let client = GitbayClient( instance: try GitbayInstance(url: "https://gitbay.org"), token: "test-token", session: box.session() ) return (client, box) } private func argvFrom(_ url: URL) -> [String] { URLComponents(url: url, resolvingAgainstBaseURL: false)? .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] } private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) return try #require(body["argv"] as? [String]) } private let fullStatus = """ {"protocol_version":1,"data":{"enabled":true,\ "last_check":"2026-09-06T09:47:07.905Z","issue_number":140,\ "behind":[{"ecosystem":"go","name":"github.com/yuin/goldmark",\ "current":"v1.8.5","latest":"v1.8.6"}]},"exit_code":0} """ struct DepsDecodingTests { private func decode(_ json: String) throws -> DepsStatus { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return try decoder.decode(DepsStatus.self, from: Data(json.utf8)) } /// A repository whose checks were just switched on has never been /// checked, so it carries none of the three omitempty fields. That is /// the ordinary case, not an edge case. @Test func aNeverCheckedRepositoryHasNoTimestampErrorOrIssue() throws { let status = try decode(""" {"enabled":true,"behind":[]} """) #expect(status.enabled) #expect(status.lastCheck == nil) #expect(status.lastError == nil) #expect(status.issueNumber == nil) #expect(status.behind.isEmpty) } @Test func aCheckedRepositoryDecodesEverything() throws { let status = try decode(""" {"enabled":true,"last_check":"2026-09-06T09:47:07Z","issue_number":140,\ "behind":[{"ecosystem":"go","name":"modernc.org/sqlite",\ "current":"v1.57.0","latest":"v1.58.0"}]} """) #expect(status.lastCheck != nil) #expect(status.issueNumber == 140) #expect(status.behind.first?.name == "modernc.org/sqlite") #expect(status.behind.first?.latest == "v1.58.0") } @Test func aFailedCheckCarriesItsError() throws { let status = try decode(""" {"enabled":true,"last_error":"could not read go.mod","behind":[]} """) #expect(status.lastError == "could not read go.mod") } /// Two ecosystems can carry the same package name, so the id must /// include the ecosystem or SwiftUI reuses one row for the other. @Test func behindRowsAreUniqueAcrossEcosystems() throws { let status = try decode(""" {"enabled":true,"behind":[\ {"ecosystem":"go","name":"x","current":"1","latest":"2"},\ {"ecosystem":"npm","name":"x","current":"1","latest":"2"}]} """) let ids = status.behind.map(\.id) #expect(Set(ids).count == 2) } } @MainActor struct DepsActionTests { /// All three are THREE-word paths. The app sends two-word `repo` /// commands everywhere else, so this is easy to get wrong. @Test func statusUsesTheThreeWordPath() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: fullStatus)) let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") await model.loadDeps() #expect(argvFrom(try #require(stub.seen.last).url) == ["repo", "deps", "status", "krz/gitbay"]) #expect(model.deps?.enabled == true) #expect(model.deps?.behind.count == 1) } @Test func enableAndDisableAreDifferentCommands() async throws { let (client, stub) = try makeClient() let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub.enqueue(.init(status: 200, json: fullStatus)) await model.setDepsEnabled(true) var write = try #require(stub.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["repo", "deps", "enable", "krz/gitbay"]) let (client2, stub2) = try makeClient() let model2 = RepoSettingsViewModel(client: client2, repoPath: "krz/gitbay") stub2.enqueue(.init(status: 200, json: """ {"protocol_version":1,"exit_code":0} """)) stub2.enqueue(.init(status: 200, json: fullStatus)) await model2.setDepsEnabled(false) write = try #require(stub2.seen.first { $0.method == "POST" }) #expect(try argvOf(write) == ["repo", "deps", "disable", "krz/gitbay"]) } /// "Could not ask" must not render as "checks are off". @Test func aFailedStatusReadIsDistinguishableFromChecksBeingOff() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"denied","exit_code":4} """)) let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") await model.loadDeps() #expect(model.deps == nil) #expect(model.depsError?.isEmpty == false) } @Test func aRefusedWriteSurfacesAndLeavesTheStatusAlone() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: fullStatus)) let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") await model.loadDeps() #expect(model.deps?.enabled == true) stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"turning dependency checks on needs admin","exit_code":4} """)) await model.setDepsEnabled(false) #expect(model.actionError?.isEmpty == false) // The write failed, so the last known status still says enabled. #expect(model.deps?.enabled == true) } /// A successful load after a failed one must clear the error. @Test func depsErrorClearsOnALaterSuccess() async throws { let (client, stub) = try makeClient() stub.enqueue(.init(status: 200, json: """ {"protocol_version":1,"error":"denied","exit_code":4} """)) let model = RepoSettingsViewModel(client: client, repoPath: "krz/gitbay") await model.loadDeps() #expect(model.depsError != nil) stub.enqueue(.init(status: 200, json: fullStatus)) await model.loadDeps() #expect(model.depsError == nil) #expect(model.deps?.enabled == true) } } ``` **Check `RepoSettingsViewModel`'s real initialiser before writing these** and match it; do not change it to suit a test. - [ ] **Step 2: Run to verify failure.** - [ ] **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. - [ ] **Step 4: Run the tests.** - [ ] **Step 5: Commit** — `git commit -m "Dependency check status and toggle"` --- ### Task 2: The toggle and the report in settings **Files:** Modify `gitbay/Views/Repos/RepoSettingsView.swift` No unit tests — UI. Add 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`. The section shows, in this order: 1. **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. 2. **When enabled**, the report: - `last_check` as a relative time, or "never checked" when absent — the ordinary state right after switching on. - `last_error` when present, as a `GBNotice` warning. - 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. - 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. 3. **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. Load with `.task`, and reuse the screen's existing notice mechanism for `actionError`. - [ ] **Step 1: The section with its toggle and caption** - [ ] **Step 2: The report, including the never-checked and everything-current cases** - [ ] **Step 3: The three read states** - [ ] **Step 4: Build and run the full suite.** No drop from 320 passed. - [ ] **Step 5: Commit** — `git commit -m "Dependency section in repository settings"` --- ### Task 3: Flip two parity rows and open the merge request - [ ] **Step 1:** Set `dependency checks on/off` and `dependency status` to `yes` for iOS. Touch no other row. Land 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.** - [ ] **Step 2:** Run the full suite via `xcresulttool`; record the real numbers. - [ ] **Step 3:** Open the merge request. --- ## Notes for whoever executes this **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. **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. **"Could not ask" is not "checks are off".** Keep `deps` and `depsError` separate and render three states. This codebase has collapsed that distinction repeatedly.