docs/superpowers/plans/2026-09-06-mr09-deps.md

v1.13.0
gitbay-ios/docs/superpowers/plans/2026-09-06-mr09-deps.md rendered · source · history · blame · raw

311 lines · 15124 bytes

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 structs.
  • 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  <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; 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
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.