Commit 59aa1b9a8f
Unsigned
Layout: unified · split
docs/superpowers/plans/2026-09-06-mr07-repo-actions.md added +300
| @@ -0,0 +1,300 @@ | ||
| 1 | # MR 7: Repository actions — fork, watch/mute, bookmark Implementation Plan | |
| 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:** Close three parity rows — `fork`, `watch, mute`, and `bookmark, bookmark list`. | |
| 6 | ||
| 7 | **Architecture:** Three repository-level actions on one screen, all thin writes over existing commands. The complication is that the server reports back almost none of the resulting state, so each of the three gets a different treatment depending on what is actually readable. | |
| 8 | ||
| 9 | **Spec:** `docs/superpowers/specs/2026-09-06-ios-parity-design.md` | |
| 10 | ||
| 11 | ## What the server does and does not report | |
| 12 | ||
| 13 | This shaped every decision below, and it is worth stating plainly because it is not what the spec assumed. | |
| 14 | ||
| 15 | | State | Readable? | Consequence | | |
| 16 | |-------|-----------|-------------| | |
| 17 | | Watch / mute | **No.** `store.RepoWatchState` exists but is internal; no command exposes it | Two stateless actions, not a toggle | | |
| 18 | | Bookmarked | **Indirectly** — `repo bookmarks` lists them, membership is derivable | A real toggle, at the cost of one list read | | |
| 19 | | `fork_of` | **No** on `repo show`; only on `repo fork`'s own response (`internal/control/mr.go:94`) | Cannot show "forked from X" for an existing repository | | |
| 20 | ||
| 21 | Filed upstream as **krz/gitbay#178**. Do not work around any of it by inventing local persistence — the app renders server state; it does not remember its own. | |
| 22 | ||
| 23 | ## Global Constraints | |
| 24 | ||
| 25 | - Swift 6 language mode, default `MainActor` isolation. Wire models are `nonisolated struct`s. | |
| 26 | - Swift Testing only — never XCTest. | |
| 27 | - `gitbayTests` is hermetic and offline; network goes through `StubProtocol`. | |
| 28 | - New files under `gitbay/` and `gitbayTests/` need **no** `project.pbxproj` edit. | |
| 29 | - The label model is `IssueLabel`; the notification model is `InboxNotification`. Neither shadows a framework type, and neither should be renamed back. | |
| 30 | - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No `Co-Authored-By` trailer. | |
| 31 | - Never commit to `main`. | |
| 32 | ||
| 33 | **The commands, verbatim from the registry:** | |
| 34 | ||
| 35 | ``` | |
| 36 | repo fork <owner/name> [--name <n>] | |
| 37 | repo watch <owner/name> | |
| 38 | repo unwatch <owner/name> | |
| 39 | repo bookmark <owner/name> | |
| 40 | repo unbookmark <owner/name> | |
| 41 | repo bookmarks | |
| 42 | ``` | |
| 43 | ||
| 44 | Note `repo bookmarks` — **plural, and it takes no argument**. It is not `repo bookmark list`. | |
| 45 | ||
| 46 | `repo fork` returns `{"path": "<new owner/name>", "fork_of": "<source>"}`. | |
| 47 | ||
| 48 | **Counting tests:** | |
| 49 | ||
| 50 | ```bash | |
| 51 | grep -cE "^Test case '[^']*' passed" /tmp/out.txt | |
| 52 | grep -cE "^Test case '[^']*' failed" /tmp/out.txt | |
| 53 | grep -cE "^Test case '[^']*' skipped" /tmp/out.txt | |
| 54 | ``` | |
| 55 | ||
| 56 | Baseline: **297 passed, 1 skipped, 0 failed.** | |
| 57 | ||
| 58 | --- | |
| 59 | ||
| 60 | ## File Structure | |
| 61 | ||
| 62 | | File | Responsibility | | |
| 63 | |------|----------------| | |
| 64 | | `gitbay/Repos/RepoActionsViewModel.swift` (create) | `ForkResult` model, bookmark state, and the five actions | | |
| 65 | | `gitbayTests/RepoActionTests.swift` (create) | Every test in this plan | | |
| 66 | | `gitbay/Views/Repos/RepoView.swift` (modify) | The actions, in the repository's menu | | |
| 67 | ||
| 68 | --- | |
| 69 | ||
| 70 | ### Task 1: The actions and the bookmark state | |
| 71 | ||
| 72 | **Files:** Create `gitbay/Repos/RepoActionsViewModel.swift`; test in `gitbayTests/RepoActionTests.swift` | |
| 73 | ||
| 74 | **Interfaces produced:** | |
| 75 | - `nonisolated struct ForkResult: Decodable, Sendable, Hashable` — `path: String`, `forkOf: String` | |
| 76 | - `@Observable @MainActor final class RepoActionsViewModel` — `isBookmarked: Bool?`, `actionError: String?`, `notice: String?`, `working: Bool`, `func loadBookmarkState() async`, `func setBookmarked(_:) async`, `func watch() async`, `func unwatch() async`, `func fork(named:) async -> ForkResult?` | |
| 77 | ||
| 78 | Behaviour, each pinned by a test: | |
| 79 | 1. `repo bookmarks` takes **no repository argument** — argv is exactly `["repo", "bookmarks"]`. Passing the path would be a usage error. | |
| 80 | 2. `isBookmarked` is `Bool?`: `nil` until known. It is derived by testing membership of the `repo bookmarks` result, and a failed load leaves it `nil` rather than `false` — "we don't know" and "not bookmarked" are different, and the UI must not claim the latter when it means the former. This is the same three-state lesson MRs 4 and 5 each had to learn. | |
| 81 | 3. `setBookmarked(true)` sends `repo bookmark`; `setBookmarked(false)` sends `repo unbookmark`. Two commands, not a flag. | |
| 82 | 4. `watch()` and `unwatch()` send their commands and report the result. **They do not attempt to track state** — nothing reports it back, and inventing a local guess would show the user something the server never said. | |
| 83 | 5. `fork(named:)` sends `["repo", "fork", repoPath]`, appending `--name <n>` only when a name is given, and returns the decoded `ForkResult` so the caller can navigate to the new repository. | |
| 84 | 6. A refusal surfaces into `actionError` and does not clear a prior success `notice` misleadingly. | |
| 85 | ||
| 86 | - [ ] **Step 1: Write the failing tests** | |
| 87 | ||
| 88 | ```swift | |
| 89 | import Foundation | |
| 90 | import Testing | |
| 91 | @testable import gitbay | |
| 92 | ||
| 93 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 94 | let box = StubProtocol.box() | |
| 95 | let client = GitbayClient( | |
| 96 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 97 | token: "test-token", | |
| 98 | session: box.session() | |
| 99 | ) | |
| 100 | return (client, box) | |
| 101 | } | |
| 102 | ||
| 103 | private func argvFrom(_ url: URL) -> [String] { | |
| 104 | URLComponents(url: url, resolvingAgainstBaseURL: false)? | |
| 105 | .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] | |
| 106 | } | |
| 107 | ||
| 108 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | |
| 109 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | |
| 110 | return try #require(body["argv"] as? [String]) | |
| 111 | } | |
| 112 | ||
| 113 | private let bookmarks = """ | |
| 114 | {"protocol_version":1,"data":[{"path":"krz/gitbay","visibility":"public"},\ | |
| 115 | {"path":"krz/solar","visibility":"public"}],"exit_code":0} | |
| 116 | """ | |
| 117 | private let ok = """ | |
| 118 | {"protocol_version":1,"exit_code":0} | |
| 119 | """ | |
| 120 | ||
| 121 | @MainActor | |
| 122 | struct RepoActionsTests { | |
| 123 | ||
| 124 | /// `repo bookmarks` is plural and takes NO repository argument. | |
| 125 | @Test func theBookmarkListingTakesNoArgument() async throws { | |
| 126 | let (client, stub) = try makeClient() | |
| 127 | stub.enqueue(.init(status: 200, json: bookmarks)) | |
| 128 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 129 | await model.loadBookmarkState() | |
| 130 | ||
| 131 | #expect(argvFrom(try #require(stub.seen.last).url) == ["repo", "bookmarks"]) | |
| 132 | #expect(model.isBookmarked == true) | |
| 133 | } | |
| 134 | ||
| 135 | @Test func aRepoAbsentFromTheListingIsNotBookmarked() async throws { | |
| 136 | let (client, stub) = try makeClient() | |
| 137 | stub.enqueue(.init(status: 200, json: bookmarks)) | |
| 138 | let model = RepoActionsViewModel(client: client, repoPath: "krz/other") | |
| 139 | await model.loadBookmarkState() | |
| 140 | ||
| 141 | #expect(model.isBookmarked == false) | |
| 142 | } | |
| 143 | ||
| 144 | /// "We don't know" is not "not bookmarked". A failed load must leave | |
| 145 | /// the state unknown rather than claiming the repository is not | |
| 146 | /// bookmarked, which would draw the wrong control. | |
| 147 | @Test func aFailedListingLeavesTheStateUnknown() async throws { | |
| 148 | let (client, stub) = try makeClient() | |
| 149 | stub.enqueue(.init(status: 200, json: """ | |
| 150 | {"protocol_version":1,"error":"denied","exit_code":4} | |
| 151 | """)) | |
| 152 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 153 | await model.loadBookmarkState() | |
| 154 | ||
| 155 | #expect(model.isBookmarked == nil) | |
| 156 | } | |
| 157 | ||
| 158 | @Test func bookmarkingAndUnbookmarkingAreDifferentCommands() async throws { | |
| 159 | let (client, stub) = try makeClient() | |
| 160 | stub.enqueue(.init(status: 200, json: bookmarks)) | |
| 161 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 162 | await model.loadBookmarkState() | |
| 163 | ||
| 164 | stub.enqueue(.init(status: 200, json: ok)) | |
| 165 | stub.enqueue(.init(status: 200, json: bookmarks)) | |
| 166 | await model.setBookmarked(false) | |
| 167 | var write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 168 | #expect(try argvOf(write) == ["repo", "unbookmark", "krz/gitbay"]) | |
| 169 | ||
| 170 | let (client2, stub2) = try makeClient() | |
| 171 | stub2.enqueue(.init(status: 200, json: bookmarks)) | |
| 172 | let model2 = RepoActionsViewModel(client: client2, repoPath: "krz/other") | |
| 173 | await model2.loadBookmarkState() | |
| 174 | stub2.enqueue(.init(status: 200, json: ok)) | |
| 175 | stub2.enqueue(.init(status: 200, json: bookmarks)) | |
| 176 | await model2.setBookmarked(true) | |
| 177 | write = try #require(stub2.seen.first { $0.method == "POST" }) | |
| 178 | #expect(try argvOf(write) == ["repo", "bookmark", "krz/other"]) | |
| 179 | } | |
| 180 | ||
| 181 | @Test func watchAndUnwatchSendTheirOwnCommands() async throws { | |
| 182 | let (client, stub) = try makeClient() | |
| 183 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 184 | ||
| 185 | stub.enqueue(.init(status: 200, json: ok)) | |
| 186 | await model.watch() | |
| 187 | #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "watch", "krz/gitbay"]) | |
| 188 | ||
| 189 | stub.enqueue(.init(status: 200, json: ok)) | |
| 190 | await model.unwatch() | |
| 191 | #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "unwatch", "krz/gitbay"]) | |
| 192 | } | |
| 193 | ||
| 194 | @Test func forkingReturnsTheNewPath() async throws { | |
| 195 | let (client, stub) = try makeClient() | |
| 196 | stub.enqueue(.init(status: 200, json: """ | |
| 197 | {"protocol_version":1,"data":{"path":"cmc/gitbay","fork_of":"krz/gitbay"},\ | |
| 198 | "exit_code":0} | |
| 199 | """)) | |
| 200 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 201 | let result = await model.fork(named: nil) | |
| 202 | ||
| 203 | #expect(result?.path == "cmc/gitbay") | |
| 204 | #expect(result?.forkOf == "krz/gitbay") | |
| 205 | #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "fork", "krz/gitbay"]) | |
| 206 | } | |
| 207 | ||
| 208 | @Test func forkingWithANameAppendsTheFlag() async throws { | |
| 209 | let (client, stub) = try makeClient() | |
| 210 | stub.enqueue(.init(status: 200, json: """ | |
| 211 | {"protocol_version":1,"data":{"path":"cmc/mine","fork_of":"krz/gitbay"},\ | |
| 212 | "exit_code":0} | |
| 213 | """)) | |
| 214 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 215 | _ = await model.fork(named: "mine") | |
| 216 | ||
| 217 | #expect(try argvOf(try #require(stub.seen.last)) | |
| 218 | == ["repo", "fork", "krz/gitbay", "--name", "mine"]) | |
| 219 | } | |
| 220 | ||
| 221 | /// A blank name must not send an empty flag value. | |
| 222 | @Test func forkingWithABlankNameOmitsTheFlag() async throws { | |
| 223 | let (client, stub) = try makeClient() | |
| 224 | stub.enqueue(.init(status: 200, json: """ | |
| 225 | {"protocol_version":1,"data":{"path":"cmc/gitbay","fork_of":"krz/gitbay"},\ | |
| 226 | "exit_code":0} | |
| 227 | """)) | |
| 228 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 229 | _ = await model.fork(named: " ") | |
| 230 | ||
| 231 | #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "fork", "krz/gitbay"]) | |
| 232 | } | |
| 233 | ||
| 234 | @Test func aRefusedForkReturnsNilAndSurfaces() async throws { | |
| 235 | let (client, stub) = try makeClient() | |
| 236 | stub.enqueue(.init(status: 200, json: """ | |
| 237 | {"protocol_version":1,"error":"a repository named gitbay already exists",\ | |
| 238 | "exit_code":1} | |
| 239 | """)) | |
| 240 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 241 | let result = await model.fork(named: nil) | |
| 242 | ||
| 243 | #expect(result == nil) | |
| 244 | #expect(model.actionError?.isEmpty == false) | |
| 245 | #expect(model.working == false) | |
| 246 | } | |
| 247 | } | |
| 248 | ``` | |
| 249 | ||
| 250 | - [ ] **Step 2: Run to verify failure.** | |
| 251 | - [ ] **Step 3: Implement.** Copy `perform(argv:)` from `RepoSettingsViewModel` for the void writes; `fork` needs `client.run(_:as:)` since it decodes a result. Reuse `RepoSummary` for the bookmarks listing — I checked the live response, `{path, description, visibility, bookmarks}`, and `RepoSummary` decodes it directly (the extra `bookmarks` count is ignored, as Swift's `Decodable` ignores unknown keys). Do not write a new model. | |
| 252 | - [ ] **Step 4: Run the tests.** | |
| 253 | - [ ] **Step 5: Commit** — `git commit -m "Fork, watch, mute and bookmark a repository"` | |
| 254 | ||
| 255 | --- | |
| 256 | ||
| 257 | ### Task 2: The actions on the repository screen | |
| 258 | ||
| 259 | **Files:** Modify `gitbay/Views/Repos/RepoView.swift` | |
| 260 | ||
| 261 | No unit tests — UI. | |
| 262 | ||
| 263 | Put all five in the repository screen's toolbar menu, beside the existing pin and archive actions. Study how those are presented and match them. | |
| 264 | ||
| 265 | **Each of the three rows gets the treatment its readable state allows:** | |
| 266 | ||
| 267 | - **Bookmark** — a real toggle, because the state is derivable. While `isBookmarked` is `nil` the control must not claim either state: show it disabled or as a spinner, never as "not bookmarked". Load the state with `.task`. | |
| 268 | - **Watch / Mute** — **two separate actions, not a toggle.** Nothing reports which one is current, so a toggle would be lying. Say what each does rather than implying a state: "Watch this repository" and "Mute this repository". A brief caption noting that the current setting is not shown is honest and cheap; krz/gitbay#178 tracks fixing it properly. | |
| 269 | - **Fork** — an action that prompts for an optional name, then navigates to the new repository on success using `RepoRoute.repo(result.path)`. On refusal, surface the server's message: "already exists" is the common one and the user needs to read it. | |
| 270 | ||
| 271 | `RepoView` already surfaces `actionError`-style messages for pin and archive — reuse that, do not add a second notice mechanism. | |
| 272 | ||
| 273 | - [ ] **Step 1: Bookmark toggle with its unknown state** | |
| 274 | - [ ] **Step 2: Watch and Mute as two actions** | |
| 275 | - [ ] **Step 3: Fork with an optional name and navigation on success** | |
| 276 | - [ ] **Step 4: Build and run the full suite.** No drop from 297. | |
| 277 | - [ ] **Step 5: Commit** — `git commit -m "Repository actions in the repo menu"` | |
| 278 | ||
| 279 | --- | |
| 280 | ||
| 281 | ### Task 3: Flip three parity rows and open the merge request | |
| 282 | ||
| 283 | - [ ] **Step 1:** In the Repositories table set `fork`, `watch, mute` and `bookmark, bookmark list` to `yes` for iOS. Touch no other row. | |
| 284 | ||
| 285 | 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 output rather than assuming it.** | |
| 286 | ||
| 287 | - [ ] **Step 2:** Run the full suite; record the real number. | |
| 288 | - [ ] **Step 3:** Open the merge request, noting that watch and mute ship stateless and why. | |
| 289 | ||
| 290 | --- | |
| 291 | ||
| 292 | ## Notes for whoever executes this | |
| 293 | ||
| 294 | **`repo bookmarks` is plural and takes no argument.** Not `repo bookmark list`, and not `repo bookmarks <owner/name>`. | |
| 295 | ||
| 296 | **Do not invent state the server does not report.** Watch and mute have no readable state; a toggle would show the user something the server never said. Two actions is the honest rendering, and krz/gitbay#178 tracks the fix. | |
| 297 | ||
| 298 | **`nil` is not `false` for the bookmark state.** A failed listing means unknown, and drawing "not bookmarked" would be a claim the app cannot support. | |
| 299 | ||
| 300 | **`fork_of` is not on `repo show`.** Do not try to display "forked from X" for an existing repository; it is only on `repo fork`'s own response, which is why the fork action returns it. | |
gitbay/Repos/RepoActionsViewModel.swift added +121
| @@ -0,0 +1,121 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// Fork, watch, mute and bookmark — the repository-level actions the | |
| 5 | /// server exposes but does not describe fully back: | |
| 6 | /// | |
| 7 | /// - Watch/mute state is reported by no command at all (krz/gitbay#178), | |
| 8 | /// so `watch()`/`unwatch()` only send their command and report the | |
| 9 | /// result; they never track or infer a current state. | |
| 10 | /// - Bookmark state has to be derived by listing `repo bookmarks` and | |
| 11 | /// testing membership — there is no per-repo "is this bookmarked" call. | |
| 12 | /// - `fork_of` is only on `repo fork`'s own response, never on `repo | |
| 13 | /// show`, which is why `fork` hands its result back to the caller. | |
| 14 | @Observable | |
| 15 | @MainActor | |
| 16 | final class RepoActionsViewModel { | |
| 17 | ||
| 18 | /// `repo fork`'s result — the only place `fork_of` is reported. | |
| 19 | nonisolated struct ForkResult: Decodable, Sendable, Hashable { | |
| 20 | let path: String | |
| 21 | let forkOf: String | |
| 22 | ||
| 23 | enum CodingKeys: String, CodingKey { | |
| 24 | case path | |
| 25 | case forkOf = "fork_of" | |
| 26 | } | |
| 27 | } | |
| 28 | ||
| 29 | /// `nil` until known or after a failed load. Never `false` for "could | |
| 30 | /// not check" — that would draw the wrong control. | |
| 31 | private(set) var isBookmarked: Bool? | |
| 32 | private(set) var actionError: String? | |
| 33 | private(set) var notice: String? | |
| 34 | private(set) var working = false | |
| 35 | ||
| 36 | private let client: GitbayClient | |
| 37 | let repoPath: String | |
| 38 | ||
| 39 | init(client: GitbayClient, repoPath: String) { | |
| 40 | self.client = client | |
| 41 | self.repoPath = repoPath | |
| 42 | } | |
| 43 | ||
| 44 | // MARK: - Bookmarks | |
| 45 | ||
| 46 | /// `repo bookmarks` — plural, no repository argument. Membership in | |
| 47 | /// the listing is the only signal the server gives for this repo. | |
| 48 | func loadBookmarkState() async { | |
| 49 | do { | |
| 50 | let bookmarks = try await client.readList(["repo", "bookmarks"], of: RepoSummary.self) | |
| 51 | isBookmarked = bookmarks.contains { $0.path == repoPath } | |
| 52 | } catch { | |
| 53 | // "We don't know" is not "not bookmarked". | |
| 54 | isBookmarked = nil | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | func setBookmarked(_ bookmarked: Bool) async { | |
| 59 | await perform(["repo", bookmarked ? "bookmark" : "unbookmark", repoPath]) | |
| 60 | await loadBookmarkState() | |
| 61 | } | |
| 62 | ||
| 63 | // MARK: - Watch / mute | |
| 64 | ||
| 65 | func watch() async { | |
| 66 | await perform(["repo", "watch", repoPath]) | |
| 67 | if actionError == nil { | |
| 68 | notice = "You are now watching this repository." | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | func unwatch() async { | |
| 73 | await perform(["repo", "unwatch", repoPath]) | |
| 74 | if actionError == nil { | |
| 75 | notice = "Notifications for this repository are muted." | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | // MARK: - Fork | |
| 80 | ||
| 81 | func fork(named name: String?) async -> ForkResult? { | |
| 82 | var argv = ["repo", "fork", repoPath] | |
| 83 | if let name { | |
| 84 | let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 85 | if !trimmed.isEmpty { | |
| 86 | argv += ["--name", trimmed] | |
| 87 | } | |
| 88 | } | |
| 89 | working = true | |
| 90 | actionError = nil | |
| 91 | notice = nil | |
| 92 | defer { working = false } | |
| 93 | do { | |
| 94 | guard let result = try await client.run(argv, as: ForkResult.self) else { | |
| 95 | actionError = "The fork succeeded, but the server reported nothing back." | |
| 96 | return nil | |
| 97 | } | |
| 98 | return result | |
| 99 | } catch let error as GitbayError { | |
| 100 | actionError = error.userFacingMessage | |
| 101 | return nil | |
| 102 | } catch { | |
| 103 | actionError = GitbayError.transport(error).userFacingMessage | |
| 104 | return nil | |
| 105 | } | |
| 106 | } | |
| 107 | ||
| 108 | private func perform(_ argv: [String]) async { | |
| 109 | working = true | |
| 110 | actionError = nil | |
| 111 | notice = nil | |
| 112 | defer { working = false } | |
| 113 | do { | |
| 114 | try await client.run(argv) | |
| 115 | } catch let error as GitbayError { | |
| 116 | actionError = error.userFacingMessage | |
| 117 | } catch { | |
| 118 | actionError = GitbayError.transport(error).userFacingMessage | |
| 119 | } | |
| 120 | } | |
| 121 | } | |
gitbay/Views/Repos/RepoView.swift +78 −3
| @@ -3,16 +3,23 @@ import SwiftUI | ||
| 3 | 3 | struct RepoView: View { |
| 4 | 4 | |
| 5 | 5 | @State private var model: RepoDetailViewModel |
| 6 | @State private var actionsModel: RepoActionsViewModel | |
| 7 | private let client: GitbayClient | |
| 6 | 8 | private let path: String |
| 7 | 9 | private let host: String |
| 8 | 10 | |
| 9 | 11 | init(client: GitbayClient, path: String) { |
| 12 | self.client = client | |
| 10 | 13 | self.path = path |
| 11 | 14 | self.host = client.instance.baseURL.host() ?? "" |
| 12 | 15 | _model = State(initialValue: RepoDetailViewModel(client: client, path: path)) |
| 16 | _actionsModel = State(initialValue: RepoActionsViewModel(client: client, repoPath: path)) | |
| 13 | 17 | } |
| 14 | 18 | |
| 15 | 19 | @State private var confirmingArchive = false |
| 20 | @State private var confirmingFork = false | |
| 21 | @State private var forkName = "" | |
| 22 | @State private var forkDestination: RepoRoute? | |
| 16 | 23 | |
| 17 | 24 | var body: some View { |
| 18 | 25 | List { |
| @@ -22,6 +29,16 @@ struct RepoView: View { | ||
| 22 | 29 | GBNotice(error, .gbWarn) |
| 23 | 30 | } |
| 24 | 31 | } |
| 32 | if let error = actionsModel.actionError { | |
| 33 | Section { | |
| 34 | GBNotice(error, .gbWarn) | |
| 35 | } | |
| 36 | } | |
| 37 | if let notice = actionsModel.notice { | |
| 38 | Section { | |
| 39 | GBNotice(notice, .gbOK) | |
| 40 | } | |
| 41 | } | |
| 25 | 42 | header(detail) |
| 26 | 43 | |
| 27 | 44 | Section { |
| @@ -84,7 +101,29 @@ struct RepoView: View { | ||
| 84 | 101 | .navigationBarTitleDisplayMode(.inline) |
| 85 | 102 | .toolbar { toolbar } |
| 86 | 103 | .task { await model.load() } |
| 87 | .refreshable { await model.load() } | |
| 104 | .task { await actionsModel.loadBookmarkState() } | |
| 105 | .refreshable { | |
| 106 | await model.load() | |
| 107 | await actionsModel.loadBookmarkState() | |
| 108 | } | |
| 109 | .navigationDestination(item: $forkDestination) { route in | |
| 110 | if case .repo(let forkedPath) = route { | |
| 111 | RepoView(client: client, path: forkedPath) | |
| 112 | } | |
| 113 | } | |
| 114 | .alert("Fork \(path)", isPresented: $confirmingFork) { | |
| 115 | TextField("New name (optional)", text: $forkName) | |
| 116 | .autocorrectionDisabled() | |
| 117 | .textInputAutocapitalization(.never) | |
| 118 | Button("Fork") { | |
| 119 | Task { | |
| 120 | if let result = await actionsModel.fork(named: forkName) { | |
| 121 | forkDestination = .repo(result.path) | |
| 122 | } | |
| 123 | } | |
| 124 | } | |
| 125 | Button("Cancel", role: .cancel) {} | |
| 126 | } | |
| 88 | 127 | .confirmationDialog( |
| 89 | 128 | model.state.value?.isArchived == true |
| 90 | 129 | ? "Unarchive \(path)?" |
| @@ -114,6 +153,42 @@ struct RepoView: View { | ||
| 114 | 153 | systemImage: pinned ? "pin.slash" : "pin") |
| 115 | 154 | } |
| 116 | 155 | } |
| 156 | if let bookmarked = actionsModel.isBookmarked { | |
| 157 | Button { | |
| 158 | Task { await actionsModel.setBookmarked(!bookmarked) } | |
| 159 | } label: { | |
| 160 | Label(bookmarked ? "Unbookmark" : "Bookmark", | |
| 161 | systemImage: bookmarked ? "bookmark.slash" : "bookmark") | |
| 162 | } | |
| 163 | } else { | |
| 164 | Label { | |
| 165 | Text("Checking Bookmark…") | |
| 166 | } icon: { | |
| 167 | ProgressView() | |
| 168 | } | |
| 169 | } | |
| 170 | Divider() | |
| 171 | Section { | |
| 172 | Button { | |
| 173 | Task { await actionsModel.watch() } | |
| 174 | } label: { | |
| 175 | Label("Watch this repository", systemImage: "eye") | |
| 176 | } | |
| 177 | Button { | |
| 178 | Task { await actionsModel.unwatch() } | |
| 179 | } label: { | |
| 180 | Label("Mute this repository", systemImage: "bell.slash") | |
| 181 | } | |
| 182 | // Nothing reports which of these is current (krz/gitbay#178). | |
| 183 | Text("The current setting isn't shown.") | |
| 184 | } | |
| 185 | Divider() | |
| 186 | Button { | |
| 187 | forkName = "" | |
| 188 | confirmingFork = true | |
| 189 | } label: { | |
| 190 | Label("Fork", systemImage: "tuningfork") | |
| 191 | } | |
| 117 | 192 | Divider() |
| 118 | 193 | Button(role: detail.isArchived ? nil : .destructive) { |
| 119 | 194 | confirmingArchive = true |
| @@ -122,13 +197,13 @@ struct RepoView: View { | ||
| 122 | 197 | systemImage: "archivebox") |
| 123 | 198 | } |
| 124 | 199 | } label: { |
| 125 | if model.working { | |
| 200 | if model.working || actionsModel.working { | |
| 126 | 201 | ProgressView() |
| 127 | 202 | } else { |
| 128 | 203 | Image(systemName: "ellipsis.circle") |
| 129 | 204 | } |
| 130 | 205 | } |
| 131 | .disabled(model.working) | |
| 206 | .disabled(model.working || actionsModel.working) | |
| 132 | 207 | .accessibilityIdentifier("repo-actions-menu") |
| 133 | 208 | } |
| 134 | 209 | } |
gitbayTests/RepoActionTests.swift added +235
| @@ -0,0 +1,235 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 6 | let box = StubProtocol.box() | |
| 7 | let client = GitbayClient( | |
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 9 | token: "test-token", | |
| 10 | session: box.session() | |
| 11 | ) | |
| 12 | return (client, box) | |
| 13 | } | |
| 14 | ||
| 15 | private func argvFrom(_ url: URL) -> [String] { | |
| 16 | URLComponents(url: url, resolvingAgainstBaseURL: false)? | |
| 17 | .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? [] | |
| 18 | } | |
| 19 | ||
| 20 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | |
| 21 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | |
| 22 | return try #require(body["argv"] as? [String]) | |
| 23 | } | |
| 24 | ||
| 25 | private let bookmarks = """ | |
| 26 | {"protocol_version":1,"data":[{"path":"krz/gitbay","visibility":"public"},\ | |
| 27 | {"path":"krz/solar","visibility":"public"}],"exit_code":0} | |
| 28 | """ | |
| 29 | private let ok = """ | |
| 30 | {"protocol_version":1,"exit_code":0} | |
| 31 | """ | |
| 32 | ||
| 33 | @MainActor | |
| 34 | struct RepoActionsTests { | |
| 35 | ||
| 36 | /// `repo bookmarks` is plural and takes NO repository argument. | |
| 37 | @Test func theBookmarkListingTakesNoArgument() async throws { | |
| 38 | let (client, stub) = try makeClient() | |
| 39 | stub.enqueue(.init(status: 200, json: bookmarks)) | |
| 40 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 41 | await model.loadBookmarkState() | |
| 42 | ||
| 43 | #expect(argvFrom(try #require(stub.seen.last).url) == ["repo", "bookmarks"]) | |
| 44 | #expect(model.isBookmarked == true) | |
| 45 | } | |
| 46 | ||
| 47 | @Test func aRepoAbsentFromTheListingIsNotBookmarked() async throws { | |
| 48 | let (client, stub) = try makeClient() | |
| 49 | stub.enqueue(.init(status: 200, json: bookmarks)) | |
| 50 | let model = RepoActionsViewModel(client: client, repoPath: "krz/other") | |
| 51 | await model.loadBookmarkState() | |
| 52 | ||
| 53 | #expect(model.isBookmarked == false) | |
| 54 | } | |
| 55 | ||
| 56 | /// "We don't know" is not "not bookmarked". A failed load must reset | |
| 57 | /// the state to unknown rather than leaving a stale prior value in | |
| 58 | /// place — this proves the reset by establishing a known `true` | |
| 59 | /// first, then failing a second load. | |
| 60 | @Test func aFailedListingLeavesTheStateUnknown() async throws { | |
| 61 | let (client, stub) = try makeClient() | |
| 62 | stub.enqueue(.init(status: 200, json: bookmarks)) | |
| 63 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 64 | await model.loadBookmarkState() | |
| 65 | #expect(model.isBookmarked == true) | |
| 66 | ||
| 67 | stub.enqueue(.init(status: 200, json: """ | |
| 68 | {"protocol_version":1,"error":"denied","exit_code":4} | |
| 69 | """)) | |
| 70 | await model.loadBookmarkState() | |
| 71 | ||
| 72 | #expect(model.isBookmarked == nil) | |
| 73 | } | |
| 74 | ||
| 75 | @Test func bookmarkingAndUnbookmarkingAreDifferentCommands() async throws { | |
| 76 | let (client, stub) = try makeClient() | |
| 77 | stub.enqueue(.init(status: 200, json: bookmarks)) | |
| 78 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 79 | await model.loadBookmarkState() | |
| 80 | ||
| 81 | stub.enqueue(.init(status: 200, json: ok)) | |
| 82 | stub.enqueue(.init(status: 200, json: bookmarks)) | |
| 83 | await model.setBookmarked(false) | |
| 84 | var write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 85 | #expect(try argvOf(write) == ["repo", "unbookmark", "krz/gitbay"]) | |
| 86 | ||
| 87 | let (client2, stub2) = try makeClient() | |
| 88 | stub2.enqueue(.init(status: 200, json: bookmarks)) | |
| 89 | let model2 = RepoActionsViewModel(client: client2, repoPath: "krz/other") | |
| 90 | await model2.loadBookmarkState() | |
| 91 | stub2.enqueue(.init(status: 200, json: ok)) | |
| 92 | stub2.enqueue(.init(status: 200, json: bookmarks)) | |
| 93 | await model2.setBookmarked(true) | |
| 94 | write = try #require(stub2.seen.first { $0.method == "POST" }) | |
| 95 | #expect(try argvOf(write) == ["repo", "bookmark", "krz/other"]) | |
| 96 | } | |
| 97 | ||
| 98 | @Test func watchAndUnwatchSendTheirOwnCommands() async throws { | |
| 99 | let (client, stub) = try makeClient() | |
| 100 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 101 | ||
| 102 | stub.enqueue(.init(status: 200, json: ok)) | |
| 103 | await model.watch() | |
| 104 | #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "watch", "krz/gitbay"]) | |
| 105 | ||
| 106 | stub.enqueue(.init(status: 200, json: ok)) | |
| 107 | await model.unwatch() | |
| 108 | #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "unwatch", "krz/gitbay"]) | |
| 109 | } | |
| 110 | ||
| 111 | @Test func forkingReturnsTheNewPath() async throws { | |
| 112 | let (client, stub) = try makeClient() | |
| 113 | stub.enqueue(.init(status: 200, json: """ | |
| 114 | {"protocol_version":1,"data":{"path":"cmc/gitbay","fork_of":"krz/gitbay"},\ | |
| 115 | "exit_code":0} | |
| 116 | """)) | |
| 117 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 118 | let result = await model.fork(named: nil) | |
| 119 | ||
| 120 | #expect(result?.path == "cmc/gitbay") | |
| 121 | #expect(result?.forkOf == "krz/gitbay") | |
| 122 | #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "fork", "krz/gitbay"]) | |
| 123 | } | |
| 124 | ||
| 125 | @Test func forkingWithANameAppendsTheFlag() async throws { | |
| 126 | let (client, stub) = try makeClient() | |
| 127 | stub.enqueue(.init(status: 200, json: """ | |
| 128 | {"protocol_version":1,"data":{"path":"cmc/mine","fork_of":"krz/gitbay"},\ | |
| 129 | "exit_code":0} | |
| 130 | """)) | |
| 131 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 132 | _ = await model.fork(named: "mine") | |
| 133 | ||
| 134 | #expect(try argvOf(try #require(stub.seen.last)) | |
| 135 | == ["repo", "fork", "krz/gitbay", "--name", "mine"]) | |
| 136 | } | |
| 137 | ||
| 138 | /// Surrounding whitespace must not reach the server — the name is | |
| 139 | /// trimmed, not just tested for blankness. | |
| 140 | @Test func forkingWithSurroundingWhitespaceTrimsTheName() async throws { | |
| 141 | let (client, stub) = try makeClient() | |
| 142 | stub.enqueue(.init(status: 200, json: """ | |
| 143 | {"protocol_version":1,"data":{"path":"cmc/mine","fork_of":"krz/gitbay"},\ | |
| 144 | "exit_code":0} | |
| 145 | """)) | |
| 146 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 147 | _ = await model.fork(named: " mine ") | |
| 148 | ||
| 149 | #expect(try argvOf(try #require(stub.seen.last)) | |
| 150 | == ["repo", "fork", "krz/gitbay", "--name", "mine"]) | |
| 151 | } | |
| 152 | ||
| 153 | /// A blank name must not send an empty flag value. | |
| 154 | @Test func forkingWithABlankNameOmitsTheFlag() async throws { | |
| 155 | let (client, stub) = try makeClient() | |
| 156 | stub.enqueue(.init(status: 200, json: """ | |
| 157 | {"protocol_version":1,"data":{"path":"cmc/gitbay","fork_of":"krz/gitbay"},\ | |
| 158 | "exit_code":0} | |
| 159 | """)) | |
| 160 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 161 | _ = await model.fork(named: " ") | |
| 162 | ||
| 163 | #expect(try argvOf(try #require(stub.seen.last)) == ["repo", "fork", "krz/gitbay"]) | |
| 164 | } | |
| 165 | ||
| 166 | @Test func aRefusedForkReturnsNilAndSurfaces() async throws { | |
| 167 | let (client, stub) = try makeClient() | |
| 168 | stub.enqueue(.init(status: 200, json: """ | |
| 169 | {"protocol_version":1,"error":"a repository named gitbay already exists",\ | |
| 170 | "exit_code":1} | |
| 171 | """)) | |
| 172 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 173 | let result = await model.fork(named: nil) | |
| 174 | ||
| 175 | #expect(result == nil) | |
| 176 | #expect(model.actionError?.isEmpty == false) | |
| 177 | #expect(model.working == false) | |
| 178 | } | |
| 179 | ||
| 180 | /// A stale `notice` from an earlier success must not survive a later | |
| 181 | /// failure — the screen must never show a success banner and a | |
| 182 | /// failure banner at once. | |
| 183 | @Test func aFailedActionClearsAStaleNotice() async throws { | |
| 184 | let (client, stub) = try makeClient() | |
| 185 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 186 | ||
| 187 | stub.enqueue(.init(status: 200, json: ok)) | |
| 188 | await model.watch() | |
| 189 | #expect(model.notice != nil) | |
| 190 | ||
| 191 | stub.enqueue(.init(status: 200, json: """ | |
| 192 | {"protocol_version":1,"error":"a repository named gitbay already exists",\ | |
| 193 | "exit_code":1} | |
| 194 | """)) | |
| 195 | _ = await model.fork(named: nil) | |
| 196 | ||
| 197 | #expect(model.notice == nil) | |
| 198 | #expect(model.actionError?.isEmpty == false) | |
| 199 | } | |
| 200 | ||
| 201 | /// A 0-exit fork whose envelope carries no `data` must not fail | |
| 202 | /// silently — the caller needs to know nothing came back. | |
| 203 | @Test func aForkThatReportsNothingBackSetsAnError() async throws { | |
| 204 | let (client, stub) = try makeClient() | |
| 205 | stub.enqueue(.init(status: 200, json: """ | |
| 206 | {"protocol_version":1,"exit_code":0} | |
| 207 | """)) | |
| 208 | let model = RepoActionsViewModel(client: client, repoPath: "krz/gitbay") | |
| 209 | let result = await model.fork(named: nil) | |
| 210 | ||
| 211 | #expect(result == nil) | |
| 212 | #expect(model.actionError?.isEmpty == false) | |
| 213 | } | |
| 214 | ||
| 215 | /// A bookmark write must be followed by a re-read of `repo | |
| 216 | /// bookmarks` — that re-read is what keeps `isBookmarked` honest | |
| 217 | /// instead of an optimistic local guess. | |
| 218 | @Test func bookmarkingRereadsTheListingAfterTheWrite() async throws { | |
| 219 | let (client, stub) = try makeClient() | |
| 220 | stub.enqueue(.init(status: 200, json: bookmarks)) | |
| 221 | let model = RepoActionsViewModel(client: client, repoPath: "krz/other") | |
| 222 | await model.loadBookmarkState() | |
| 223 | #expect(model.isBookmarked == false) | |
| 224 | ||
| 225 | stub.enqueue(.init(status: 200, json: ok)) | |
| 226 | stub.enqueue(.init(status: 200, json: bookmarks)) | |
| 227 | await model.setBookmarked(true) | |
| 228 | ||
| 229 | let calls = stub.seen.suffix(2) | |
| 230 | let write = try #require(calls.first) | |
| 231 | let reread = try #require(calls.last) | |
| 232 | #expect(try argvOf(write) == ["repo", "bookmark", "krz/other"]) | |
| 233 | #expect(argvFrom(reread.url) == ["repo", "bookmarks"]) | |
| 234 | } | |
| 235 | } | |