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

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

195 lines · 10482 bytes

  1# MR 12: Render the repository state the server now reports
  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:** 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.
  6
  7**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.
  8
  9**No parity row moves.** All three rows are already `yes`; this improves what closed them.
 10
 11## What the server now reports
 12
 13From `internal/control/repo.go:288-299`, all three `omitempty`:
 14
 15```go
 16// ForkOf names the parent only when the caller can read it: a
 17// private parent is not confirmed to exist, here as anywhere.
 18ForkOf string `json:"fork_of,omitempty"`
 19// Watch and Bookmarked are the caller's own state, so a client
 20// can draw a toggle rather than two stateless buttons (#178).
 21Watch      string `json:"watch,omitempty"` // watching, muted, or absent
 22Bookmarked bool   `json:"bookmarked,omitempty"`
 23```
 24
 25Three consequences that shape the whole design:
 26
 271. **`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.
 282. **`bookmarked` is `omitempty` on a `Bool`, so absent means false.** Decoding must default it, not require it.
 293. **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".
 30
 31## Global Constraints
 32
 33- Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s.
 34- Swift Testing only — never XCTest.
 35- `gitbayTests` is hermetic and offline; network goes through `StubProtocol`.
 36- New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit.
 37- The label model is `IssueLabel`; the notification model is `InboxNotification`.
 38- Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer.
 39- Never commit to `main`.
 40- **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.
 41
 42Baseline: **365 total, 364 passed, 1 skipped, 0 failed.**
 43
 44---
 45
 46## File Structure
 47
 48| File | Responsibility |
 49|------|----------------|
 50| `gitbay/Repos/RepoModels.swift` (modify) | Decode `watch`, `bookmarked`, `fork_of` on `RepoDetail` |
 51| `gitbay/Repos/RepoActionsViewModel.swift` (modify) | Delete `loadBookmarkState`; take state from `RepoDetail` |
 52| `gitbayTests/RepoActionTests.swift` (modify) | Rewrite the bookmark-state tests |
 53| `gitbayTests/RepoStateTests.swift` (create) | Decoding and transition tests |
 54| `gitbay/Views/Repos/RepoView.swift` (modify) | The watch control, the bookmark toggle, the fork line |
 55
 56---
 57
 58### Task 1: Decode the state, and stop fetching it separately
 59
 60**Files:** `RepoModels.swift`, `RepoActionsViewModel.swift`; tests in `gitbayTests/RepoStateTests.swift` and `RepoActionTests.swift`
 61
 62**Interfaces produced:**
 63- `RepoDetail.watch: String?`, `.bookmarked: Bool`, `.forkOf: String?`
 64- `nonisolated enum WatchState: Sendable` — `.watching`, `.muted`, `.default`; `init(_ raw: String?)`; `var available: [WatchAction]` giving only the transitions that exist
 65- `RepoActionsViewModel` **loses** `isBookmarked` and `loadBookmarkState()`; the screen reads state from the loaded `RepoDetail` instead
 66
 67**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.
 68
 69Behaviour, each pinned by a test:
 701. `bookmarked` is `omitempty` on a Bool — **absent means false**, and must decode, not throw.
 712. `watch` absent means the default state, which is neither watching nor muted.
 723. `fork_of` absent means **either** not-a-fork **or** a parent you cannot read. The model exposes it as `String?` and says nothing more.
 734. `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.
 745. `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.
 75
 76- [ ] **Step 1: Write the failing tests**
 77
 78```swift
 79import Foundation
 80import Testing
 81@testable import gitbay
 82
 83struct RepoStateDecodingTests {
 84
 85    private func decode(_ json: String) throws -> RepoDetail {
 86        try JSONDecoder().decode(RepoDetail.self, from: Data(json.utf8))
 87    }
 88
 89    private let base = """
 90        "path":"krz/gitbay","visibility":"public","default_branch":"main"
 91        """
 92
 93    /// All three are omitempty. A repository you have expressed no
 94    /// opinion about, which is not a fork, carries none of them.
 95    @Test func aRepositoryWithNoStateDecodes() throws {
 96        let repo = try decode("{\(base)}")
 97        #expect(repo.watch == nil)
 98        #expect(repo.bookmarked == false)
 99        #expect(repo.forkOf == nil)
100    }
101
102    @Test func theCallersOwnStateDecodes() throws {
103        let repo = try decode("""
104            {\(base),"watch":"watching","bookmarked":true,"fork_of":"upstream/thing"}
105            """)
106        #expect(repo.watch == "watching")
107        #expect(repo.bookmarked)
108        #expect(repo.forkOf == "upstream/thing")
109    }
110
111    @Test func mutedDecodes() throws {
112        #expect(try decode("{\(base),\"watch\":\"muted\"}").watch == "muted")
113    }
114}
115
116struct WatchStateTests {
117
118    @Test func theThreeStatesMapFromTheWire() {
119        #expect(WatchState("watching") == .watching)
120        #expect(WatchState("muted") == .muted)
121        #expect(WatchState(nil) == .default)
122        // An unknown value the server might add later must not crash or
123        // masquerade as watching.
124        #expect(WatchState("something-new") == .default)
125    }
126
127    /// Only two transitions exist. Nothing returns you to the default,
128    /// so nothing may offer it.
129    @Test func onlyTheReachableTransitionsAreOffered() {
130        #expect(WatchState.watching.available == [.mute])
131        #expect(WatchState.muted.available == [.watch])
132        #expect(WatchState.default.available == [.watch, .mute])
133    }
134
135    @Test func noStateOffersAReturnToTheDefault() {
136        for state in [WatchState.watching, .muted, .default] {
137            #expect(state.available.allSatisfy { $0 == .watch || $0 == .mute })
138        }
139    }
140}
141```
142
143Add 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.
144
145- [ ] **Step 2: Run to verify failure.**
146- [ ] **Step 3: Implement, deleting `loadBookmarkState` and `isBookmarked` entirely.**
147- [ ] **Step 4: Run the tests.**
148- [ ] **Step 5: Commit** — `git commit -m "Take watch, bookmark and fork state from repo show"`
149
150---
151
152### Task 2: The controls
153
154**Files:** `gitbay/Views/Repos/RepoView.swift`
155
156No unit tests — UI.
157
158**The watch control** replaces `!53`'s two stateless buttons. Show the current state and offer only what `WatchState.available` gives:
159
160- Watching → "Watching" with a Mute action
161- Muted → "Muted" with a Watch action
162- Default → neither label claimed, both actions offered
163
164Do **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.
165
166**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.
167
168**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.
169
170- [ ] **Step 1: The watch control**
171- [ ] **Step 2: The bookmark toggle, with the old load path removed**
172- [ ] **Step 3: The fork line**
173- [ ] **Step 4: Build and run the full suite.**
174- [ ] **Step 5: Commit** — `git commit -m "Show watch, bookmark and fork state on the repository screen"`
175
176---
177
178### Task 3: Open the merge request
179
180No parity rows move — all three are already `yes`. **Do not touch the wiki.**
181
182- [ ] **Step 1:** Run the full suite via `xcresulttool`; record the real numbers.
183- [ ] **Step 2:** Open the merge request, noting it closes the gap `krz/gitbay#178` described and that the issue is already closed upstream.
184
185---
186
187## Notes for whoever executes this
188
189**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.
190
191**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.
192
193**`bookmarked` absent means false** — it is `omitempty` on a Bool.
194
195**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.