docs/superpowers/plans/2026-09-06-mr09-deps.md
311 lines · 15124 bytes
7 symbols in this file
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
MainActorisolation. Wire models arenonisolated structs. - Swift Testing only — never XCTest.
gitbayTestsis hermetic and offline; network goes throughStubProtocol.- New files under
gitbay/andgitbayTests/need noproject.pbxprojedit. - The label model is
IssueLabel; the notification model isInboxNotification. - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No
Co-Authored-Bytrailer. - Never commit to
main.
The commands, verbatim from the registry:
repo deps enable <owner/name>
repo deps disable <owner/name>
repo deps status <owner/name>
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:
{"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:
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;idunique per rowRepoSettingsViewModel.deps: DepsStatus?,.depsError: String?,func loadDeps() async,func setDepsEnabled(_ on: Bool) async
Behaviour, each pinned by a test:
- All three commands are three-word paths:
["repo","deps","status",repo],["repo","deps","enable",repo],["repo","deps","disable",repo]. setDepsEnabled(true)sendsenable,setDepsEnabled(false)sendsdisable— two commands, not a flag.last_check,last_errorandissue_numberare absent for a never-checked repository. Decoding must not require them;behindmay be present and empty.depsisDepsStatus?anddepsErroris separate.nildeps with no error means "not loaded yet";nildeps 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".- A refused write surfaces into the existing
actionErrorand does not flip the toggle's backing state. DepBehind.idis unique across ecosystems — two ecosystems can carry the same package name.
- Step 1: Write the failing tests
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.
setDepsEnabledroutes through the file's existingperform(argv:), then reloads the status so the report reflects the change.loadDepssets exactly one ofdepsordepsErrorper 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:
- 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. - When enabled, the report:
last_checkas a relative time, or "never checked" when absent — the ordinary state right after switching on.last_errorwhen present, as aGBNoticewarning.- A link to the tracking issue when
issue_numberis present, routing toIssueRoute.issue(repo:number:). The worker opens, rewrites and closes that issue, and it is the same copy every other surface reads. - The
behindrows: ecosystem, name, current → latest. An emptybehindwith alast_checkmeans everything is current — say so rather than showing an empty list.
- Three distinct states for the read itself: not loaded (spinner), failed (
depsErroras 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/offanddependency statustoyesfor 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.