docs/superpowers/plans/2026-09-07-mr12-repo-state.md

v1.4.0
gitbay-ios/docs/superpowers/plans/2026-09-07-mr12-repo-state.md rendered · source · history · blame · raw

195 lines · 10482 bytes

MR 12: Render the repository state the server now reports

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: Replace the stateless watch/mute buttons with a control that shows where you actually are, drop the extra request the bookmark state costs, and say when a repository is a fork.

Why now: krz/gitbay#178 — filed while building !53, because watch, bookmark and fork state were readable from no command — has been implemented upstream. repo show now carries all three. !53 shipped a weaker affordance purely because of that gap, and the gap is gone.

No parity row moves. All three rows are already yes; this improves what closed them.

What the server now reports

From internal/control/repo.go:288-299, all three omitempty:

// ForkOf names the parent only when the caller can read it: a
// private parent is not confirmed to exist, here as anywhere.
ForkOf string `json:"fork_of,omitempty"`
// Watch and Bookmarked are the caller's own state, so a client
// can draw a toggle rather than two stateless buttons (#178).
Watch      string `json:"watch,omitempty"` // watching, muted, or absent
Bookmarked bool   `json:"bookmarked,omitempty"`

Three consequences that shape the whole design:

  1. watch has three states, and only two are reachable. repo watch sets watching; repo unwatch sets muted (internal/control/notifications.go:177-181). No command returns you to the absent default. So the control must show the current state and offer only the transitions that exist — never a "Default" option with no command behind it.
  2. bookmarked is omitempty on a Bool, so absent means false. Decoding must default it, not require it.
  3. Absent fork_of does not mean "not a fork." The server withholds it when the caller cannot read the parent — a private parent is not confirmed to exist. So the UI may say "forked from X" when present, and must say nothing when absent. It must never say "not a fork".

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.
  • Test discipline: use the until(_:_:) helper in gitbayTests/StubProtocol.swift rather than fixed sleeps; assert on the request identified by its argv, not stub.seen.last; never index stub.seen directly, go through #require; provision a stub for every request each flow makes.

Baseline: 365 total, 364 passed, 1 skipped, 0 failed.


File Structure

File Responsibility
gitbay/Repos/RepoModels.swift (modify) Decode watch, bookmarked, fork_of on RepoDetail
gitbay/Repos/RepoActionsViewModel.swift (modify) Delete loadBookmarkState; take state from RepoDetail
gitbayTests/RepoActionTests.swift (modify) Rewrite the bookmark-state tests
gitbayTests/RepoStateTests.swift (create) Decoding and transition tests
gitbay/Views/Repos/RepoView.swift (modify) The watch control, the bookmark toggle, the fork line

Task 1: Decode the state, and stop fetching it separately

Files: RepoModels.swift, RepoActionsViewModel.swift; tests in gitbayTests/RepoStateTests.swift and RepoActionTests.swift

Interfaces produced:

  • RepoDetail.watch: String?, .bookmarked: Bool, .forkOf: String?
  • nonisolated enum WatchState: Sendable — .watching, .muted, .default; init(_ raw: String?); var available: [WatchAction] giving only the transitions that exist
  • RepoActionsViewModel loses isBookmarked and loadBookmarkState(); the screen reads state from the loaded RepoDetail instead

What to delete, and why. !53 added loadBookmarkState(), which calls repo bookmarks and tests membership — one extra read per repository screen, plus a .refreshable hook and a three-state Bool? to cover its failure. All of that existed only because the state was unreadable. repo show is already fetched by that screen, so the whole mechanism goes. Delete it; do not leave it dual-sourced.

Behaviour, each pinned by a test:

  1. bookmarked is omitempty on a Bool — absent means false, and must decode, not throw.
  2. watch absent means the default state, which is neither watching nor muted.
  3. fork_of absent means either not-a-fork or a parent you cannot read. The model exposes it as String? and says nothing more.
  4. WatchState.available returns [.mute] when watching, [.watch] when muted, and both when default. It never offers a transition to the default, because no command performs one.
  5. watch() and unwatch() still send repo watch / repo unwatch unchanged, and still reload so the new state is read back from the server rather than assumed.
  • Step 1: Write the failing tests
import Foundation
import Testing
@testable import gitbay

struct RepoStateDecodingTests {

    private func decode(_ json: String) throws -> RepoDetail {
        try JSONDecoder().decode(RepoDetail.self, from: Data(json.utf8))
    }

    private let base = """
        "path":"krz/gitbay","visibility":"public","default_branch":"main"
        """

    /// All three are omitempty. A repository you have expressed no
    /// opinion about, which is not a fork, carries none of them.
    @Test func aRepositoryWithNoStateDecodes() throws {
        let repo = try decode("{\(base)}")
        #expect(repo.watch == nil)
        #expect(repo.bookmarked == false)
        #expect(repo.forkOf == nil)
    }

    @Test func theCallersOwnStateDecodes() throws {
        let repo = try decode("""
            {\(base),"watch":"watching","bookmarked":true,"fork_of":"upstream/thing"}
            """)
        #expect(repo.watch == "watching")
        #expect(repo.bookmarked)
        #expect(repo.forkOf == "upstream/thing")
    }

    @Test func mutedDecodes() throws {
        #expect(try decode("{\(base),\"watch\":\"muted\"}").watch == "muted")
    }
}

struct WatchStateTests {

    @Test func theThreeStatesMapFromTheWire() {
        #expect(WatchState("watching") == .watching)
        #expect(WatchState("muted") == .muted)
        #expect(WatchState(nil) == .default)
        // An unknown value the server might add later must not crash or
        // masquerade as watching.
        #expect(WatchState("something-new") == .default)
    }

    /// Only two transitions exist. Nothing returns you to the default,
    /// so nothing may offer it.
    @Test func onlyTheReachableTransitionsAreOffered() {
        #expect(WatchState.watching.available == [.mute])
        #expect(WatchState.muted.available == [.watch])
        #expect(WatchState.default.available == [.watch, .mute])
    }

    @Test func noStateOffersAReturnToTheDefault() {
        for state in [WatchState.watching, .muted, .default] {
            #expect(state.available.allSatisfy { $0 == .watch || $0 == .mute })
        }
    }
}

Add view-model tests confirming watch()/unwatch() still send their commands and reload, and rewrite RepoActionTests's bookmark tests: the ones covering loadBookmarkState and the repo bookmarks argv go, since that path no longer exists. setBookmarked still sends repo bookmark/repo unbookmark and must keep its tests.

  • Step 2: Run to verify failure.
  • Step 3: Implement, deleting loadBookmarkState and isBookmarked entirely.
  • Step 4: Run the tests.
  • Step 5: Commit — git commit -m "Take watch, bookmark and fork state from repo show"

Task 2: The controls

Files: gitbay/Views/Repos/RepoView.swift

No unit tests — UI.

The watch control replaces !53's two stateless buttons. Show the current state and offer only what WatchState.available gives:

  • Watching → "Watching" with a Mute action
  • Muted → "Muted" with a Watch action
  • Default → neither label claimed, both actions offered

Do not render a three-segment picker: selecting the default segment would have no command behind it. And drop !53's caption saying the current setting is not shown — it is now.

The bookmark toggle reads RepoDetail.bookmarked directly. Remove the .task and .refreshable hooks that fed loadBookmarkState, and the "Checking Bookmark…" state, which existed only for a read that no longer happens. setBookmarked still reloads, so the toggle follows the server.

The fork line: when forkOf is present, show "Forked from <path>", linking to RepoRoute.repo(forkOf). When absent, show nothing — absence means either not-a-fork or a parent you cannot see, and the screen must not claim the former.

  • Step 1: The watch control
  • Step 2: The bookmark toggle, with the old load path removed
  • Step 3: The fork line
  • Step 4: Build and run the full suite.
  • Step 5: Commit — git commit -m "Show watch, bookmark and fork state on the repository screen"

Task 3: Open the merge request

No parity rows move — all three are already yes. Do not touch the wiki.

  • Step 1: Run the full suite via xcresulttool; record the real numbers.
  • Step 2: Open the merge request, noting it closes the gap krz/gitbay#178 described and that the issue is already closed upstream.

Notes for whoever executes this

No command returns you to the neutral watch state. Only repo watch and repo unwatch exist. Any control offering a third option would be offering something the server cannot do.

Absent fork_of is not "not a fork." The server withholds it when you cannot read the parent. Say nothing rather than saying the wrong thing.

bookmarked absent means false — it is omitempty on a Bool.

Delete the old path, do not dual-source it. loadBookmarkState and isBookmarked existed only because the state was unreadable. Leaving both would mean two answers to one question.