docs: parity follow-up spec and plans for 1.3.0-1.6.0 !122
6 files changed, +3893 −0
Layout: unified · split
LICENSE added +10
| @@ -0,0 +1,10 @@ | |||
| 1 | Permission to use, copy, modify, and/or distribute this software for any | ||
| 2 | purpose with or without fee is hereby granted. | ||
| 3 | |||
| 4 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES | ||
| 5 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF | ||
| 6 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR | ||
| 7 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES | ||
| 8 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN | ||
| 9 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF | ||
| 10 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. | ||
docs/superpowers/plans/2026-09-22-1.3.0-profile-about.md added +786
| @@ -0,0 +1,786 @@ | |||
| 1 | # 1.3.0 Profile About 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:** Profile and org-profile saves work again against gitbay v1.31.0 and later, and the about text is edited as the file it now is. | ||
| 6 | |||
| 7 | **Architecture:** `profile set` and `org profile` carry description, website and links only. The about text lives at `profile/README.{md,org,markdown}` in `<owner>/.gitbay`; `profile show` reports its path as `about_path`. The profile screen links to that file in the existing file screen, whose edit sheet already commits through `repo commit-file`, and offers to create the repository and a starter file when there is none — the web's `profile-repo` form. | ||
| 8 | |||
| 9 | **Tech Stack:** Swift 6 language mode with default `MainActor` isolation, SwiftUI, Swift Testing. iOS 18.0 minimum, iOS 26.5 SDK. Server side: `krz/gitbay` wiki (org-mode) and the `gitbay` CLI. | ||
| 10 | |||
| 11 | **Spec:** `docs/superpowers/specs/2026-09-22-parity-followup-design.md` (sections "1.3.0: profile about" and "Upstream: krz/gitbay") | ||
| 12 | |||
| 13 | ## Global Constraints | ||
| 14 | |||
| 15 | - **Never attribute anything to an assistant or model** — not in commits, code comments, MR bodies, or docs. No `Co-Authored-By` trailer. This is absolute. | ||
| 16 | - **Commits are signed.** `commit.gpgsign` is on in both repositories; do not pass `--no-gpg-sign`. | ||
| 17 | - **Never push to `main`. Never merge.** Work on the branches below; pushing a branch and opening its MR is the last step of a task group, and merging is the user's. | ||
| 18 | - **Two repositories.** Tasks 1–4 land in `~/git/krz/gitbay-ios` on branch `profile-about`. Task 5 lands in `~/git/krz/gitbay` on branch `parity-ios-followup`. Check which one you are in before editing. | ||
| 19 | - **Swift 6, default `MainActor` isolation.** Wire models are `nonisolated` structs; anything decoded needs `nonisolated` or its `Decodable` conformance fails as main-actor isolated. | ||
| 20 | - **The Xcode project uses file-system-synchronized groups.** New `.swift` files under `gitbay/` and `gitbayTests/` are picked up automatically. Do not edit `project.pbxproj`. | ||
| 21 | - **Reads are `GET /api/v1/read?argv=…`; writes are `POST /api/v1/cmd`.** In tests, a stub's `match:` is a URL substring: `"argv=profile&argv=show"`, `"argv=repo&argv=show"` for reads, `"cmd"` for writes. Writes are asserted by filtering `stub.seen` on `method == "POST"`, never by position. | ||
| 22 | - **Exit 3 is `GitbayError.notFound`**, and `error.isEmptyState` is true for exactly that case. | ||
| 23 | - Comments explain *why*, in plain direct English, at the density of the surrounding code. No before/after commentary. | ||
| 24 | - **Run the whole unit target, and read the count.** `-only-testing:` on a single test can report success with zero tests run. Baseline on 2026-09-22: 462 tests, 461 passed, 1 skipped. | ||
| 25 | |||
| 26 | ```bash | ||
| 27 | cd /Users/cmc/git/krz/gitbay-ios && rm -rf /tmp/gb.xcresult && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 28 | -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' -only-testing:gitbayTests \ | ||
| 29 | -resultBundlePath /tmp/gb.xcresult test 2>&1 | grep -E "error:|failed|TEST (SUCCEEDED|FAILED)" | tail -20 | ||
| 30 | xcrun xcresulttool get test-results summary --path /tmp/gb.xcresult --format json \ | ||
| 31 | | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['result'], d['totalTestCount'], d['failedTests'])" | ||
| 32 | ``` | ||
| 33 | |||
| 34 | The destination is the iPhone 17 Pro simulator on iOS 26.5. Everything below that says "run the unit target" means these two commands. | ||
| 35 | |||
| 36 | --- | ||
| 37 | |||
| 38 | ### Task 1: `profile set` carries scalars and links only | ||
| 39 | |||
| 40 | **Files:** | ||
| 41 | - Modify: `gitbay/Discovery/ProfileEdit.swift` (whole file) | ||
| 42 | - Modify: `gitbay/Discovery/ProfileViewModel.swift:136-143` (`saveProfile`) | ||
| 43 | - Modify: `gitbay/Views/Discovery/ProfileEditSheet.swift:3-8` (doc comment), `:37-54` (About section) | ||
| 44 | - Test: `gitbayTests/ProfileEditTests.swift` | ||
| 45 | |||
| 46 | **Interfaces:** | ||
| 47 | - Produces: `ProfileEdit(description: String = "", website: String = "", links: [ProfileEdit.Link] = [])`, `ProfileEdit(from: ProfileViewModel.Profile)`, `ProfileEdit.flags() -> [String]`. The properties `about`, `aboutFormat` and `aboutStdin` no longer exist. | ||
| 48 | |||
| 49 | - [ ] **Step 1: Create the branch** | ||
| 50 | |||
| 51 | ```bash | ||
| 52 | cd /Users/cmc/git/krz/gitbay-ios && git switch main && git switch -c profile-about | ||
| 53 | ``` | ||
| 54 | |||
| 55 | - [ ] **Step 2: Rewrite the tests to the new contract** | ||
| 56 | |||
| 57 | In `gitbayTests/ProfileEditTests.swift`: | ||
| 58 | |||
| 59 | Replace the `edit(...)` helper at the top of `ProfileEditFlagTests` with: | ||
| 60 | |||
| 61 | ```swift | ||
| 62 | private func edit( | ||
| 63 | description: String = "", website: String = "", | ||
| 64 | links: [ProfileEdit.Link] = [] | ||
| 65 | ) -> ProfileEdit { | ||
| 66 | ProfileEdit(description: description, website: website, links: links) | ||
| 67 | } | ||
| 68 | ``` | ||
| 69 | |||
| 70 | Delete `theAboutTextGoesToStdinNotArgv()` and `theAboutFormatAccompaniesTheText()`, and add in their place: | ||
| 71 | |||
| 72 | ```swift | ||
| 73 | /// Since gitbay v1.31.0 the about text is a file in <owner>/.gitbay, | ||
| 74 | /// and `profile set` refuses any of these as an unknown flag (exit 2). | ||
| 75 | @Test func noAboutFlagIsSent() { | ||
| 76 | let flags = edit(description: "hi", website: "https://x.test", | ||
| 77 | links: [.init(label: "a", url: "https://a.test")]).flags() | ||
| 78 | #expect(!flags.contains("--about")) | ||
| 79 | #expect(!flags.contains("--about-format")) | ||
| 80 | #expect(!flags.contains("--file")) | ||
| 81 | } | ||
| 82 | ``` | ||
| 83 | |||
| 84 | In `ProfileEditFromProfileTests`, replace `seedsFromAnOrgProfile()` with the version below and delete `seedingFallsBackToMarkdownWhenFormatIsAbsent()` entirely: | ||
| 85 | |||
| 86 | ```swift | ||
| 87 | @Test func seedsFromTheProfile() throws { | ||
| 88 | let data = try #require(orgProfileJSON.data(using: .utf8)) | ||
| 89 | let profile = try JSONDecoder().decode(ProfileViewModel.Profile.self, from: data) | ||
| 90 | |||
| 91 | let edit = ProfileEdit(from: profile) | ||
| 92 | |||
| 93 | #expect(edit.description == "about me") | ||
| 94 | #expect(edit.website == "https://cleberg.net") | ||
| 95 | #expect(edit.links.map(\.url) == ["https://a.test", "https://b.test"]) | ||
| 96 | #expect(edit.links.map(\.label) == ["", "blog"]) | ||
| 97 | } | ||
| 98 | ``` | ||
| 99 | |||
| 100 | In `ProfileSaveTests`, replace `savesTheFullArgvWithAboutOnlyInStdin()` with: | ||
| 101 | |||
| 102 | ```swift | ||
| 103 | /// The write, then the reload `profile set` triggers — one POST, one | ||
| 104 | /// GET. Both need a stub or the flow ends up looking like a refusal | ||
| 105 | /// even when the write itself succeeded. | ||
| 106 | @Test func savesScalarsAndLinksWithNoStdin() async throws { | ||
| 107 | let (client, stub) = try makeClient() | ||
| 108 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 109 | stub.enqueue(.init(status: 200, json: profileJSON, match: "argv=show")) | ||
| 110 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 111 | let edit = ProfileEdit( | ||
| 112 | description: "hi", website: "https://cleberg.net", | ||
| 113 | links: [.init(label: "blog", url: "https://b.test")] | ||
| 114 | ) | ||
| 115 | |||
| 116 | let saved = await model.saveProfile(edit) | ||
| 117 | |||
| 118 | #expect(saved) | ||
| 119 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 120 | let (argv, stdin) = try argvOf(write) | ||
| 121 | #expect(argv == [ | ||
| 122 | "profile", "set", | ||
| 123 | "--description", "hi", "--website", "https://cleberg.net", | ||
| 124 | "--link", "blog|https://b.test", | ||
| 125 | ]) | ||
| 126 | #expect(stdin == nil) | ||
| 127 | } | ||
| 128 | ``` | ||
| 129 | |||
| 130 | - [ ] **Step 3: Run the unit target to verify it fails** | ||
| 131 | |||
| 132 | Run the unit target (see Global Constraints). | ||
| 133 | Expected: `Failed 460 2`. The tests compile — every parameter of the current initializer has a default — and two fail at runtime: `noAboutFlagIsSent` (the flags still end in `--about-format md --file -`) and `savesScalarsAndLinksWithNoStdin` (the argv carries that tail and stdin is `""`). | ||
| 134 | |||
| 135 | - [ ] **Step 4: Rewrite `ProfileEdit`** | ||
| 136 | |||
| 137 | Replace the whole of `gitbay/Discovery/ProfileEdit.swift` with: | ||
| 138 | |||
| 139 | ```swift | ||
| 140 | import Foundation | ||
| 141 | |||
| 142 | /// `profile set` — unlike every other filter or edit in the app, `--link` | ||
| 143 | /// replaces the whole set rather than appending, so every link the form | ||
| 144 | /// wants to keep is sent on every save. The form is always seeded from | ||
| 145 | /// the current profile, so every other field is also sent every time, | ||
| 146 | /// including empty ones: `--description ""` clears, omitting the flag | ||
| 147 | /// would leave the old value alone. | ||
| 148 | /// | ||
| 149 | /// The about text is not here. It is a file in `<owner>/.gitbay`, written | ||
| 150 | /// through the file editor like any other file. | ||
| 151 | nonisolated struct ProfileEdit: Equatable, Sendable { | ||
| 152 | |||
| 153 | nonisolated struct Link: Equatable, Sendable, Identifiable { | ||
| 154 | var id = UUID() | ||
| 155 | var label: String | ||
| 156 | var url: String | ||
| 157 | } | ||
| 158 | |||
| 159 | var description: String | ||
| 160 | var website: String | ||
| 161 | var links: [Link] | ||
| 162 | |||
| 163 | init(description: String = "", website: String = "", links: [Link] = []) { | ||
| 164 | self.description = description | ||
| 165 | self.website = website | ||
| 166 | self.links = links | ||
| 167 | } | ||
| 168 | |||
| 169 | init(from profile: ProfileViewModel.Profile) { | ||
| 170 | description = profile.description ?? "" | ||
| 171 | website = profile.website ?? "" | ||
| 172 | links = (profile.links ?? []).map { Link(label: $0.label ?? "", url: $0.url) } | ||
| 173 | } | ||
| 174 | |||
| 175 | /// At most 5 links and every link needs a URL — the server enforces | ||
| 176 | /// both, but the form should not let a save reach it that way. A | ||
| 177 | /// label may not contain `|` either: the server splits a link on its | ||
| 178 | /// first pipe, so a label carrying one would silently corrupt the URL | ||
| 179 | /// rather than be rejected. | ||
| 180 | var validationError: String? { | ||
| 181 | if links.count > 5 { return "At most 5 links." } | ||
| 182 | if links.contains(where: { $0.url.trimmingCharacters(in: .whitespaces).isEmpty }) { | ||
| 183 | return "A link needs a URL." | ||
| 184 | } | ||
| 185 | if links.contains(where: { $0.label.contains("|") }) { | ||
| 186 | return "A link label can't contain \"|\" — that's what separates it from the URL." | ||
| 187 | } | ||
| 188 | return nil | ||
| 189 | } | ||
| 190 | |||
| 191 | func flags() -> [String] { | ||
| 192 | var flags = ["--description", description, "--website", website] | ||
| 193 | if links.isEmpty { | ||
| 194 | flags += ["--link", ""] | ||
| 195 | } else { | ||
| 196 | for link in links { | ||
| 197 | let label = link.label.trimmingCharacters(in: .whitespaces) | ||
| 198 | flags += ["--link", label.isEmpty ? link.url : "\(label)|\(link.url)"] | ||
| 199 | } | ||
| 200 | } | ||
| 201 | return flags | ||
| 202 | } | ||
| 203 | } | ||
| 204 | ``` | ||
| 205 | |||
| 206 | - [ ] **Step 5: Stop sending stdin from `saveProfile`** | ||
| 207 | |||
| 208 | In `gitbay/Discovery/ProfileViewModel.swift`, replace: | ||
| 209 | |||
| 210 | ```swift | ||
| 211 | /// `profile set` for the caller's own profile, `org profile <org>` for | ||
| 212 | /// an organization; both take the same flags. The about text travels | ||
| 213 | /// over stdin, never argv, since it is long-form and may carry newlines. | ||
| 214 | func saveProfile(_ edit: ProfileEdit) async -> Bool { | ||
| 215 | guard edit.validationError == nil else { return false } | ||
| 216 | let command = state.value?.isOrg == true ? ["org", "profile", name] : ["profile", "set"] | ||
| 217 | await perform(command + edit.flags(), stdin: edit.aboutStdin) | ||
| 218 | return actionError == nil | ||
| 219 | } | ||
| 220 | ``` | ||
| 221 | |||
| 222 | with: | ||
| 223 | |||
| 224 | ```swift | ||
| 225 | /// `profile set` for the caller's own profile, `org profile <org>` for | ||
| 226 | /// an organization; both take the same flags. | ||
| 227 | func saveProfile(_ edit: ProfileEdit) async -> Bool { | ||
| 228 | guard edit.validationError == nil else { return false } | ||
| 229 | let command = state.value?.isOrg == true ? ["org", "profile", name] : ["profile", "set"] | ||
| 230 | await perform(command + edit.flags()) | ||
| 231 | return actionError == nil | ||
| 232 | } | ||
| 233 | ``` | ||
| 234 | |||
| 235 | - [ ] **Step 6: Remove the about field from the sheet** | ||
| 236 | |||
| 237 | In `gitbay/Views/Discovery/ProfileEditSheet.swift`, replace the doc comment on the struct with: | ||
| 238 | |||
| 239 | ```swift | ||
| 240 | /// `profile set`, or `org profile` for an organization. Description and | ||
| 241 | /// website are single lines; links are capped at five, matching the | ||
| 242 | /// server. The about text is a file and is edited from the profile | ||
| 243 | /// screen, not here. | ||
| 244 | ``` | ||
| 245 | |||
| 246 | and delete the whole `Section { Picker("Format", …) … } header: { Text("About") } footer: { … }` block (the section between the Website section and `linksSection`). | ||
| 247 | |||
| 248 | - [ ] **Step 7: Run the unit target to verify it passes** | ||
| 249 | |||
| 250 | Run the unit target. | ||
| 251 | Expected: `Passed 460 0` — two about tests and one seeding test deleted, one flag test added. | ||
| 252 | |||
| 253 | - [ ] **Step 8: Commit** | ||
| 254 | |||
| 255 | ```bash | ||
| 256 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Discovery/ProfileEdit.swift gitbay/Discovery/ProfileViewModel.swift gitbay/Views/Discovery/ProfileEditSheet.swift gitbayTests/ProfileEditTests.swift && git commit -m "profile: set description, website and links only | ||
| 257 | |||
| 258 | gitbay v1.31.0 moved the about text into <owner>/.gitbay, and profile | ||
| 259 | set and org profile refuse --about-format and --file as unknown flags, | ||
| 260 | so every save failed with exit 2." | ||
| 261 | ``` | ||
| 262 | |||
| 263 | --- | ||
| 264 | |||
| 265 | ### Task 2: Render the about text under the file it came from | ||
| 266 | |||
| 267 | **Files:** | ||
| 268 | - Modify: `gitbay/Discovery/ProfileViewModel.swift:11-48` (the `Profile` struct) | ||
| 269 | - Modify: `gitbay/Views/Discovery/ProfileView.swift:140-149` (`aboutSection`) | ||
| 270 | - Test: `gitbayTests/ProfileCommitTests.swift` | ||
| 271 | |||
| 272 | **Interfaces:** | ||
| 273 | - Consumes: nothing from Task 1. | ||
| 274 | - Produces: `ProfileViewModel.Profile.aboutPath: String?` (decoded from `about_path`). `aboutFormat` and `aboutFile` no longer exist. | ||
| 275 | |||
| 276 | - [ ] **Step 1: Write the failing test** | ||
| 277 | |||
| 278 | In `gitbayTests/ProfileCommitTests.swift`, in `userProfileJSON`, change | ||
| 279 | |||
| 280 | ``` | ||
| 281 | "about":"* Christian\\n\\nOrg-Mode.","about_format":"org",\ | ||
| 282 | ``` | ||
| 283 | |||
| 284 | to | ||
| 285 | |||
| 286 | ``` | ||
| 287 | "about":"* Christian\\n\\nOrg-Mode.","about_format":"org","about_path":"profile/README.org",\ | ||
| 288 | ``` | ||
| 289 | |||
| 290 | In `aUserProfileCarriesOrgsReposAndActivity()`, replace | ||
| 291 | |||
| 292 | ```swift | ||
| 293 | // The format picks the filename the about text renders under. | ||
| 294 | #expect(profile.aboutFile == "about.org") | ||
| 295 | ``` | ||
| 296 | |||
| 297 | with | ||
| 298 | |||
| 299 | ```swift | ||
| 300 | // The file's own path picks the renderer, as it does on the web. | ||
| 301 | #expect(profile.aboutPath == "profile/README.org") | ||
| 302 | ``` | ||
| 303 | |||
| 304 | In `anOrgProfileCarriesMembersInsteadOfOrgs()`, replace | ||
| 305 | |||
| 306 | ```swift | ||
| 307 | // Markdown is the default for anything but org. | ||
| 308 | #expect(profile.aboutFile == "about.md") | ||
| 309 | ``` | ||
| 310 | |||
| 311 | with | ||
| 312 | |||
| 313 | ```swift | ||
| 314 | // No about file, no path. | ||
| 315 | #expect(profile.aboutPath == nil) | ||
| 316 | ``` | ||
| 317 | |||
| 318 | - [ ] **Step 2: Run the unit target to verify it fails** | ||
| 319 | |||
| 320 | Expected: build FAILS with `value of type 'ProfileViewModel.Profile' has no member 'aboutPath'`. | ||
| 321 | |||
| 322 | - [ ] **Step 3: Decode `about_path`, drop the format guess** | ||
| 323 | |||
| 324 | In `gitbay/Discovery/ProfileViewModel.swift`, in `Profile`: | ||
| 325 | |||
| 326 | Replace | ||
| 327 | |||
| 328 | ```swift | ||
| 329 | /// Long-form profile text, rendered between the header and the | ||
| 330 | /// activity graph the way the web renders it. | ||
| 331 | let about: String? | ||
| 332 | let aboutFormat: String? | ||
| 333 | ``` | ||
| 334 | |||
| 335 | with | ||
| 336 | |||
| 337 | ```swift | ||
| 338 | /// Long-form profile text, rendered between the header and the | ||
| 339 | /// activity graph the way the web renders it. | ||
| 340 | let about: String? | ||
| 341 | /// The file in `<name>/.gitbay` the text was read from, e.g. | ||
| 342 | /// `profile/README.org`. Its extension picks the renderer. | ||
| 343 | let aboutPath: String? | ||
| 344 | ``` | ||
| 345 | |||
| 346 | In `CodingKeys`, replace `case aboutFormat = "about_format"` with `case aboutPath = "about_path"`. | ||
| 347 | |||
| 348 | Delete the `aboutFile` property and its doc comment: | ||
| 349 | |||
| 350 | ```swift | ||
| 351 | /// The filename the about text is rendered under. The server has | ||
| 352 | /// none to dispatch on either, so the stored format picks the | ||
| 353 | /// extension and anything but org is markdown. | ||
| 354 | var aboutFile: String { aboutFormat == "org" ? "about.org" : "about.md" } | ||
| 355 | ``` | ||
| 356 | |||
| 357 | - [ ] **Step 4: Render under the real path** | ||
| 358 | |||
| 359 | In `gitbay/Views/Discovery/ProfileView.swift`, in `aboutSection`, replace | ||
| 360 | |||
| 361 | ```swift | ||
| 362 | ReadmeView(name: profile.aboutFile, content: about) | ||
| 363 | ``` | ||
| 364 | |||
| 365 | with | ||
| 366 | |||
| 367 | ```swift | ||
| 368 | ReadmeView(name: profile.aboutPath ?? "", content: about) | ||
| 369 | ``` | ||
| 370 | |||
| 371 | `ReadmeView` renders anything not ending in `.org` as markdown, so an absent path renders markdown. | ||
| 372 | |||
| 373 | - [ ] **Step 5: Run the unit target to verify it passes** | ||
| 374 | |||
| 375 | Expected: `Passed 460 0`. | ||
| 376 | |||
| 377 | - [ ] **Step 6: Commit** | ||
| 378 | |||
| 379 | ```bash | ||
| 380 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Discovery/ProfileViewModel.swift gitbay/Views/Discovery/ProfileView.swift gitbayTests/ProfileCommitTests.swift && git commit -m "profile: render the about text under about_path" | ||
| 381 | ``` | ||
| 382 | |||
| 383 | --- | ||
| 384 | |||
| 385 | ### Task 3: Edit the about file, or create it | ||
| 386 | |||
| 387 | **Files:** | ||
| 388 | - Modify: `gitbay/Discovery/ProfileViewModel.swift` (add `aboutRepo`, `createAboutFile()`) | ||
| 389 | - Modify: `gitbay/Views/Discovery/ProfileView.swift` (`aboutSection`) | ||
| 390 | - Test: `gitbayTests/ProfileCommitTests.swift` (new suite `AboutFileTests` at the end of the file) | ||
| 391 | |||
| 392 | **Interfaces:** | ||
| 393 | - Consumes: `Profile.aboutPath` (Task 2), `Profile.canEdit(by:)`, `RepoRoute.file(repo: String, path: String, ref: String?)`. | ||
| 394 | - Produces: `ProfileViewModel.aboutRepo: String` (`"<name>/.gitbay"`), `ProfileViewModel.createAboutFile() async -> Bool`. | ||
| 395 | |||
| 396 | - [ ] **Step 1: Write the failing tests** | ||
| 397 | |||
| 398 | Append to `gitbayTests/ProfileCommitTests.swift`: | ||
| 399 | |||
| 400 | ```swift | ||
| 401 | private func postArgv(_ stub: StubProtocol.Box) throws -> [([String], String?)] { | ||
| 402 | try stub.seen.filter { $0.method == "POST" }.map { seen in | ||
| 403 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 404 | return (try #require(body["argv"] as? [String]), body["stdin"] as? String) | ||
| 405 | } | ||
| 406 | } | ||
| 407 | |||
| 408 | private let okCmdJSON = #"{"protocol_version":1,"exit_code":0}"# | ||
| 409 | |||
| 410 | @MainActor | ||
| 411 | struct AboutFileTests { | ||
| 412 | |||
| 413 | /// The web's "profile-repo" form: create <owner>/.gitbay, commit a | ||
| 414 | /// starter README, show the result. | ||
| 415 | @Test func createsTheRepositoryAndAStarterFile() async throws { | ||
| 416 | let (client, stub) = try makeClient() | ||
| 417 | stub.enqueue(.init(status: 404, json: | ||
| 418 | #"{"protocol_version":1,"error":"no repository cmc/.gitbay","exit_code":3}"#, | ||
| 419 | match: "argv=repo&argv=show")) | ||
| 420 | stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd")) | ||
| 421 | stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd")) | ||
| 422 | stub.enqueue(.init(status: 200, json: userProfileJSON, match: "argv=profile&argv=show")) | ||
| 423 | let model = ProfileViewModel(client: client, name: "cmc") | ||
| 424 | |||
| 425 | let created = await model.createAboutFile() | ||
| 426 | |||
| 427 | #expect(created) | ||
| 428 | #expect(model.actionError == nil) | ||
| 429 | let writes = try postArgv(stub) | ||
| 430 | #expect(writes.map(\.0) == [ | ||
| 431 | ["repo", "create", "cmc/.gitbay"], | ||
| 432 | ["repo", "commit-file", "cmc/.gitbay", "profile/README.md", "--ref", "main", | ||
| 433 | "--message", "add profile about", "--file", "-"], | ||
| 434 | ]) | ||
| 435 | #expect(writes[1].1 == "# cmc\n\nThis is the about text on your profile.\n") | ||
| 436 | #expect(model.state.value?.aboutPath == "profile/README.org") | ||
| 437 | } | ||
| 438 | |||
| 439 | /// A .gitbay repository can exist without an about file in it — it | ||
| 440 | /// holds other per-owner configuration too. Creating it again would | ||
| 441 | /// fail, so only the file is written. | ||
| 442 | @Test func skipsCreateWhenTheRepositoryExists() async throws { | ||
| 443 | let (client, stub) = try makeClient() | ||
| 444 | stub.enqueue(.init(status: 200, json: | ||
| 445 | #"{"protocol_version":1,"data":{"path":"krz/.gitbay"},"exit_code":0}"#, | ||
| 446 | match: "argv=repo&argv=show")) | ||
| 447 | stub.enqueue(.init(status: 200, json: okCmdJSON, match: "cmd")) | ||
| 448 | stub.enqueue(.init(status: 200, json: orgProfileJSON, match: "argv=profile&argv=show")) | ||
| 449 | let model = ProfileViewModel(client: client, name: "krz") | ||
| 450 | |||
| 451 | let created = await model.createAboutFile() | ||
| 452 | |||
| 453 | #expect(created) | ||
| 454 | let writes = try postArgv(stub) | ||
| 455 | #expect(writes.count == 1) | ||
| 456 | #expect(Array(writes[0].0.prefix(4)) == ["repo", "commit-file", "krz/.gitbay", "profile/README.md"]) | ||
| 457 | #expect(writes[0].1 == "# krz\n\nThis is the about text on your profile.\n") | ||
| 458 | } | ||
| 459 | |||
| 460 | /// A refused create must not go on to write a file into nothing. | ||
| 461 | @Test func aRefusedCreateStopsAndSurfaces() async throws { | ||
| 462 | let (client, stub) = try makeClient() | ||
| 463 | stub.enqueue(.init(status: 404, json: | ||
| 464 | #"{"protocol_version":1,"error":"no repository krz/.gitbay","exit_code":3}"#, | ||
| 465 | match: "argv=repo&argv=show")) | ||
| 466 | stub.enqueue(.init(status: 200, json: | ||
| 467 | #"{"protocol_version":1,"error":"only admins of krz can create repositories there","exit_code":4}"#, | ||
| 468 | match: "cmd")) | ||
| 469 | let model = ProfileViewModel(client: client, name: "krz") | ||
| 470 | |||
| 471 | let created = await model.createAboutFile() | ||
| 472 | |||
| 473 | #expect(created == false) | ||
| 474 | #expect(model.actionError?.isEmpty == false) | ||
| 475 | #expect(try postArgv(stub).count == 1) | ||
| 476 | } | ||
| 477 | |||
| 478 | @Test func theAboutRepositoryIsTheOwnersDotGitbay() throws { | ||
| 479 | let (client, _) = try makeClient() | ||
| 480 | #expect(ProfileViewModel(client: client, name: "cmc").aboutRepo == "cmc/.gitbay") | ||
| 481 | } | ||
| 482 | } | ||
| 483 | ``` | ||
| 484 | |||
| 485 | - [ ] **Step 2: Run the unit target to verify it fails** | ||
| 486 | |||
| 487 | Expected: build FAILS with `value of type 'ProfileViewModel' has no member 'createAboutFile'` and `… no member 'aboutRepo'`. | ||
| 488 | |||
| 489 | - [ ] **Step 3: Implement `createAboutFile`** | ||
| 490 | |||
| 491 | In `gitbay/Discovery/ProfileViewModel.swift`, add after `saveProfile(_:)`: | ||
| 492 | |||
| 493 | ```swift | ||
| 494 | /// The repository that holds this profile's about text. | ||
| 495 | var aboutRepo: String { "\(name)/.gitbay" } | ||
| 496 | |||
| 497 | /// Creates `<name>/.gitbay` when it is missing and commits a starter | ||
| 498 | /// `profile/README.md`, the same starter the web's settings page | ||
| 499 | /// writes, so the file editor has something to open. The repository | ||
| 500 | /// may already exist without the file, and creating it again would | ||
| 501 | /// fail, so it is looked up first. | ||
| 502 | func createAboutFile() async -> Bool { | ||
| 503 | working = true | ||
| 504 | actionError = nil | ||
| 505 | defer { working = false } | ||
| 506 | do { | ||
| 507 | do { | ||
| 508 | _ = try await client.read(["repo", "show", aboutRepo], as: Exists.self) | ||
| 509 | } catch let error as GitbayError where error.isEmptyState { | ||
| 510 | try await client.run(["repo", "create", aboutRepo]) | ||
| 511 | } | ||
| 512 | try await client.run( | ||
| 513 | ["repo", "commit-file", aboutRepo, "profile/README.md", "--ref", "main", | ||
| 514 | "--message", "add profile about", "--file", "-"], | ||
| 515 | stdin: "# \(name)\n\nThis is the about text on your profile.\n") | ||
| 516 | await load() | ||
| 517 | return true | ||
| 518 | } catch let error as GitbayError { | ||
| 519 | actionError = error.userFacingMessage | ||
| 520 | } catch { | ||
| 521 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 522 | } | ||
| 523 | return false | ||
| 524 | } | ||
| 525 | |||
| 526 | /// `repo show` is read only to learn whether the repository exists. | ||
| 527 | nonisolated private struct Exists: Decodable, Sendable {} | ||
| 528 | ``` | ||
| 529 | |||
| 530 | - [ ] **Step 4: Run the unit target to verify it passes** | ||
| 531 | |||
| 532 | Expected: `Passed 464 0`. | ||
| 533 | |||
| 534 | - [ ] **Step 5: Add the Edit and Create actions to the about section** | ||
| 535 | |||
| 536 | In `gitbay/Views/Discovery/ProfileView.swift`, replace the whole `aboutSection` function with: | ||
| 537 | |||
| 538 | ```swift | ||
| 539 | /// The long-form about text, rendered under the file it came from. A | ||
| 540 | /// viewer who may write the profile gets the file itself to edit, or, | ||
| 541 | /// when there is none yet, the offer to create it. | ||
| 542 | @ViewBuilder | ||
| 543 | private func aboutSection(_ profile: ProfileViewModel.Profile) -> some View { | ||
| 544 | let canEdit = profile.canEdit(by: session.current?.username) | ||
| 545 | if let about = profile.about, !about.isEmpty { | ||
| 546 | Section("About") { | ||
| 547 | ReadmeView(name: profile.aboutPath ?? "", content: about) | ||
| 548 | .padding(.vertical, 4) | ||
| 549 | if canEdit, let path = profile.aboutPath { | ||
| 550 | NavigationLink(value: RepoRoute.file(repo: model.aboutRepo, path: path, ref: nil)) { | ||
| 551 | Label("Edit About", systemImage: "pencil") | ||
| 552 | } | ||
| 553 | .accessibilityIdentifier("profile-about-edit") | ||
| 554 | } | ||
| 555 | } | ||
| 556 | } else if canEdit { | ||
| 557 | Section { | ||
| 558 | Button { | ||
| 559 | Task { await model.createAboutFile() } | ||
| 560 | } label: { | ||
| 561 | Label("Create About File", systemImage: "doc.badge.plus") | ||
| 562 | } | ||
| 563 | .disabled(model.working) | ||
| 564 | .accessibilityIdentifier("profile-about-create") | ||
| 565 | } header: { | ||
| 566 | Text("About") | ||
| 567 | } footer: { | ||
| 568 | Text("Adds profile/README.md to \(model.aboutRepo), creating the repository if needed.") | ||
| 569 | } | ||
| 570 | } | ||
| 571 | } | ||
| 572 | ``` | ||
| 573 | |||
| 574 | - [ ] **Step 6: Build and run the unit target** | ||
| 575 | |||
| 576 | Expected: `Passed 464 0`. The view has no unit test; Task 4's live step covers it. | ||
| 577 | |||
| 578 | - [ ] **Step 7: Commit** | ||
| 579 | |||
| 580 | ```bash | ||
| 581 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Discovery/ProfileViewModel.swift gitbay/Views/Discovery/ProfileView.swift gitbayTests/ProfileCommitTests.swift && git commit -m "profile: edit the about file, or create it | ||
| 582 | |||
| 583 | The about section links to profile/README.* in <owner>/.gitbay for a | ||
| 584 | viewer who may write it, and offers to create the repository and a | ||
| 585 | starter file when there is none, as the web's settings page does." | ||
| 586 | ``` | ||
| 587 | |||
| 588 | --- | ||
| 589 | |||
| 590 | ### Task 4: Live suite covers the save and the about actions | ||
| 591 | |||
| 592 | **Files:** | ||
| 593 | - Modify: `gitbayUITests/LiveSmokeUITests.swift` (`testProfileAndNavigationFlows`, after the "a profile is a profile" block, before "an org admin can edit the org's profile") | ||
| 594 | |||
| 595 | **Interfaces:** | ||
| 596 | - Consumes: accessibility identifiers `profile-edit-button`, `profile-edit-save` (existing), `profile-about-edit`, `profile-about-create` (Task 3). `Self.smokeProfileDescription`. | ||
| 597 | |||
| 598 | The `ios-smoke` account's `.gitbay` repository may or may not exist, so the step accepts either action and creates nothing. | ||
| 599 | |||
| 600 | - [ ] **Step 1: Add the steps** | ||
| 601 | |||
| 602 | In `testProfileAndNavigationFlows()`, immediately before the line `// --- an org admin can edit the org's profile ---`, insert: | ||
| 603 | |||
| 604 | ```swift | ||
| 605 | // --- saving the profile succeeds: profile set takes no about flags --- | ||
| 606 | let edit = app.descendants(matching: .any) | ||
| 607 | .matching(identifier: "profile-edit-button").firstMatch | ||
| 608 | XCTAssertTrue(edit.waitForExistence(timeout: 10), "own profile offers no edit") | ||
| 609 | edit.tap() | ||
| 610 | let save = app.buttons["profile-edit-save"].firstMatch | ||
| 611 | XCTAssertTrue(save.waitForExistence(timeout: 10), "edit sheet did not open") | ||
| 612 | XCTAssertFalse(app.descendants(matching: .any) | ||
| 613 | .matching(identifier: "profile-edit-about").firstMatch.exists, | ||
| 614 | "the sheet still edits the about text") | ||
| 615 | save.tap() | ||
| 616 | XCTAssertTrue(save.waitForNonExistence(timeout: 15), | ||
| 617 | "profile save did not dismiss the sheet — the server refused it") | ||
| 618 | |||
| 619 | // --- the about text is a file: edit it, or offer to create it --- | ||
| 620 | let aboutAction = app.descendants(matching: .any).matching(NSPredicate( | ||
| 621 | format: "identifier IN %@", ["profile-about-edit", "profile-about-create"])).firstMatch | ||
| 622 | XCTAssertTrue(scrollTo(aboutAction), | ||
| 623 | "own profile offers neither editing nor creating the about file") | ||
| 624 | ``` | ||
| 625 | |||
| 626 | - [ ] **Step 2: Build the UI test target** | ||
| 627 | |||
| 628 | ```bash | ||
| 629 | cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 630 | -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' build-for-testing 2>&1 | grep -E "error:|BUILD (SUCCEEDED|FAILED)" | tail -5 | ||
| 631 | ``` | ||
| 632 | |||
| 633 | Expected: `** TEST BUILD SUCCEEDED **`. `waitForNonExistence(timeout:)` needs the iOS 26.5 SDK's XCTest, which is what builds here. | ||
| 634 | |||
| 635 | - [ ] **Step 3: Run the live test (needs a token from the user)** | ||
| 636 | |||
| 637 | The live suite runs as `ios-smoke` and needs `TEST_RUNNER_GITBAY_UITEST_TOKEN`. Ask the user to mint one — `ssh -F /dev/null -i ~/.ssh/gitbay-ios-smoke -o IdentitiesOnly=yes git@gitbay.org token create --name ui-smoke --scope full --ttl 1d` — and never type it into the app by hand. Then: | ||
| 638 | |||
| 639 | ```bash | ||
| 640 | cd /Users/cmc/git/krz/gitbay-ios && TEST_RUNNER_GITBAY_UITEST_LIVE=1 TEST_RUNNER_GITBAY_UITEST_TOKEN="$TOKEN" \ | ||
| 641 | xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 642 | -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' \ | ||
| 643 | -only-testing:gitbayUITests/LiveSmokeUITests/testProfileAndNavigationFlows \ | ||
| 644 | -resultBundlePath /tmp/gb-live.xcresult test 2>&1 | grep -E "error:|passed|failed|TEST (SUCCEEDED|FAILED)" | tail -10 | ||
| 645 | ``` | ||
| 646 | |||
| 647 | Expected: `Test Case '-[gitbayUITests.LiveSmokeUITests testProfileAndNavigationFlows]' passed`. If it fails, read the failure from the bundle (`xcrun xcresulttool get test-results tests --path /tmp/gb-live.xcresult --format json`), not stdout. If no token is available, record that the live step was not run and continue. | ||
| 648 | |||
| 649 | - [ ] **Step 4: Commit** | ||
| 650 | |||
| 651 | ```bash | ||
| 652 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbayUITests/LiveSmokeUITests.swift && git commit -m "live suite: profile save and the about file actions" | ||
| 653 | ``` | ||
| 654 | |||
| 655 | - [ ] **Step 5: Run the unit target on iOS 18.6 as well** | ||
| 656 | |||
| 657 | ```bash | ||
| 658 | cd /Users/cmc/git/krz/gitbay-ios && rm -rf /tmp/gb18.xcresult && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 659 | -destination 'id=9F8E5D84-0A15-4C64-8A45-994667A73817' -only-testing:gitbayTests \ | ||
| 660 | -resultBundlePath /tmp/gb18.xcresult test 2>&1 | grep -E "error:|TEST (SUCCEEDED|FAILED)" | tail -5 | ||
| 661 | ``` | ||
| 662 | |||
| 663 | Expected: `** TEST SUCCEEDED **`, same count as on iOS 26.5. | ||
| 664 | |||
| 665 | - [ ] **Step 6: Release build for device** | ||
| 666 | |||
| 667 | ```bash | ||
| 668 | cd /Users/cmc/git/krz/gitbay-ios && xcodebuild build -scheme gitbay -configuration Release \ | ||
| 669 | -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO 2>&1 | grep -E "error:|While running pass|BUILD (SUCCEEDED|FAILED)" | tail -5 | ||
| 670 | ``` | ||
| 671 | |||
| 672 | Expected: `** BUILD SUCCEEDED **`. | ||
| 673 | |||
| 674 | - [ ] **Step 7: Push the branch and open the MR** | ||
| 675 | |||
| 676 | ```bash | ||
| 677 | cd /Users/cmc/git/krz/gitbay-ios && git push -u origin profile-about && gitbay mr create --source profile-about --target main \ | ||
| 678 | --title "Profile save: drop the about flags, edit the about file" --file - <<'EOF' | ||
| 679 | gitbay v1.31.0 moved the profile about text into <owner>/.gitbay and | ||
| 680 | removed --about-format and --file from profile set and org profile. | ||
| 681 | Every save from the app failed with exit 2 (unknown flag). | ||
| 682 | |||
| 683 | - ProfileEdit sends description, website and links only. | ||
| 684 | - Profile decodes about_path and renders the text under it. | ||
| 685 | - The about section links to the file for a viewer who may write it, | ||
| 686 | and offers to create <owner>/.gitbay with a starter | ||
| 687 | profile/README.md when there is none. | ||
| 688 | |||
| 689 | Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md | ||
| 690 | EOF | ||
| 691 | ``` | ||
| 692 | |||
| 693 | Main already carries 1.3.0 (12), so no version bump is needed. Merging, tagging `v1.3.0` on the merge commit, archiving and uploading are the user's. | ||
| 694 | |||
| 695 | --- | ||
| 696 | |||
| 697 | ### Task 5: Upstream corrections in krz/gitbay | ||
| 698 | |||
| 699 | **Files:** | ||
| 700 | - Modify: `~/git/krz/gitbay/.gitbay/wiki/Parity.org:204` (the `build list paging` row), the rows after it, and the preview paragraph ending near line 159. | ||
| 701 | - The published v1.31.0 release notes (not a file; edited with `gitbay release edit`). | ||
| 702 | |||
| 703 | **Interfaces:** none. | ||
| 704 | |||
| 705 | - [ ] **Step 1: Branch** | ||
| 706 | |||
| 707 | ```bash | ||
| 708 | cd /Users/cmc/git/krz/gitbay && git switch main && git pull --ff-only && git switch -c parity-ios-followup | ||
| 709 | ``` | ||
| 710 | |||
| 711 | - [ ] **Step 2: Correct the build rows** | ||
| 712 | |||
| 713 | In `.gitbay/wiki/Parity.org`, replace | ||
| 714 | |||
| 715 | ``` | ||
| 716 | | build list paging (limit, cursor) | yes | yes | yes | | ||
| 717 | ``` | ||
| 718 | |||
| 719 | with | ||
| 720 | |||
| 721 | ``` | ||
| 722 | | build list paging (limit, cursor) | yes | yes | no | | ||
| 723 | | build row names its commit | yes | yes | no | | ||
| 724 | ``` | ||
| 725 | |||
| 726 | - [ ] **Step 3: Say what the iOS preview will be** | ||
| 727 | |||
| 728 | In the same file, the preview paragraph ends with | ||
| 729 | |||
| 730 | ``` | ||
| 731 | =issue show= already renders. Diff-line comments are left out — they | ||
| 732 | sit inside the diff, where a page-level preview has nowhere to go. | ||
| 733 | ``` | ||
| 734 | |||
| 735 | Append after it, as a new paragraph: | ||
| 736 | |||
| 737 | ``` | ||
| 738 | |||
| 739 | The iOS client resolves no autolinks: =#N= and =owner/name#N= render as | ||
| 740 | text in its threads, so a preview there will show the app's rendering | ||
| 741 | rather than the one the web page shows. | ||
| 742 | ``` | ||
| 743 | |||
| 744 | - [ ] **Step 4: Commit and open the MR** | ||
| 745 | |||
| 746 | ```bash | ||
| 747 | cd /Users/cmc/git/krz/gitbay && git add .gitbay/wiki/Parity.org && git commit -m "Parity: iOS build list paging is no; add the commit-subject row | ||
| 748 | |||
| 749 | The iOS build list reads one unpaged page and leads each row with a sha. | ||
| 750 | Also note that the iOS client resolves no autolinks." && git push -u origin parity-ios-followup && \ | ||
| 751 | gitbay mr create --source parity-ios-followup --target main --title "Parity: correct the iOS build rows" --file - <<'EOF' | ||
| 752 | - build list paging (limit, cursor): iOS is no. The client reads one | ||
| 753 | page and ignores the cursor. | ||
| 754 | - New row: build row names its commit (the subject field from v1.33.0). | ||
| 755 | iOS is no. | ||
| 756 | - A sentence that the iOS client resolves no autolinks, ahead of its | ||
| 757 | markup preview. | ||
| 758 | EOF | ||
| 759 | ``` | ||
| 760 | |||
| 761 | - [ ] **Step 5: Correct the v1.31.0 release notes (confirm with the user first)** | ||
| 762 | |||
| 763 | This edits a published release on gitbay.org. Show the user the before/after sentence and wait for a yes before running it. | ||
| 764 | |||
| 765 | ```bash | ||
| 766 | cd /Users/cmc/git/krz/gitbay && gitbay release show krz/gitbay v1.31.0 --json \ | ||
| 767 | | python3 -c "import json,sys; d=json.load(sys.stdin)['data']; print(d['notes'])" > /tmp/v1.31.0-notes.org | ||
| 768 | python3 - <<'EOF' | ||
| 769 | p = '/tmp/v1.31.0-notes.org' | ||
| 770 | s = open(p).read() | ||
| 771 | old = "at =about.org= or\n =about.md= in =<owner>/.gitbay=," | ||
| 772 | new = "at =profile/README.md=\n or =profile/README.org= in =<owner>/.gitbay=," | ||
| 773 | assert old in s, "sentence not found; the notes changed since the audit" | ||
| 774 | open(p, 'w').write(s.replace(old, new)) | ||
| 775 | EOF | ||
| 776 | diff <(gitbay release show krz/gitbay v1.31.0 --json | python3 -c "import json,sys; print(json.load(sys.stdin)['data']['notes'])") /tmp/v1.31.0-notes.org | ||
| 777 | ``` | ||
| 778 | |||
| 779 | Expected: the diff shows only the two changed lines. After the user confirms: | ||
| 780 | |||
| 781 | ```bash | ||
| 782 | cd /Users/cmc/git/krz/gitbay && gitbay release edit krz/gitbay v1.31.0 --file - < /tmp/v1.31.0-notes.org && \ | ||
| 783 | gitbay release show krz/gitbay v1.31.0 | grep -n "profile/README" | ||
| 784 | ``` | ||
| 785 | |||
| 786 | No `--format`: `release edit` keeps the stored format when none is given. Expected: the line naming `profile/README.md` prints. The CHANGELOG entry for v1.31.0 already names the right path and is not touched. | ||
docs/superpowers/plans/2026-09-22-1.4.0-builds-and-mr-labels.md added +1257
| @@ -0,0 +1,1257 @@ | |||
| 1 | # 1.4.0 Build List and MR Labels 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:** The build list names each build's commit and pages past the first window; merge requests carry labels and filter by them, as issues already do. | ||
| 6 | |||
| 7 | **Architecture:** `Build` decodes the `subject` that `build list` has returned since gitbay v1.33.0, and `BuildListViewModel` moves onto the `PagedListModel` every other list uses. MR labels copy the issue implementation: `mr label --add/--remove` on the detail screen, `mr list --label` from the filter sheet. The chip row and the label picker the issue screens own become shared views so both screens use one copy. | ||
| 8 | |||
| 9 | **Tech Stack:** Swift 6 language mode with default `MainActor` isolation, SwiftUI, Swift Testing, XCUITest. iOS 18.0 minimum, iOS 26.5 SDK. | ||
| 10 | |||
| 11 | **Spec:** `docs/superpowers/specs/2026-09-22-parity-followup-design.md` (section "1.4.0: build list and MR labels") | ||
| 12 | |||
| 13 | ## Global Constraints | ||
| 14 | |||
| 15 | - **Never attribute anything to an assistant or model** — not in commits, code comments, MR bodies, or docs. No `Co-Authored-By` trailer. This is absolute. | ||
| 16 | - **Commits are signed.** `commit.gpgsign` is on; do not pass `--no-gpg-sign`. | ||
| 17 | - **Never push to `main`. Never merge.** Two branches, one MR each: `build-list` (Tasks 1–3) and `mr-labels` (Tasks 4–6), both cut from `main` after the 1.3.0 work has merged. Task 7 is the version bump and waits for the user to merge both. | ||
| 18 | - **Swift 6, default `MainActor` isolation.** Wire models are `nonisolated` structs. | ||
| 19 | - **File-system-synchronized groups.** New `.swift` files under `gitbay/` and `gitbayTests/` are picked up automatically. Do not edit `project.pbxproj` except for the version lines in Task 7. | ||
| 20 | - **Reads are `GET /api/v1/read?argv=…`; writes are `POST /api/v1/cmd`.** A stub's `match:` is a URL substring. Assert writes by filtering `stub.seen` on `method == "POST"`, never by position. | ||
| 21 | - **A paged read's envelope is `{"data":{"items":[…],"next":"…"}}`.** `next` is absent on the last page. `PagedListModel` appends `--limit <n>` and, for later pages, `--cursor <c>` to its `argv`. | ||
| 22 | - **Server order, never a client sort.** A client sort reorders rows across page boundaries. | ||
| 23 | - **`MRDetailViewModel.load()` must keep issuing exactly four GETs** (`mr show`, `mr diff`, `mr threads`, `mr revisions`). `MRViewModelTests` and `MRLifecycleTests` count them. Labels load from the section's `.task`, the way milestones already do. | ||
| 24 | - Comments explain *why*, in plain direct English, at the density of the surrounding code. No before/after commentary. | ||
| 25 | - **Run the whole unit target, and read the count.** `-only-testing:` on one test can report success with zero tests run. | ||
| 26 | |||
| 27 | ```bash | ||
| 28 | cd /Users/cmc/git/krz/gitbay-ios && rm -rf /tmp/gb.xcresult && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 29 | -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' -only-testing:gitbayTests \ | ||
| 30 | -resultBundlePath /tmp/gb.xcresult test 2>&1 | grep -E "error:|failed|TEST (SUCCEEDED|FAILED)" | tail -20 | ||
| 31 | xcrun xcresulttool get test-results summary --path /tmp/gb.xcresult --format json \ | ||
| 32 | | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['result'], d['totalTestCount'], d['failedTests'])" | ||
| 33 | ``` | ||
| 34 | |||
| 35 | "Run the unit target" below means these two commands. Record the count printed before Task 1 as `BASE`; expected counts below are relative to it. | ||
| 36 | |||
| 37 | --- | ||
| 38 | |||
| 39 | ### Task 1: A build row names its commit | ||
| 40 | |||
| 41 | **Files:** | ||
| 42 | - Modify: `gitbay/Builds/BuildModels.swift:4-22` (`Build`) | ||
| 43 | - Modify: `gitbay/Views/Builds/BuildListView.swift:69-99` (`BuildRow`) | ||
| 44 | - Create: `gitbayTests/BuildListPagingTests.swift` | ||
| 45 | |||
| 46 | **Interfaces:** | ||
| 47 | - Produces: `Build.subject: String?`, `Build.title: String` (`"#<number> <subject>"`, or `"#<number> <shortSHA>"` when there is no subject). | ||
| 48 | |||
| 49 | - [ ] **Step 1: Branch** | ||
| 50 | |||
| 51 | ```bash | ||
| 52 | cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c build-list | ||
| 53 | ``` | ||
| 54 | |||
| 55 | Run the unit target and record the count as `BASE`. | ||
| 56 | |||
| 57 | - [ ] **Step 2: Write the failing tests** | ||
| 58 | |||
| 59 | Create `gitbayTests/BuildListPagingTests.swift`: | ||
| 60 | |||
| 61 | ```swift | ||
| 62 | import Foundation | ||
| 63 | import Testing | ||
| 64 | @testable import gitbay | ||
| 65 | |||
| 66 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 67 | let box = StubProtocol.box() | ||
| 68 | let client = GitbayClient( | ||
| 69 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 70 | token: "test-token", | ||
| 71 | session: box.session() | ||
| 72 | ) | ||
| 73 | return (client, box) | ||
| 74 | } | ||
| 75 | |||
| 76 | private func buildRow(_ number: Int, subject: String? = nil) -> String { | ||
| 77 | let subjectField = subject.map { #","subject":"\#($0)""# } ?? "" | ||
| 78 | return """ | ||
| 79 | {"number":\(number),"job":"test","status":"success",\ | ||
| 80 | "sha":"db7503f06f11c31a41f1c8e91187e1ff8f861c7f","ref":"main",\ | ||
| 81 | "created_at":"2026-09-22T04:40:16.000Z"\(subjectField)} | ||
| 82 | """ | ||
| 83 | } | ||
| 84 | |||
| 85 | struct BuildSubjectTests { | ||
| 86 | |||
| 87 | private func decode(_ json: String) throws -> Build { | ||
| 88 | try GitbayClient.decoder().decode(Build.self, from: Data(json.utf8)) | ||
| 89 | } | ||
| 90 | |||
| 91 | @Test func aRowLeadsWithTheCommitSubject() throws { | ||
| 92 | let build = try decode(buildRow(1479, subject: "CHANGELOG: v1.34.1")) | ||
| 93 | #expect(build.subject == "CHANGELOG: v1.34.1") | ||
| 94 | #expect(build.title == "#1479 CHANGELOG: v1.34.1") | ||
| 95 | } | ||
| 96 | |||
| 97 | /// A build whose commit is gone has no subject to report; the sha is | ||
| 98 | /// all there is. | ||
| 99 | @Test func aRowWithoutASubjectFallsBackToTheSha() throws { | ||
| 100 | let build = try decode(buildRow(12)) | ||
| 101 | #expect(build.subject == nil) | ||
| 102 | #expect(build.title == "#12 db7503f06f") | ||
| 103 | } | ||
| 104 | |||
| 105 | @Test func anEmptySubjectAlsoFallsBack() throws { | ||
| 106 | let build = try decode(buildRow(12, subject: "")) | ||
| 107 | #expect(build.title == "#12 db7503f06f") | ||
| 108 | } | ||
| 109 | } | ||
| 110 | ``` | ||
| 111 | |||
| 112 | - [ ] **Step 3: Run the unit target to verify it fails** | ||
| 113 | |||
| 114 | Expected: build FAILS with `value of type 'Build' has no member 'subject'` and `… no member 'title'`. | ||
| 115 | |||
| 116 | - [ ] **Step 4: Decode `subject` and derive the title** | ||
| 117 | |||
| 118 | In `gitbay/Builds/BuildModels.swift`, in `Build`, add after `let finishedAt: Date?`: | ||
| 119 | |||
| 120 | ```swift | ||
| 121 | /// The built commit's subject, which `build list` resolves since | ||
| 122 | /// gitbay v1.33.0. Absent when the commit is gone. | ||
| 123 | let subject: String? | ||
| 124 | ``` | ||
| 125 | |||
| 126 | Change `case number, job, status, sha, ref` to `case number, job, status, sha, ref, subject`. | ||
| 127 | |||
| 128 | Add after `var shortSHA`: | ||
| 129 | |||
| 130 | ```swift | ||
| 131 | /// What a row leads with. Recognising a build by its sha meant | ||
| 132 | /// opening the commit or already knowing it (krz/gitbay#241). | ||
| 133 | var title: String { | ||
| 134 | if let subject, !subject.isEmpty { return "#\(number) \(subject)" } | ||
| 135 | return "#\(number) \(shortSHA)" | ||
| 136 | } | ||
| 137 | ``` | ||
| 138 | |||
| 139 | - [ ] **Step 5: Lead the row with it** | ||
| 140 | |||
| 141 | In `gitbay/Views/Builds/BuildListView.swift`, in `BuildRow`, replace | ||
| 142 | |||
| 143 | ```swift | ||
| 144 | Text("#\(build.number) \(build.job)") | ||
| 145 | .font(.gbSans(.subheadline).weight(.medium)) | ||
| 146 | HStack(spacing: 6) { | ||
| 147 | Text(build.ref) | ||
| 148 | Text(build.shortSHA) | ||
| 149 | .font(.gbMono(.caption)) | ||
| 150 | } | ||
| 151 | ``` | ||
| 152 | |||
| 153 | with | ||
| 154 | |||
| 155 | ```swift | ||
| 156 | Text(build.title) | ||
| 157 | .font(.gbSans(.subheadline).weight(.medium)) | ||
| 158 | .lineLimit(2) | ||
| 159 | HStack(spacing: 6) { | ||
| 160 | Text(build.job) | ||
| 161 | Text(build.ref) | ||
| 162 | Text(build.shortSHA) | ||
| 163 | .font(.gbMono(.caption)) | ||
| 164 | } | ||
| 165 | ``` | ||
| 166 | |||
| 167 | - [ ] **Step 6: Run the unit target to verify it passes** | ||
| 168 | |||
| 169 | Expected: `Passed <BASE + 3> 0`. | ||
| 170 | |||
| 171 | - [ ] **Step 7: Commit** | ||
| 172 | |||
| 173 | ```bash | ||
| 174 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Builds/BuildModels.swift gitbay/Views/Builds/BuildListView.swift gitbayTests/BuildListPagingTests.swift && git commit -m "builds: lead each row with the commit subject | ||
| 175 | |||
| 176 | build list returns subject since gitbay v1.33.0 (krz/gitbay#241). The | ||
| 177 | job, ref and sha move to the meta line." | ||
| 178 | ``` | ||
| 179 | |||
| 180 | --- | ||
| 181 | |||
| 182 | ### Task 2: The build list pages | ||
| 183 | |||
| 184 | **Files:** | ||
| 185 | - Modify: `gitbay/Builds/BuildListViewModel.swift` (whole file) | ||
| 186 | - Modify: `gitbay/Views/Builds/BuildListView.swift:19-23` (add `PageFooter`) | ||
| 187 | - Modify: `gitbayTests/BuildListPagingTests.swift` (add `BuildListPagingTests`) | ||
| 188 | - Modify: `gitbayTests/IssueBuildViewModelTests.swift:32-40` (`buildListJSON`), `:146-193` (`BuildListViewModelTests`) | ||
| 189 | - Modify: `gitbayTests/RepoManagementTests.swift:367-371` (`BuildTriggerTests.buildListJSON`) | ||
| 190 | |||
| 191 | **Interfaces:** | ||
| 192 | - Consumes: `PagedListModel<Element>(client:argv:emptyMessage:pageSize:)`, its `reload()`, `loadMore()`, `hasMore`, `argv`, `emptyMessage`, `state`; `PageFooter(list:)`. | ||
| 193 | - Produces: `BuildListViewModel.list: PagedListModel<Build>`; `state`, `filter`, `jobs`, `load()`, `loadJobs()`, `trigger(job:)` keep their signatures. | ||
| 194 | |||
| 195 | - [ ] **Step 1: Write the failing tests** | ||
| 196 | |||
| 197 | Append to `gitbayTests/BuildListPagingTests.swift`: | ||
| 198 | |||
| 199 | ```swift | ||
| 200 | private func page(_ rows: [String], next: String? = nil) -> String { | ||
| 201 | let nextField = next.map { #","next":"\#($0)""# } ?? "" | ||
| 202 | return #"{"protocol_version":1,"data":{"items":["# + rows.joined(separator: ",") | ||
| 203 | + "]" + nextField + #"},"exit_code":0}"# | ||
| 204 | } | ||
| 205 | |||
| 206 | @MainActor | ||
| 207 | struct BuildListPagingTests { | ||
| 208 | |||
| 209 | /// The web asks for thirty; so does the app. | ||
| 210 | @Test func asksForThirtyAndFollowsTheCursor() async throws { | ||
| 211 | let (client, stub) = try makeClient() | ||
| 212 | stub.enqueue(.init(status: 200, json: page([buildRow(5), buildRow(4)], next: "YnVpbGQ6NA"), | ||
| 213 | match: "argv=build&argv=list")) | ||
| 214 | let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 215 | |||
| 216 | await model.load() | ||
| 217 | |||
| 218 | #expect(try #require(model.state.value).map(\.number) == [5, 4]) | ||
| 219 | #expect(model.list.hasMore) | ||
| 220 | let first = try #require(stub.seen.first) | ||
| 221 | #expect(first.url.query() == "argv=build&argv=list&argv=krz/gitbay&argv=--limit&argv=30") | ||
| 222 | |||
| 223 | stub.enqueue(.init(status: 200, json: page([buildRow(3)]), match: "argv=build&argv=list")) | ||
| 224 | await model.list.loadMore() | ||
| 225 | |||
| 226 | #expect(try #require(model.state.value).map(\.number) == [5, 4, 3]) | ||
| 227 | #expect(model.list.hasMore == false) | ||
| 228 | let second = try #require(stub.seen.last) | ||
| 229 | #expect(second.url.query()?.hasSuffix("argv=--limit&argv=30&argv=--cursor&argv=YnVpbGQ6NA") == true) | ||
| 230 | } | ||
| 231 | |||
| 232 | /// Rows are shown in the order the server sent them. A client sort | ||
| 233 | /// would reorder rows across a page boundary. | ||
| 234 | @Test func keepsTheServersOrder() async throws { | ||
| 235 | let (client, stub) = try makeClient() | ||
| 236 | stub.enqueue(.init(status: 200, json: page([buildRow(3), buildRow(4)]))) | ||
| 237 | let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 238 | |||
| 239 | await model.load() | ||
| 240 | |||
| 241 | #expect(try #require(model.state.value).map(\.number) == [3, 4]) | ||
| 242 | } | ||
| 243 | |||
| 244 | @Test func aFilterChangeStartsOverFromTheFirstPage() async throws { | ||
| 245 | let (client, stub) = try makeClient() | ||
| 246 | stub.enqueue(.init(status: 200, json: page([buildRow(5)], next: "YnVpbGQ6NQ"))) | ||
| 247 | let model = BuildListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 248 | await model.load() | ||
| 249 | |||
| 250 | stub.enqueue(.init(status: 200, json: page([]))) | ||
| 251 | var filter = BuildFilter() | ||
| 252 | filter.status = .failure | ||
| 253 | model.filter = filter | ||
| 254 | await until { | ||
| 255 | if case .empty = model.state { return true } | ||
| 256 | return false | ||
| 257 | } | ||
| 258 | |||
| 259 | let second = try #require(stub.seen.last) | ||
| 260 | #expect(second.url.query() | ||
| 261 | == "argv=build&argv=list&argv=krz/gitbay&argv=--status&argv=failure&argv=--limit&argv=30") | ||
| 262 | #expect(model.list.hasMore == false) | ||
| 263 | } | ||
| 264 | } | ||
| 265 | ``` | ||
| 266 | |||
| 267 | In `gitbayTests/IssueBuildViewModelTests.swift`, replace `buildListJSON` with the paged shape, keeping the rows in the order the server would send them (newest first): | ||
| 268 | |||
| 269 | ```swift | ||
| 270 | private let buildListJSON = """ | ||
| 271 | {"protocol_version":1,"data":{"items":[\ | ||
| 272 | {"number":4,"job":"ci","status":"running","sha":"7953e780000000000000",\ | ||
| 273 | "ref":"refs/heads/main","created_at":"2026-08-20T11:00:00.000Z"},\ | ||
| 274 | {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\ | ||
| 275 | "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z",\ | ||
| 276 | "finished_at":"2026-08-20T10:05:00.000Z"}\ | ||
| 277 | ]},"exit_code":0} | ||
| 278 | """ | ||
| 279 | ``` | ||
| 280 | |||
| 281 | In `BuildListViewModelTests`, rename `listsNewestFirst()` to `decodesEachRow()` (its assertions stay: `[4, 3]`, `running`, `finishedAt`). In `changingTheFilterReloadsWithItsFlags()`, change the empty stub to | ||
| 282 | |||
| 283 | ```swift | ||
| 284 | stub.enqueue(.init(status: 200, json: """ | ||
| 285 | {"protocol_version":1,"data":{"items":[]},"exit_code":0} | ||
| 286 | """)) | ||
| 287 | ``` | ||
| 288 | |||
| 289 | and the expected query to | ||
| 290 | |||
| 291 | ```swift | ||
| 292 | == "argv=build&argv=list&argv=krz/gitbay&argv=--status&argv=failure&argv=--job&argv=test&argv=--limit&argv=30") | ||
| 293 | ``` | ||
| 294 | |||
| 295 | In `gitbayTests/RepoManagementTests.swift`, in `BuildTriggerTests`, replace `buildListJSON` with: | ||
| 296 | |||
| 297 | ```swift | ||
| 298 | private let buildListJSON = """ | ||
| 299 | {"protocol_version":1,"data":{"items":[\ | ||
| 300 | {"number":3,"job":"ci","status":"success","sha":"65ba14e0000000000000",\ | ||
| 301 | "ref":"refs/heads/main","created_at":"2026-08-20T10:00:00.000Z"}]},"exit_code":0} | ||
| 302 | """ | ||
| 303 | ``` | ||
| 304 | |||
| 305 | - [ ] **Step 2: Run the unit target to verify it fails** | ||
| 306 | |||
| 307 | Expected: build FAILS with `value of type 'BuildListViewModel' has no member 'list'`. | ||
| 308 | |||
| 309 | - [ ] **Step 3: Move the view model onto `PagedListModel`** | ||
| 310 | |||
| 311 | Replace the whole of `gitbay/Builds/BuildListViewModel.swift` with: | ||
| 312 | |||
| 313 | ```swift | ||
| 314 | import Foundation | ||
| 315 | import Observation | ||
| 316 | |||
| 317 | /// `build list <repo>`, narrowed by `filter`, thirty to a page as the web | ||
| 318 | /// pages it. Rows stay in server order: a client sort would reorder them | ||
| 319 | /// across a page boundary. | ||
| 320 | @Observable | ||
| 321 | @MainActor | ||
| 322 | final class BuildListViewModel { | ||
| 323 | |||
| 324 | let list: PagedListModel<Build> | ||
| 325 | /// A change reloads from the first page. The sheet edits a copy and | ||
| 326 | /// assigns once, so a typed branch does not fire a request per | ||
| 327 | /// keystroke. | ||
| 328 | var filter = BuildFilter() { | ||
| 329 | didSet { | ||
| 330 | guard filter != oldValue else { return } | ||
| 331 | list.argv = ["build", "list", repoPath] + filter.flags() | ||
| 332 | list.emptyMessage = filter.emptyMessage | ||
| 333 | reloadTask?.cancel() | ||
| 334 | reloadTask = Task { await list.reload() } | ||
| 335 | } | ||
| 336 | } | ||
| 337 | private var reloadTask: Task<Void, Never>? | ||
| 338 | /// The jobs a trigger can name. Empty when the repo has no CI | ||
| 339 | /// config — the web hides its trigger form in that case. | ||
| 340 | private(set) var jobs: [CIJob] = [] | ||
| 341 | private(set) var actionError: String? | ||
| 342 | private(set) var working = false | ||
| 343 | |||
| 344 | private let client: GitbayClient | ||
| 345 | let repoPath: String | ||
| 346 | |||
| 347 | init(client: GitbayClient, repoPath: String) { | ||
| 348 | self.client = client | ||
| 349 | self.repoPath = repoPath | ||
| 350 | list = PagedListModel( | ||
| 351 | client: client, | ||
| 352 | argv: ["build", "list", repoPath] + BuildFilter().flags(), | ||
| 353 | emptyMessage: BuildFilter().emptyMessage, | ||
| 354 | pageSize: 30 | ||
| 355 | ) | ||
| 356 | } | ||
| 357 | |||
| 358 | var state: LoadState<[Build]> { list.state } | ||
| 359 | |||
| 360 | func load() async { | ||
| 361 | reloadTask?.cancel() | ||
| 362 | await list.reload() | ||
| 363 | } | ||
| 364 | |||
| 365 | /// `build jobs <owner/name>` — what the picker offers. A repo | ||
| 366 | /// without a CI config answers "not found"; that means no jobs, not | ||
| 367 | /// a failure worth showing. | ||
| 368 | func loadJobs() async { | ||
| 369 | jobs = (try? await client.readList(["build", "jobs", repoPath], of: CIJob.self)) ?? [] | ||
| 370 | } | ||
| 371 | |||
| 372 | /// `build trigger <owner/name> <job>` — queue a job now. | ||
| 373 | func trigger(job: String) async { | ||
| 374 | working = true | ||
| 375 | actionError = nil | ||
| 376 | defer { working = false } | ||
| 377 | do { | ||
| 378 | try await client.run(["build", "trigger", repoPath, job]) | ||
| 379 | await load() | ||
| 380 | } catch let error as GitbayError { | ||
| 381 | actionError = error.userFacingMessage | ||
| 382 | } catch { | ||
| 383 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 384 | } | ||
| 385 | } | ||
| 386 | } | ||
| 387 | ``` | ||
| 388 | |||
| 389 | - [ ] **Step 4: Page from the list's last row** | ||
| 390 | |||
| 391 | In `gitbay/Views/Builds/BuildListView.swift`, after the `ForEach(model.state.value ?? []) { … }` block inside the `List`, add: | ||
| 392 | |||
| 393 | ```swift | ||
| 394 | PageFooter(list: model.list) | ||
| 395 | ``` | ||
| 396 | |||
| 397 | - [ ] **Step 5: Run the unit target to verify it passes** | ||
| 398 | |||
| 399 | Expected: `Passed <BASE + 6> 0`. | ||
| 400 | |||
| 401 | - [ ] **Step 6: Commit** | ||
| 402 | |||
| 403 | ```bash | ||
| 404 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Builds/BuildListViewModel.swift gitbay/Views/Builds/BuildListView.swift gitbayTests/BuildListPagingTests.swift gitbayTests/IssueBuildViewModelTests.swift gitbayTests/RepoManagementTests.swift && git commit -m "builds: page the list thirty at a time | ||
| 405 | |||
| 406 | build list takes --limit and --cursor since gitbay v1.33.0 | ||
| 407 | (krz/gitbay#244). The list moves onto PagedListModel and keeps server | ||
| 408 | order." | ||
| 409 | ``` | ||
| 410 | |||
| 411 | --- | ||
| 412 | |||
| 413 | ### Task 3: Live steps for the build list, then the MR | ||
| 414 | |||
| 415 | **Files:** | ||
| 416 | - Modify: `gitbayUITests/LiveSmokeUITests.swift:1760-1766` (`testBuildFilterFlow`, the job step) and a new `testBuildPagingFlow()` after `testBuildFilterFlow()` | ||
| 417 | |||
| 418 | **Interfaces:** | ||
| 419 | - Consumes: `readCommand(_:)`, `openRepo(_:)`, `scrollTo(_:swipes:)` in the live suite. | ||
| 420 | |||
| 421 | - [ ] **Step 1: The job now has its own text** | ||
| 422 | |||
| 423 | In `testBuildFilterFlow()`, replace | ||
| 424 | |||
| 425 | ```swift | ||
| 426 | XCTAssertTrue(app.staticTexts | ||
| 427 | .containing(NSPredicate(format: "label CONTAINS ' test'")).firstMatch | ||
| 428 | .waitForExistence(timeout: 20), "job filter did not narrow to test") | ||
| 429 | ``` | ||
| 430 | |||
| 431 | with | ||
| 432 | |||
| 433 | ```swift | ||
| 434 | XCTAssertTrue(app.staticTexts["test"].firstMatch | ||
| 435 | .waitForExistence(timeout: 20), "job filter did not narrow to test") | ||
| 436 | ``` | ||
| 437 | |||
| 438 | - [ ] **Step 2: Add the paging test** | ||
| 439 | |||
| 440 | After `testBuildFilterFlow()`, add: | ||
| 441 | |||
| 442 | ```swift | ||
| 443 | /// krz/gitbay has well over thirty builds, so the thirty-first is on | ||
| 444 | /// the second page. Reaching it means the footer asked for more. | ||
| 445 | func testBuildPagingFlow() throws { | ||
| 446 | guard let data = readCommand(["build", "list", "krz/gitbay", "--limit", "31"]), | ||
| 447 | let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], | ||
| 448 | let page = envelope["data"] as? [String: Any], | ||
| 449 | let items = page["items"] as? [[String: Any]], items.count == 31, | ||
| 450 | let number = items[30]["number"] as? Int | ||
| 451 | else { throw XCTSkip("could not read krz/gitbay's builds over the API") } | ||
| 452 | |||
| 453 | openRepo("krz/gitbay") | ||
| 454 | app.staticTexts["Builds"].firstMatch.tap() | ||
| 455 | XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 20), "no builds listed") | ||
| 456 | let thirtyFirst = app.staticTexts | ||
| 457 | .containing(NSPredicate(format: "label BEGINSWITH %@", "#\(number) ")).firstMatch | ||
| 458 | XCTAssertTrue(scrollTo(thirtyFirst, swipes: 20), | ||
| 459 | "build #\(number), the first row of page two, never appeared") | ||
| 460 | } | ||
| 461 | ``` | ||
| 462 | |||
| 463 | - [ ] **Step 3: Build the UI test target** | ||
| 464 | |||
| 465 | ```bash | ||
| 466 | cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 467 | -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' build-for-testing 2>&1 | grep -E "error:|TEST BUILD (SUCCEEDED|FAILED)" | tail -5 | ||
| 468 | ``` | ||
| 469 | |||
| 470 | Expected: `** TEST BUILD SUCCEEDED **`. | ||
| 471 | |||
| 472 | - [ ] **Step 4: Run the two live tests (needs a token from the user)** | ||
| 473 | |||
| 474 | Ask the user for a token for `ios-smoke` (`ssh -F /dev/null -i ~/.ssh/gitbay-ios-smoke -o IdentitiesOnly=yes git@gitbay.org token create --name ui-smoke --scope full --ttl 1d`). `testBuildFilterFlow` fails when krz/gitbay has a build pending or running; wait until `gitbay build list krz/gitbay --status running --json` and `--status pending` both return no rows. | ||
| 475 | |||
| 476 | ```bash | ||
| 477 | cd /Users/cmc/git/krz/gitbay-ios && TEST_RUNNER_GITBAY_UITEST_LIVE=1 TEST_RUNNER_GITBAY_UITEST_TOKEN="$TOKEN" \ | ||
| 478 | xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' \ | ||
| 479 | -only-testing:gitbayUITests/LiveSmokeUITests/testBuildFilterFlow \ | ||
| 480 | -only-testing:gitbayUITests/LiveSmokeUITests/testBuildPagingFlow \ | ||
| 481 | -resultBundlePath /tmp/gb-live.xcresult test 2>&1 | grep -E "error:|passed|failed|TEST (SUCCEEDED|FAILED)" | tail -10 | ||
| 482 | ``` | ||
| 483 | |||
| 484 | Expected: both `passed`. Read failures from the bundle, not stdout. If no token is available, record that the live steps were not run. | ||
| 485 | |||
| 486 | - [ ] **Step 5: Commit, push, open the MR** | ||
| 487 | |||
| 488 | ```bash | ||
| 489 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbayUITests/LiveSmokeUITests.swift && git commit -m "live suite: build rows by subject, and the second page" && \ | ||
| 490 | git push -u origin build-list && gitbay mr create --source build-list --target main \ | ||
| 491 | --title "Build list: name the commit, page past thirty" --file - <<'EOF' | ||
| 492 | - Build decodes subject (gitbay v1.33.0, krz/gitbay#241). A row leads | ||
| 493 | with "#<n> <subject>", falling back to the sha when the commit is | ||
| 494 | gone; job, ref and sha move to the meta line. | ||
| 495 | - The list moves onto PagedListModel at thirty a page (krz/gitbay#244) | ||
| 496 | and keeps server order. | ||
| 497 | |||
| 498 | Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md | ||
| 499 | EOF | ||
| 500 | ``` | ||
| 501 | |||
| 502 | --- | ||
| 503 | |||
| 504 | ### Task 4: Shared chip row and label picker | ||
| 505 | |||
| 506 | A refactor with no behaviour change, so the merge request screens in Tasks 5 and 6 use the issue screens' views rather than a copy. | ||
| 507 | |||
| 508 | **Files:** | ||
| 509 | - Create: `gitbay/Views/Shared/RemovableChips.swift` | ||
| 510 | - Modify: `gitbay/Views/Issues/IssueView.swift:93-96`, `:112-114` (call sites), `:155-181` (delete `labelFlow`) | ||
| 511 | - Modify: `gitbay/Views/Shared/FilterSheet.swift:1-6` (doc comment), `:60-98` (`IssueFilterExtra` → `LabelFilterSection` + assignee) | ||
| 512 | |||
| 513 | **Interfaces:** | ||
| 514 | - Produces: `RemovableChips(items: [String], idPrefix: String, disabled: Bool, color: (String) -> Color = { _ in .secondary }, remove: (String) -> Void)`; `LabelFilterSection(label: Binding<String?>, labels: [IssueLabel], colors: LabelColors)`. `IssueFilterExtra`'s initializer is unchanged. | ||
| 515 | |||
| 516 | - [ ] **Step 1: Branch** | ||
| 517 | |||
| 518 | ```bash | ||
| 519 | cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c mr-labels | ||
| 520 | ``` | ||
| 521 | |||
| 522 | - [ ] **Step 2: Create the chip row** | ||
| 523 | |||
| 524 | Create `gitbay/Views/Shared/RemovableChips.swift`: | ||
| 525 | |||
| 526 | ```swift | ||
| 527 | import SwiftUI | ||
| 528 | |||
| 529 | /// A horizontal row of chips, each with a remove button: an issue's | ||
| 530 | /// labels and assignees, a merge request's labels. The remove button is | ||
| 531 | /// identified as `<idPrefix>-remove-<item>`. | ||
| 532 | struct RemovableChips: View { | ||
| 533 | |||
| 534 | let items: [String] | ||
| 535 | let idPrefix: String | ||
| 536 | let disabled: Bool | ||
| 537 | var color: (String) -> Color = { _ in .secondary } | ||
| 538 | let remove: (String) -> Void | ||
| 539 | |||
| 540 | var body: some View { | ||
| 541 | if !items.isEmpty { | ||
| 542 | ScrollView(.horizontal, showsIndicators: false) { | ||
| 543 | HStack(spacing: 6) { | ||
| 544 | ForEach(items, id: \.self) { item in | ||
| 545 | HStack(spacing: 3) { | ||
| 546 | Text(item) | ||
| 547 | Button { | ||
| 548 | remove(item) | ||
| 549 | } label: { | ||
| 550 | Image(systemName: "xmark.circle.fill") | ||
| 551 | .foregroundStyle(.tertiary) | ||
| 552 | } | ||
| 553 | .disabled(disabled) | ||
| 554 | .accessibilityIdentifier("\(idPrefix)-remove-\(item)") | ||
| 555 | } | ||
| 556 | .font(.gbSans(.caption)) | ||
| 557 | .padding(.horizontal, 8) | ||
| 558 | .padding(.vertical, 3) | ||
| 559 | .background(color(item).opacity(0.07), in: gbChipShape) | ||
| 560 | .overlay(gbChipShape.stroke(color(item).opacity(0.35), lineWidth: 1)) | ||
| 561 | } | ||
| 562 | } | ||
| 563 | } | ||
| 564 | } | ||
| 565 | } | ||
| 566 | } | ||
| 567 | ``` | ||
| 568 | |||
| 569 | - [ ] **Step 3: Use it in the issue screen** | ||
| 570 | |||
| 571 | In `gitbay/Views/Issues/IssueView.swift`, replace | ||
| 572 | |||
| 573 | ```swift | ||
| 574 | labelFlow(issue.labels ?? [], color: { model.colors.color($0) }, remove: { label in | ||
| 575 | Task { await model.removeLabel(label) } | ||
| 576 | }) | ||
| 577 | ``` | ||
| 578 | |||
| 579 | with | ||
| 580 | |||
| 581 | ```swift | ||
| 582 | RemovableChips(items: issue.labels ?? [], idPrefix: "issue-label", | ||
| 583 | disabled: model.working, color: { model.colors.color($0) }, | ||
| 584 | remove: { label in Task { await model.removeLabel(label) } }) | ||
| 585 | ``` | ||
| 586 | |||
| 587 | replace | ||
| 588 | |||
| 589 | ```swift | ||
| 590 | labelFlow(issue.assignees ?? [], remove: { user in | ||
| 591 | Task { await model.removeAssignee(user) } | ||
| 592 | }) | ||
| 593 | ``` | ||
| 594 | |||
| 595 | with | ||
| 596 | |||
| 597 | ```swift | ||
| 598 | RemovableChips(items: issue.assignees ?? [], idPrefix: "issue-assignee", | ||
| 599 | disabled: model.working, | ||
| 600 | remove: { user in Task { await model.removeAssignee(user) } }) | ||
| 601 | ``` | ||
| 602 | |||
| 603 | and delete the whole `private func labelFlow(…)` function with its `@ViewBuilder` attribute. | ||
| 604 | |||
| 605 | - [ ] **Step 4: Split the label picker out of `IssueFilterExtra`** | ||
| 606 | |||
| 607 | In `gitbay/Views/Shared/FilterSheet.swift`, replace the doc comment on `FilterSheet` with: | ||
| 608 | |||
| 609 | ```swift | ||
| 610 | /// The issue and MR lists' filter sheet. A `Form` with the fields both | ||
| 611 | /// lists share — author and milestone — plus an `extra` slot for the | ||
| 612 | /// rows each list adds: the label picker on both, and an assignee field | ||
| 613 | /// on issues only, since `mr list` takes no `--assignee`. | ||
| 614 | ``` | ||
| 615 | |||
| 616 | Replace the whole of `IssueFilterExtra` (the struct and its doc comment) with: | ||
| 617 | |||
| 618 | ```swift | ||
| 619 | /// The label picker both lists' sheets add through `FilterSheet`'s | ||
| 620 | /// `extra` slot. | ||
| 621 | struct LabelFilterSection: View { | ||
| 622 | |||
| 623 | @Binding var label: String? | ||
| 624 | let labels: [IssueLabel] | ||
| 625 | let colors: LabelColors | ||
| 626 | |||
| 627 | var body: some View { | ||
| 628 | Section("Label") { | ||
| 629 | Menu { | ||
| 630 | Button("Any label") { label = nil } | ||
| 631 | ForEach(labels) { entry in | ||
| 632 | Button { | ||
| 633 | label = entry.name | ||
| 634 | } label: { | ||
| 635 | Text(entry.name) | ||
| 636 | } | ||
| 637 | } | ||
| 638 | } label: { | ||
| 639 | HStack { | ||
| 640 | if let label { | ||
| 641 | GBChip(label, colors.color(label)) | ||
| 642 | } else { | ||
| 643 | Text("Any label") | ||
| 644 | } | ||
| 645 | Spacer() | ||
| 646 | Image(systemName: "chevron.up.chevron.down") | ||
| 647 | .font(.gbSans(.caption2)) | ||
| 648 | .foregroundStyle(.secondary) | ||
| 649 | } | ||
| 650 | } | ||
| 651 | .accessibilityIdentifier("filter-label-menu") | ||
| 652 | } | ||
| 653 | } | ||
| 654 | } | ||
| 655 | |||
| 656 | /// The label and assignee rows the issue list's sheet adds through | ||
| 657 | /// `FilterSheet`'s `extra` slot. | ||
| 658 | struct IssueFilterExtra: View { | ||
| 659 | |||
| 660 | @Binding var label: String? | ||
| 661 | @Binding var assignee: String? | ||
| 662 | let labels: [IssueLabel] | ||
| 663 | let colors: LabelColors | ||
| 664 | |||
| 665 | var body: some View { | ||
| 666 | LabelFilterSection(label: $label, labels: labels, colors: colors) | ||
| 667 | Section("Assignee") { | ||
| 668 | TextField("Username", text: $assignee.orEmpty) | ||
| 669 | .autocorrectionDisabled() | ||
| 670 | .textInputAutocapitalization(.never) | ||
| 671 | .accessibilityIdentifier("filter-assignee") | ||
| 672 | } | ||
| 673 | } | ||
| 674 | } | ||
| 675 | ``` | ||
| 676 | |||
| 677 | - [ ] **Step 5: Run the unit target** | ||
| 678 | |||
| 679 | Expected: same count as before this task, 0 failed. Nothing here has a unit test; the issue screens' behaviour is unchanged. | ||
| 680 | |||
| 681 | - [ ] **Step 6: Commit** | ||
| 682 | |||
| 683 | ```bash | ||
| 684 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Views/Shared/RemovableChips.swift gitbay/Views/Issues/IssueView.swift gitbay/Views/Shared/FilterSheet.swift && git commit -m "views: share the chip row and the label picker | ||
| 685 | |||
| 686 | Merge requests are about to carry labels too; both screens use one | ||
| 687 | copy." | ||
| 688 | ``` | ||
| 689 | |||
| 690 | --- | ||
| 691 | |||
| 692 | ### Task 5: Label a merge request | ||
| 693 | |||
| 694 | **Files:** | ||
| 695 | - Modify: `gitbay/MRs/MRModels.swift:75-157` (`MRDetail`: add `labels`) | ||
| 696 | - Modify: `gitbay/MRs/MRDetailViewModel.swift` (add `colors`, `loadLabels()`, `addLabel(_:)`, `removeLabel(_:)`) | ||
| 697 | - Modify: `gitbay/Views/MRs/MRView.swift` (state `editingLabel`, `labelsSection`, call it after `milestoneSection(mr)`) | ||
| 698 | - Create: `gitbayTests/MRLabelTests.swift` | ||
| 699 | |||
| 700 | **Interfaces:** | ||
| 701 | - Consumes: `RemovableChips` (Task 4), `IssueLabel`, `LabelColors`. | ||
| 702 | - Produces: `MRDetail.labels: [String]`; `MRDetailViewModel.colors: LabelColors`, `loadLabels() async`, `addLabel(_ label: String) async`, `removeLabel(_ label: String) async`. | ||
| 703 | |||
| 704 | - [ ] **Step 1: Write the failing tests** | ||
| 705 | |||
| 706 | Create `gitbayTests/MRLabelTests.swift`: | ||
| 707 | |||
| 708 | ```swift | ||
| 709 | import Foundation | ||
| 710 | import Testing | ||
| 711 | @testable import gitbay | ||
| 712 | |||
| 713 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 714 | let box = StubProtocol.box() | ||
| 715 | let client = GitbayClient( | ||
| 716 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 717 | token: "test-token", | ||
| 718 | session: box.session() | ||
| 719 | ) | ||
| 720 | return (client, box) | ||
| 721 | } | ||
| 722 | |||
| 723 | private func mrShow(labels: String?) -> String { | ||
| 724 | let field = labels.map { #","labels":\#($0)"# } ?? "" | ||
| 725 | return """ | ||
| 726 | {"protocol_version":1,"data":{"number":7,"title":"t","state":"open","author":"cmc",\ | ||
| 727 | "source":"feature","target_ref":"main","head_sha":"aa",\ | ||
| 728 | "created_at":"2026-09-22T04:40:16.000Z"\(field)},"exit_code":0} | ||
| 729 | """ | ||
| 730 | } | ||
| 731 | |||
| 732 | private let okJSON = #"{"protocol_version":1,"exit_code":0}"# | ||
| 733 | |||
| 734 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | ||
| 735 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | ||
| 736 | return try #require(body["argv"] as? [String]) | ||
| 737 | } | ||
| 738 | |||
| 739 | @MainActor | ||
| 740 | struct MRLabelTests { | ||
| 741 | |||
| 742 | @Test func mrShowCarriesItsLabels() async throws { | ||
| 743 | let (client, stub) = try makeClient() | ||
| 744 | stub.enqueue(.init(status: 200, json: mrShow(labels: #"["bug","ios"]"#), match: "argv=show")) | ||
| 745 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) | ||
| 746 | |||
| 747 | await model.load() | ||
| 748 | |||
| 749 | #expect(try #require(model.state.value).labels == ["bug", "ios"]) | ||
| 750 | } | ||
| 751 | |||
| 752 | /// The server omits the key when there are none. | ||
| 753 | @Test func noLabelsKeyMeansNoLabels() async throws { | ||
| 754 | let (client, stub) = try makeClient() | ||
| 755 | stub.enqueue(.init(status: 200, json: mrShow(labels: nil), match: "argv=show")) | ||
| 756 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) | ||
| 757 | |||
| 758 | await model.load() | ||
| 759 | |||
| 760 | #expect(try #require(model.state.value).labels.isEmpty) | ||
| 761 | } | ||
| 762 | |||
| 763 | @Test func addingAndRemovingSendMrLabel() async throws { | ||
| 764 | let (client, stub) = try makeClient() | ||
| 765 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) | ||
| 766 | |||
| 767 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 768 | stub.enqueue(.init(status: 200, json: mrShow(labels: #"["bug"]"#), match: "argv=show")) | ||
| 769 | await model.addLabel("bug") | ||
| 770 | stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) | ||
| 771 | stub.enqueue(.init(status: 200, json: mrShow(labels: nil), match: "argv=show")) | ||
| 772 | await model.removeLabel("bug") | ||
| 773 | |||
| 774 | let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf) | ||
| 775 | #expect(writes == [ | ||
| 776 | ["mr", "label", "krz/gitbay", "7", "--add", "bug"], | ||
| 777 | ["mr", "label", "krz/gitbay", "7", "--remove", "bug"], | ||
| 778 | ]) | ||
| 779 | #expect(model.actionError == nil) | ||
| 780 | } | ||
| 781 | |||
| 782 | /// Colours come from the repository's label set, read once. | ||
| 783 | @Test func labelColoursAreReadOnce() async throws { | ||
| 784 | let (client, stub) = try makeClient() | ||
| 785 | stub.enqueue(.init(status: 200, json: """ | ||
| 786 | {"protocol_version":1,"data":[{"name":"bug","color":"#d73a4a","issues":2}],"exit_code":0} | ||
| 787 | """, match: "argv=label&argv=list")) | ||
| 788 | let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) | ||
| 789 | |||
| 790 | await model.loadLabels() | ||
| 791 | await model.loadLabels() | ||
| 792 | |||
| 793 | #expect(stub.seen.count == 1) | ||
| 794 | #expect(stub.seen[0].url.query() == "argv=label&argv=list&argv=krz/gitbay") | ||
| 795 | #expect(model.colors.hex("bug") == LabelPalette.hex(name: "bug", stored: "#d73a4a")) | ||
| 796 | } | ||
| 797 | } | ||
| 798 | ``` | ||
| 799 | |||
| 800 | - [ ] **Step 2: Run the unit target to verify it fails** | ||
| 801 | |||
| 802 | Expected: build FAILS with `value of type 'MRDetail' has no member 'labels'` and `value of type 'MRDetailViewModel' has no member 'addLabel'` (and `removeLabel`, `loadLabels`, `colors`). | ||
| 803 | |||
| 804 | - [ ] **Step 3: Decode `labels` on `MRDetail`** | ||
| 805 | |||
| 806 | In `gitbay/MRs/MRModels.swift`, in `MRDetail`: | ||
| 807 | |||
| 808 | After `let milestone: String?` add: | ||
| 809 | |||
| 810 | ```swift | ||
| 811 | /// Omitted by the server when there are none. | ||
| 812 | let labels: [String] | ||
| 813 | ``` | ||
| 814 | |||
| 815 | In `CodingKeys`, change `case number, title, state, author, source, body, milestone, checks, …` to include `labels` after `milestone`: | ||
| 816 | |||
| 817 | ```swift | ||
| 818 | case number, title, state, author, source, body, milestone, labels, checks, commits, comments, reviews, draft, stacked, gates | ||
| 819 | ``` | ||
| 820 | |||
| 821 | In `init(from:)`, after the `milestone = …` line add: | ||
| 822 | |||
| 823 | ```swift | ||
| 824 | labels = try container.decodeIfPresent([String].self, forKey: .labels) ?? [] | ||
| 825 | ``` | ||
| 826 | |||
| 827 | - [ ] **Step 4: Add the reads and writes to the view model** | ||
| 828 | |||
| 829 | In `gitbay/MRs/MRDetailViewModel.swift`, after `setMilestone(_:)` add: | ||
| 830 | |||
| 831 | ```swift | ||
| 832 | /// The repository's labels, for chip colours. Read on first use from | ||
| 833 | /// the Labels section rather than in `load()`, which stays at four | ||
| 834 | /// reads. | ||
| 835 | private(set) var colors = LabelColors() | ||
| 836 | private var labelsLoaded = false | ||
| 837 | |||
| 838 | func loadLabels() async { | ||
| 839 | guard !labelsLoaded else { return } | ||
| 840 | labelsLoaded = true | ||
| 841 | colors = LabelColors( | ||
| 842 | (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []) | ||
| 843 | } | ||
| 844 | |||
| 845 | /// A label that does not exist yet is created on the repository. | ||
| 846 | func addLabel(_ label: String) async { | ||
| 847 | await perform(["mr", "label"] + ref + ["--add", label]) | ||
| 848 | } | ||
| 849 | |||
| 850 | func removeLabel(_ label: String) async { | ||
| 851 | await perform(["mr", "label"] + ref + ["--remove", label]) | ||
| 852 | } | ||
| 853 | ``` | ||
| 854 | |||
| 855 | - [ ] **Step 5: Run the unit target to verify it passes** | ||
| 856 | |||
| 857 | Expected: 4 more than before this task, 0 failed. `MRViewModelTests` and `MRLifecycleTests` still pass, since `load()` is unchanged. | ||
| 858 | |||
| 859 | - [ ] **Step 6: Add the Labels section** | ||
| 860 | |||
| 861 | In `gitbay/Views/MRs/MRView.swift`, add to the `@State` properties at the top: | ||
| 862 | |||
| 863 | ```swift | ||
| 864 | @State private var editingLabel = "" | ||
| 865 | ``` | ||
| 866 | |||
| 867 | Replace ` milestoneSection(mr)` in `body` with: | ||
| 868 | |||
| 869 | ```swift | ||
| 870 | milestoneSection(mr) | ||
| 871 | labelsSection(mr) | ||
| 872 | ``` | ||
| 873 | |||
| 874 | Add after the `milestoneSection(_:)` function: | ||
| 875 | |||
| 876 | ```swift | ||
| 877 | private func labelsSection(_ mr: MRDetail) -> some View { | ||
| 878 | Section("Labels") { | ||
| 879 | RemovableChips(items: mr.labels, idPrefix: "mr-label", | ||
| 880 | disabled: model.working, color: { model.colors.color($0) }, | ||
| 881 | remove: { label in Task { await model.removeLabel(label) } }) | ||
| 882 | HStack { | ||
| 883 | TextField("Add label", text: $editingLabel) | ||
| 884 | .autocorrectionDisabled() | ||
| 885 | .textInputAutocapitalization(.never) | ||
| 886 | .accessibilityIdentifier("mr-label-field") | ||
| 887 | Button { | ||
| 888 | let label = editingLabel.trimmingCharacters(in: .whitespaces) | ||
| 889 | editingLabel = "" | ||
| 890 | Task { await model.addLabel(label) } | ||
| 891 | } label: { | ||
| 892 | Image(systemName: "plus.circle.fill") | ||
| 893 | } | ||
| 894 | .disabled(editingLabel.trimmingCharacters(in: .whitespaces).isEmpty || model.working) | ||
| 895 | .accessibilityIdentifier("mr-label-add") | ||
| 896 | } | ||
| 897 | } | ||
| 898 | .task { await model.loadLabels() } | ||
| 899 | } | ||
| 900 | ``` | ||
| 901 | |||
| 902 | - [ ] **Step 7: Run the unit target** | ||
| 903 | |||
| 904 | Expected: same count as Step 5, 0 failed. | ||
| 905 | |||
| 906 | - [ ] **Step 8: Commit** | ||
| 907 | |||
| 908 | ```bash | ||
| 909 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/MRs/MRModels.swift gitbay/MRs/MRDetailViewModel.swift gitbay/Views/MRs/MRView.swift gitbayTests/MRLabelTests.swift && git commit -m "mr: labels on the detail screen | ||
| 910 | |||
| 911 | mr label --add/--remove, with chips coloured from the repository's | ||
| 912 | label set, as issues have." | ||
| 913 | ``` | ||
| 914 | |||
| 915 | --- | ||
| 916 | |||
| 917 | ### Task 6: Filter merge requests by label | ||
| 918 | |||
| 919 | **Files:** | ||
| 920 | - Modify: `gitbay/MRs/MRFilter.swift:4-5` (doc comment), `:21-31` (field and flag) | ||
| 921 | - Modify: `gitbay/MRs/MRListViewModel.swift` (add `labels`, `colors`, `loadLabels()`) | ||
| 922 | - Modify: `gitbay/Views/MRs/MRListView.swift:57-69` (filter sheet) | ||
| 923 | - Modify: `gitbayTests/MRLabelTests.swift` (add `MRLabelFilterTests`) | ||
| 924 | - Modify: `gitbayUITests/LiveSmokeUITests.swift` (fixture and `testMRLabelFlow`) | ||
| 925 | |||
| 926 | **Interfaces:** | ||
| 927 | - Consumes: `LabelFilterSection` (Task 4), `until(_:)` from `gitbayTests/StubProtocol.swift`. | ||
| 928 | - Produces: `MRFilter.label: String?`; `MRListViewModel.labels: [IssueLabel]`, `colors: LabelColors`, `loadLabels() async`. | ||
| 929 | |||
| 930 | - [ ] **Step 1: Write the failing tests** | ||
| 931 | |||
| 932 | Append to `gitbayTests/MRLabelTests.swift`: | ||
| 933 | |||
| 934 | ```swift | ||
| 935 | struct MRLabelFilterFlagTests { | ||
| 936 | |||
| 937 | /// Same position as in IssueFilter: after --search, before --author. | ||
| 938 | @Test func aLabelSendsTheLabelFlag() { | ||
| 939 | var filter = MRFilter() | ||
| 940 | filter.search = "gate" | ||
| 941 | filter.label = "bug" | ||
| 942 | filter.author = "cmc" | ||
| 943 | #expect(filter.flags() == ["--state", "open", "--search", "gate", "--label", "bug", "--author", "cmc"]) | ||
| 944 | #expect(filter.isNarrowed) | ||
| 945 | } | ||
| 946 | |||
| 947 | @Test func noLabelSendsNoFlag() { | ||
| 948 | #expect(!MRFilter().flags().contains("--label")) | ||
| 949 | } | ||
| 950 | } | ||
| 951 | |||
| 952 | @MainActor | ||
| 953 | struct MRLabelFilterTests { | ||
| 954 | |||
| 955 | @Test func pickingALabelReloadsWithIt() async throws { | ||
| 956 | let (client, stub) = try makeClient() | ||
| 957 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) | ||
| 958 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 959 | await model.load() | ||
| 960 | |||
| 961 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) | ||
| 962 | model.filter.label = "bug" | ||
| 963 | await until { | ||
| 964 | guard stub.seen.count == 2 else { return false } | ||
| 965 | if case .loading = model.state { return false } | ||
| 966 | return true | ||
| 967 | } | ||
| 968 | |||
| 969 | let query = try #require(stub.seen.last?.url.query()) | ||
| 970 | #expect(query.contains("argv=--label&argv=bug")) | ||
| 971 | } | ||
| 972 | |||
| 973 | @Test func thePickerOffersTheRepositorysLabels() async throws { | ||
| 974 | let (client, stub) = try makeClient() | ||
| 975 | stub.enqueue(.init(status: 200, json: """ | ||
| 976 | {"protocol_version":1,"data":[{"name":"bug","color":"#d73a4a","issues":2},\ | ||
| 977 | {"name":"ios","issues":0}],"exit_code":0} | ||
| 978 | """, match: "argv=label&argv=list")) | ||
| 979 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | ||
| 980 | |||
| 981 | await model.loadLabels() | ||
| 982 | await model.loadLabels() | ||
| 983 | |||
| 984 | #expect(model.labels.map(\.name) == ["bug", "ios"]) | ||
| 985 | #expect(model.colors.hex("bug") == LabelPalette.hex(name: "bug", stored: "#d73a4a")) | ||
| 986 | #expect(stub.seen.count == 1) | ||
| 987 | } | ||
| 988 | } | ||
| 989 | ``` | ||
| 990 | |||
| 991 | - [ ] **Step 2: Run the unit target to verify it fails** | ||
| 992 | |||
| 993 | Expected: build FAILS with `value of type 'MRFilter' has no member 'label'` and `value of type 'MRListViewModel' has no member 'loadLabels'`. | ||
| 994 | |||
| 995 | - [ ] **Step 3: Add the field and its flag** | ||
| 996 | |||
| 997 | In `gitbay/MRs/MRFilter.swift`, replace the doc comment | ||
| 998 | |||
| 999 | ```swift | ||
| 1000 | /// What `mr list` is narrowed to. The command takes no `--label` and no | ||
| 1001 | /// `--assignee`, so this offers neither. | ||
| 1002 | ``` | ||
| 1003 | |||
| 1004 | with | ||
| 1005 | |||
| 1006 | ```swift | ||
| 1007 | /// What `mr list` is narrowed to. The command takes no `--assignee`, so | ||
| 1008 | /// this offers none. | ||
| 1009 | ``` | ||
| 1010 | |||
| 1011 | Replace | ||
| 1012 | |||
| 1013 | ```swift | ||
| 1014 | var state: State = .open | ||
| 1015 | var search = "" | ||
| 1016 | var author: String? | ||
| 1017 | var milestone: String? | ||
| 1018 | |||
| 1019 | func flags() -> [String] { | ||
| 1020 | var flags = ["--state", state.rawValue] | ||
| 1021 | flags += filterFlag("--search", search) | ||
| 1022 | flags += filterFlag("--author", author) | ||
| 1023 | flags += filterFlag("--milestone", milestone) | ||
| 1024 | return flags | ||
| 1025 | } | ||
| 1026 | ``` | ||
| 1027 | |||
| 1028 | with | ||
| 1029 | |||
| 1030 | ```swift | ||
| 1031 | var state: State = .open | ||
| 1032 | var search = "" | ||
| 1033 | var label: String? | ||
| 1034 | var author: String? | ||
| 1035 | var milestone: String? | ||
| 1036 | |||
| 1037 | func flags() -> [String] { | ||
| 1038 | var flags = ["--state", state.rawValue] | ||
| 1039 | flags += filterFlag("--search", search) | ||
| 1040 | flags += filterFlag("--label", label) | ||
| 1041 | flags += filterFlag("--author", author) | ||
| 1042 | flags += filterFlag("--milestone", milestone) | ||
| 1043 | return flags | ||
| 1044 | } | ||
| 1045 | ``` | ||
| 1046 | |||
| 1047 | - [ ] **Step 4: Load the labels for the picker** | ||
| 1048 | |||
| 1049 | In `gitbay/MRs/MRListViewModel.swift`, after `loadMilestones()` add: | ||
| 1050 | |||
| 1051 | ```swift | ||
| 1052 | /// The repository's labels for the filter picker and its chips; | ||
| 1053 | /// fetched on first use, like the milestones. | ||
| 1054 | private(set) var labels: [IssueLabel] = [] | ||
| 1055 | private(set) var colors = LabelColors() | ||
| 1056 | private var labelsLoaded = false | ||
| 1057 | |||
| 1058 | func loadLabels() async { | ||
| 1059 | guard !labelsLoaded else { return } | ||
| 1060 | labelsLoaded = true | ||
| 1061 | labels = (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [] | ||
| 1062 | colors = LabelColors(labels) | ||
| 1063 | } | ||
| 1064 | ``` | ||
| 1065 | |||
| 1066 | A label pick is not one of the typed fields `filter`'s `didSet` debounces, so it reloads immediately; `didSet` needs no change. | ||
| 1067 | |||
| 1068 | - [ ] **Step 5: Run the unit target to verify it passes** | ||
| 1069 | |||
| 1070 | Expected: 4 more than after Task 5, 0 failed. | ||
| 1071 | |||
| 1072 | - [ ] **Step 6: Put the picker in the sheet** | ||
| 1073 | |||
| 1074 | In `gitbay/Views/MRs/MRListView.swift`, replace | ||
| 1075 | |||
| 1076 | ```swift | ||
| 1077 | ) {} | ||
| 1078 | .task { await model.loadMilestones() } | ||
| 1079 | ``` | ||
| 1080 | |||
| 1081 | with | ||
| 1082 | |||
| 1083 | ```swift | ||
| 1084 | ) { | ||
| 1085 | LabelFilterSection( | ||
| 1086 | label: Bindable(model).filter.label, | ||
| 1087 | labels: model.labels, | ||
| 1088 | colors: model.colors | ||
| 1089 | ) | ||
| 1090 | } | ||
| 1091 | .task { | ||
| 1092 | await model.loadMilestones() | ||
| 1093 | await model.loadLabels() | ||
| 1094 | } | ||
| 1095 | ``` | ||
| 1096 | |||
| 1097 | - [ ] **Step 7: Add the live fixture and test** | ||
| 1098 | |||
| 1099 | In `gitbayUITests/LiveSmokeUITests.swift`: | ||
| 1100 | |||
| 1101 | After `static let successorTitle = "UI smoke: successor"` add: | ||
| 1102 | |||
| 1103 | ```swift | ||
| 1104 | static let labelledTitle = "UI smoke: labels" | ||
| 1105 | static let smokeMRLabel = "ui-smoke-mr-label" | ||
| 1106 | ``` | ||
| 1107 | |||
| 1108 | In `setUpWithError()`, after the `testCloseInFavourFlow` block, add: | ||
| 1109 | |||
| 1110 | ```swift | ||
| 1111 | if name.contains("testMRLabelFlow") { | ||
| 1112 | try requireFixtureRepo() | ||
| 1113 | try requireFixtureBranch() | ||
| 1114 | closeSmokeMergeRequests() | ||
| 1115 | _ = runCommand(["mr", "create", Self.fixtureRepo, "--source", "ui-smoke", "--target", "main", | ||
| 1116 | "--title", Self.labelledTitle]) | ||
| 1117 | try XCTSkipUnless(openSmokeMergeRequests().map { $0.title }.contains(Self.labelledTitle), | ||
| 1118 | "could not open the label fixture merge request on \(Self.fixtureRepo)") | ||
| 1119 | } | ||
| 1120 | ``` | ||
| 1121 | |||
| 1122 | In `tearDownWithError()`, add: | ||
| 1123 | |||
| 1124 | ```swift | ||
| 1125 | if name.contains("testMRLabelFlow") { | ||
| 1126 | closeSmokeMergeRequests() | ||
| 1127 | _ = runCommand(["label", "remove", Self.fixtureRepo, Self.smokeMRLabel]) | ||
| 1128 | } | ||
| 1129 | ``` | ||
| 1130 | |||
| 1131 | After `testCloseInFavourFlow()`, add: | ||
| 1132 | |||
| 1133 | ```swift | ||
| 1134 | /// A label added from the merge request screen shows as a chip, the | ||
| 1135 | /// list filters to it, and removing it clears the chip. | ||
| 1136 | func testMRLabelFlow() throws { | ||
| 1137 | openRepo(Self.fixtureRepo) | ||
| 1138 | app.staticTexts["Merge Requests"].firstMatch.tap() | ||
| 1139 | let row = app.staticTexts[Self.labelledTitle].firstMatch | ||
| 1140 | XCTAssertTrue(row.waitForExistence(timeout: 15), "fixture MR not listed") | ||
| 1141 | row.tap() | ||
| 1142 | |||
| 1143 | let field = app.textFields["mr-label-field"].firstMatch | ||
| 1144 | XCTAssertTrue(scrollTo(field), "no label field on the merge request") | ||
| 1145 | field.tap() | ||
| 1146 | field.typeText(Self.smokeMRLabel) | ||
| 1147 | app.buttons["mr-label-add"].firstMatch.tap() | ||
| 1148 | let remove = app.buttons["mr-label-remove-\(Self.smokeMRLabel)"].firstMatch | ||
| 1149 | XCTAssertTrue(remove.waitForExistence(timeout: 15), "added label has no chip") | ||
| 1150 | |||
| 1151 | back() | ||
| 1152 | app.descendants(matching: .any).matching(identifier: "mr-filter-button").firstMatch.tap() | ||
| 1153 | let menu = app.descendants(matching: .any).matching(identifier: "filter-label-menu").firstMatch | ||
| 1154 | XCTAssertTrue(menu.waitForExistence(timeout: 10), "MR filter sheet has no label picker") | ||
| 1155 | menu.tap() | ||
| 1156 | let choice = app.buttons[Self.smokeMRLabel].firstMatch | ||
| 1157 | XCTAssertTrue(choice.waitForExistence(timeout: 10), "label not offered in the picker") | ||
| 1158 | choice.tap() | ||
| 1159 | app.buttons["Done"].firstMatch.tap() | ||
| 1160 | XCTAssertTrue(row.waitForExistence(timeout: 15), "label filter dropped the labelled MR") | ||
| 1161 | |||
| 1162 | row.tap() | ||
| 1163 | XCTAssertTrue(scrollTo(remove), "chip missing on reopen") | ||
| 1164 | remove.tap() | ||
| 1165 | XCTAssertTrue(remove.waitForNonExistence(timeout: 15), "removed label still shown") | ||
| 1166 | } | ||
| 1167 | ``` | ||
| 1168 | |||
| 1169 | - [ ] **Step 8: Build the UI test target** | ||
| 1170 | |||
| 1171 | ```bash | ||
| 1172 | cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 1173 | -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' build-for-testing 2>&1 | grep -E "error:|TEST BUILD (SUCCEEDED|FAILED)" | tail -5 | ||
| 1174 | ``` | ||
| 1175 | |||
| 1176 | Expected: `** TEST BUILD SUCCEEDED **`. | ||
| 1177 | |||
| 1178 | - [ ] **Step 9: Run the live test (needs a token from the user)** | ||
| 1179 | |||
| 1180 | ```bash | ||
| 1181 | cd /Users/cmc/git/krz/gitbay-ios && TEST_RUNNER_GITBAY_UITEST_LIVE=1 TEST_RUNNER_GITBAY_UITEST_TOKEN="$TOKEN" \ | ||
| 1182 | xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' \ | ||
| 1183 | -only-testing:gitbayUITests/LiveSmokeUITests/testMRLabelFlow \ | ||
| 1184 | -resultBundlePath /tmp/gb-live.xcresult test 2>&1 | grep -E "error:|passed|failed|TEST (SUCCEEDED|FAILED)" | tail -10 | ||
| 1185 | ``` | ||
| 1186 | |||
| 1187 | Expected: `passed`. If the run dies mid-flow, close any open "UI smoke:" MR on `ios-smoke/ui-smoke-edit` and run `label remove ios-smoke/ui-smoke-edit ui-smoke-mr-label` over the API before rerunning. | ||
| 1188 | |||
| 1189 | - [ ] **Step 10: Commit, push, open the MR** | ||
| 1190 | |||
| 1191 | ```bash | ||
| 1192 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/MRs/MRFilter.swift gitbay/MRs/MRListViewModel.swift gitbay/Views/MRs/MRListView.swift gitbayTests/MRLabelTests.swift gitbayUITests/LiveSmokeUITests.swift && git commit -m "mr: filter the list by label | ||
| 1193 | |||
| 1194 | mr list takes --label; MRFilter's comment said it did not." && \ | ||
| 1195 | git push -u origin mr-labels && gitbay mr create --source mr-labels --target main \ | ||
| 1196 | --title "Merge request labels and filter by label" --file - <<'EOF' | ||
| 1197 | - MRDetail decodes labels. The detail screen adds and removes them | ||
| 1198 | with mr label --add/--remove, coloured from the repository's set. | ||
| 1199 | - MRFilter gains --label; the list's filter sheet offers the | ||
| 1200 | repository's labels. | ||
| 1201 | - The chip row and label picker the issue screens had are shared views | ||
| 1202 | now, used by both. | ||
| 1203 | |||
| 1204 | Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md | ||
| 1205 | EOF | ||
| 1206 | ``` | ||
| 1207 | |||
| 1208 | --- | ||
| 1209 | |||
| 1210 | ### Task 7: Bump to 1.4.0 (13) | ||
| 1211 | |||
| 1212 | Starts only after the user has merged both MRs from Tasks 3 and 6. | ||
| 1213 | |||
| 1214 | **Files:** | ||
| 1215 | - Modify: `gitbay.xcodeproj/project.pbxproj` (two `MARKETING_VERSION` and two `CURRENT_PROJECT_VERSION` lines) | ||
| 1216 | |||
| 1217 | - [ ] **Step 1: Branch from the merged main** | ||
| 1218 | |||
| 1219 | ```bash | ||
| 1220 | cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c bump-1.4.0 | ||
| 1221 | ``` | ||
| 1222 | |||
| 1223 | - [ ] **Step 2: Bump the four lines** | ||
| 1224 | |||
| 1225 | ```bash | ||
| 1226 | cd /Users/cmc/git/krz/gitbay-ios && sed -i '' -e 's/MARKETING_VERSION = 1.3.0;/MARKETING_VERSION = 1.4.0;/' \ | ||
| 1227 | -e 's/CURRENT_PROJECT_VERSION = 12;/CURRENT_PROJECT_VERSION = 13;/' gitbay.xcodeproj/project.pbxproj && \ | ||
| 1228 | git diff --stat && plutil -lint gitbay.xcodeproj/project.pbxproj | ||
| 1229 | ``` | ||
| 1230 | |||
| 1231 | Expected: `1 file changed, 4 insertions(+), 4 deletions(-)` and `OK`. | ||
| 1232 | |||
| 1233 | - [ ] **Step 3: Verify on both runtimes and a Release device build** | ||
| 1234 | |||
| 1235 | Run the unit target on iPhone 17 Pro (26.5), then on iOS 18.6: | ||
| 1236 | |||
| 1237 | ```bash | ||
| 1238 | cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 1239 | -destination 'id=9F8E5D84-0A15-4C64-8A45-994667A73817' -only-testing:gitbayTests test 2>&1 | grep -E "TEST (SUCCEEDED|FAILED)" | ||
| 1240 | xcodebuild build -scheme gitbay -configuration Release -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO 2>&1 \ | ||
| 1241 | | grep -E "error:|While running pass|BUILD (SUCCEEDED|FAILED)" | tail -5 | ||
| 1242 | ``` | ||
| 1243 | |||
| 1244 | Expected: `** TEST SUCCEEDED **` and `** BUILD SUCCEEDED **`. | ||
| 1245 | |||
| 1246 | - [ ] **Step 4: Commit, push, open the MR** | ||
| 1247 | |||
| 1248 | ```bash | ||
| 1249 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay.xcodeproj/project.pbxproj && git commit -m "Bump to 1.4.0 (13)" && \ | ||
| 1250 | git push -u origin bump-1.4.0 && gitbay mr create --source bump-1.4.0 --target main --title "Bump to 1.4.0 (13)" --body "Build list subject and paging; MR labels and filter by label." | ||
| 1251 | ``` | ||
| 1252 | |||
| 1253 | Tagging `v1.4.0` on the merge commit, archiving and uploading are the user's. | ||
| 1254 | |||
| 1255 | - [ ] **Step 5: Flip the Parity rows upstream** | ||
| 1256 | |||
| 1257 | Once 1.4.0 is released, in `~/git/krz/gitbay` on a new branch `parity-ios-1.4.0`, set the iOS column to `yes` for `labels` and `filter by label` under Merge requests, and for `build list paging (limit, cursor)` and `build row names its commit` under Repositories. Commit, push, and open the MR with the title "Parity: iOS 1.4.0 rows". | ||
docs/superpowers/plans/2026-09-22-1.5.0-markup-previews.md added +433
| @@ -0,0 +1,433 @@ | |||
| 1 | # 1.5.0 Markup Previews 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:** Every editor that takes markup — issue and MR bodies and comments, release notes, and a markup file in the file editor — can show its draft rendered before it is sent. | ||
| 6 | |||
| 7 | **Architecture:** One shared view, `MarkupPreview`, a collapsed "Preview" disclosure row placed under each editor. Expanded, it renders the draft with `FormattedBodyView`, the view the app's threads already render bodies and comments with, so the preview is what the app will show. A static `format(forFile:)` decides whether a file in the editor renders at all, and as which format. | ||
| 8 | |||
| 9 | **Tech Stack:** Swift 6 language mode with default `MainActor` isolation, SwiftUI, Swift Testing, XCUITest. iOS 18.0 minimum, iOS 26.5 SDK. Rendering through `OrgSwiftUI` and the app's `MarkdownView`. | ||
| 10 | |||
| 11 | **Spec:** `docs/superpowers/specs/2026-09-22-parity-followup-design.md` (section "1.5.0: markup previews") | ||
| 12 | |||
| 13 | ## Global Constraints | ||
| 14 | |||
| 15 | - **Never attribute anything to an assistant or model** — not in commits, code comments, MR bodies, or docs. No `Co-Authored-By` trailer. This is absolute. | ||
| 16 | - **Commits are signed.** `commit.gpgsign` is on; do not pass `--no-gpg-sign`. | ||
| 17 | - **Never push to `main`. Never merge.** One branch, `markup-preview`, cut from `main` after 1.4.0 has merged. Task 4 is the version bump and waits for the user to merge it. | ||
| 18 | - **File-system-synchronized groups.** New `.swift` files under `gitbay/` and `gitbayTests/` are picked up automatically. Do not edit `project.pbxproj` except for the version lines in Task 4. | ||
| 19 | - **The preview promises the app's rendering, not the web's.** Neither the app's threads nor the preview resolve autolinks (`#N`, `owner/name#N`). Say nothing in copy or comments that implies otherwise. | ||
| 20 | - **Design tokens only.** Fonts are `.gbSans(…)`/`.gbMono(…)`; colours come from the `gb` palette or the system semantic styles. No new visual treatment: a `DisclosureGroup` row in the existing `Form`/`List` is the whole UI. | ||
| 21 | - Comments explain *why*, in plain direct English, at the density of the surrounding code. No before/after commentary. | ||
| 22 | - **Run the whole unit target, and read the count.** | ||
| 23 | |||
| 24 | ```bash | ||
| 25 | cd /Users/cmc/git/krz/gitbay-ios && rm -rf /tmp/gb.xcresult && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 26 | -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' -only-testing:gitbayTests \ | ||
| 27 | -resultBundlePath /tmp/gb.xcresult test 2>&1 | grep -E "error:|failed|TEST (SUCCEEDED|FAILED)" | tail -20 | ||
| 28 | xcrun xcresulttool get test-results summary --path /tmp/gb.xcresult --format json \ | ||
| 29 | | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['result'], d['totalTestCount'], d['failedTests'])" | ||
| 30 | ``` | ||
| 31 | |||
| 32 | "Run the unit target" below means these two commands. Record the count before Task 1 as `BASE`. | ||
| 33 | |||
| 34 | --- | ||
| 35 | |||
| 36 | ### Task 1: `MarkupPreview` | ||
| 37 | |||
| 38 | **Files:** | ||
| 39 | - Create: `gitbay/Views/Shared/MarkupPreview.swift` | ||
| 40 | - Create: `gitbayTests/MarkupPreviewTests.swift` | ||
| 41 | |||
| 42 | **Interfaces:** | ||
| 43 | - Consumes: `FormattedBodyView(text: String, format: String?)` in `gitbay/Views/Shared/`. | ||
| 44 | - Produces: `MarkupPreview(text: String, format: String?, identifier: String)`; `static func MarkupPreview.format(forFile name: String) -> String?` returning `"org"`, `"md"` or `nil`. | ||
| 45 | |||
| 46 | - [ ] **Step 1: Branch** | ||
| 47 | |||
| 48 | ```bash | ||
| 49 | cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c markup-preview | ||
| 50 | ``` | ||
| 51 | |||
| 52 | Run the unit target and record the count as `BASE`. | ||
| 53 | |||
| 54 | - [ ] **Step 2: Write the failing tests** | ||
| 55 | |||
| 56 | Create `gitbayTests/MarkupPreviewTests.swift`: | ||
| 57 | |||
| 58 | ```swift | ||
| 59 | import Testing | ||
| 60 | @testable import gitbay | ||
| 61 | |||
| 62 | struct MarkupPreviewFormatTests { | ||
| 63 | |||
| 64 | @Test func orgFilesRenderAsOrg() { | ||
| 65 | #expect(MarkupPreview.format(forFile: "README.org") == "org") | ||
| 66 | #expect(MarkupPreview.format(forFile: "profile/README.org") == "org") | ||
| 67 | } | ||
| 68 | |||
| 69 | @Test func markdownFilesRenderAsMarkdown() { | ||
| 70 | #expect(MarkupPreview.format(forFile: "README.md") == "md") | ||
| 71 | #expect(MarkupPreview.format(forFile: "notes.markdown") == "md") | ||
| 72 | #expect(MarkupPreview.format(forFile: "CHANGES.MD") == "md") | ||
| 73 | } | ||
| 74 | |||
| 75 | /// Source files show as source, so there is nothing to preview. | ||
| 76 | @Test func otherFilesHaveNoPreview() { | ||
| 77 | #expect(MarkupPreview.format(forFile: "main.swift") == nil) | ||
| 78 | #expect(MarkupPreview.format(forFile: "Makefile") == nil) | ||
| 79 | #expect(MarkupPreview.format(forFile: "notes.txt") == nil) | ||
| 80 | } | ||
| 81 | } | ||
| 82 | ``` | ||
| 83 | |||
| 84 | - [ ] **Step 3: Run the unit target to verify it fails** | ||
| 85 | |||
| 86 | Expected: build FAILS with `cannot find 'MarkupPreview' in scope`. | ||
| 87 | |||
| 88 | - [ ] **Step 4: Create the view** | ||
| 89 | |||
| 90 | Create `gitbay/Views/Shared/MarkupPreview.swift`: | ||
| 91 | |||
| 92 | ```swift | ||
| 93 | import SwiftUI | ||
| 94 | |||
| 95 | /// A collapsed "Preview" row under a markup editor. Expanded, it renders | ||
| 96 | /// the draft with `FormattedBodyView`, the view the app's threads use, so | ||
| 97 | /// what it shows is what the app will show once the text is sent. It sits | ||
| 98 | /// under the editor rather than replacing it, so the draft stays in view. | ||
| 99 | struct MarkupPreview: View { | ||
| 100 | |||
| 101 | let text: String | ||
| 102 | let format: String? | ||
| 103 | let identifier: String | ||
| 104 | |||
| 105 | @State private var expanded = false | ||
| 106 | |||
| 107 | var body: some View { | ||
| 108 | DisclosureGroup("Preview", isExpanded: $expanded) { | ||
| 109 | if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { | ||
| 110 | Text("Nothing to preview.") | ||
| 111 | .font(.gbSans(.subheadline)) | ||
| 112 | .foregroundStyle(.secondary) | ||
| 113 | } else { | ||
| 114 | FormattedBodyView(text: text, format: format) | ||
| 115 | .padding(.vertical, 4) | ||
| 116 | } | ||
| 117 | } | ||
| 118 | .font(.gbSans(.subheadline)) | ||
| 119 | .accessibilityIdentifier(identifier) | ||
| 120 | } | ||
| 121 | |||
| 122 | /// The format a file's contents render in, or nil for a file shown as | ||
| 123 | /// source. The same extensions the forge renders a README or wiki | ||
| 124 | /// page from. | ||
| 125 | static func format(forFile name: String) -> String? { | ||
| 126 | switch (name as NSString).pathExtension.lowercased() { | ||
| 127 | case "org": "org" | ||
| 128 | case "md", "markdown": "md" | ||
| 129 | default: nil | ||
| 130 | } | ||
| 131 | } | ||
| 132 | } | ||
| 133 | ``` | ||
| 134 | |||
| 135 | - [ ] **Step 5: Run the unit target to verify it passes** | ||
| 136 | |||
| 137 | Expected: `Passed <BASE + 3> 0`. | ||
| 138 | |||
| 139 | - [ ] **Step 6: Commit** | ||
| 140 | |||
| 141 | ```bash | ||
| 142 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Views/Shared/MarkupPreview.swift gitbayTests/MarkupPreviewTests.swift && git commit -m "views: MarkupPreview, a draft rendered as the thread renders it" | ||
| 143 | ``` | ||
| 144 | |||
| 145 | --- | ||
| 146 | |||
| 147 | ### Task 2: Put it under every markup editor | ||
| 148 | |||
| 149 | **Files:** | ||
| 150 | - Modify: `gitbay/Views/Shared/ComposeSheet.swift:63-75` (Body section) | ||
| 151 | - Modify: `gitbay/Views/MRs/MRListView.swift:114-125` (`MRCreateSheet` Body section) | ||
| 152 | - Modify: `gitbay/Views/Issues/IssueView.swift:203-226` (comment row) | ||
| 153 | - Modify: `gitbay/Views/MRs/MRView.swift:538-561` (comment row) | ||
| 154 | - Modify: `gitbay/Views/Releases/ReleaseListView.swift:106-110` (`Notes` section) | ||
| 155 | - Modify: `gitbay/Views/Repos/FileEditSheet.swift:18-28` (content section) | ||
| 156 | |||
| 157 | **Interfaces:** | ||
| 158 | - Consumes: `MarkupPreview(text:format:identifier:)`, `MarkupPreview.format(forFile:)` (Task 1); `FileViewModel.fileName: String`. | ||
| 159 | - Produces: accessibility identifiers `compose-preview`, `mr-create-preview`, `issue-comment-field`, `issue-comment-preview`, `mr-comment-field`, `mr-comment-preview`, `release-notes-preview`, `file-edit-preview`. | ||
| 160 | |||
| 161 | Line numbers are as of `main` after 1.4.0; find each block by its content. | ||
| 162 | |||
| 163 | - [ ] **Step 1: Issue and MR create/edit, release edit** | ||
| 164 | |||
| 165 | In `gitbay/Views/Shared/ComposeSheet.swift`, in the `Section("Body")`, after the `if showsFormat { Picker("Format", …) … }` block, add: | ||
| 166 | |||
| 167 | ```swift | ||
| 168 | MarkupPreview(text: bodyText, format: format, identifier: "compose-preview") | ||
| 169 | ``` | ||
| 170 | |||
| 171 | `format` is the constant `"md"` where the sheet shows no picker, which is what the release editor sends. | ||
| 172 | |||
| 173 | - [ ] **Step 2: MR create** | ||
| 174 | |||
| 175 | In `gitbay/Views/MRs/MRListView.swift`, in `MRCreateSheet`'s `Section("Body")`, after the `Picker("Format", selection: $format) { … }` block with its modifiers, add: | ||
| 176 | |||
| 177 | ```swift | ||
| 178 | MarkupPreview(text: bodyText, format: format, identifier: "mr-create-preview") | ||
| 179 | ``` | ||
| 180 | |||
| 181 | - [ ] **Step 3: Issue comments** | ||
| 182 | |||
| 183 | In `gitbay/Views/Issues/IssueView.swift`, in the comment `HStack`, give the field an identifier: change | ||
| 184 | |||
| 185 | ```swift | ||
| 186 | TextField("Comment", text: $commentText, axis: .vertical) | ||
| 187 | .lineLimit(1...5) | ||
| 188 | ``` | ||
| 189 | |||
| 190 | to | ||
| 191 | |||
| 192 | ```swift | ||
| 193 | TextField("Comment", text: $commentText, axis: .vertical) | ||
| 194 | .lineLimit(1...5) | ||
| 195 | .accessibilityIdentifier("issue-comment-field") | ||
| 196 | ``` | ||
| 197 | |||
| 198 | and immediately after the closing brace of that `HStack`, add: | ||
| 199 | |||
| 200 | ```swift | ||
| 201 | if !commentText.isEmpty { | ||
| 202 | MarkupPreview(text: commentText, format: commentFormat, | ||
| 203 | identifier: "issue-comment-preview") | ||
| 204 | } | ||
| 205 | ``` | ||
| 206 | |||
| 207 | - [ ] **Step 4: MR comments** | ||
| 208 | |||
| 209 | In `gitbay/Views/MRs/MRView.swift`, make the same two changes to the comment `HStack` with the identifiers `mr-comment-field` and `mr-comment-preview`: | ||
| 210 | |||
| 211 | ```swift | ||
| 212 | TextField("Comment", text: $commentText, axis: .vertical) | ||
| 213 | .lineLimit(1...5) | ||
| 214 | .accessibilityIdentifier("mr-comment-field") | ||
| 215 | ``` | ||
| 216 | |||
| 217 | ```swift | ||
| 218 | if !commentText.isEmpty { | ||
| 219 | MarkupPreview(text: commentText, format: commentFormat, | ||
| 220 | identifier: "mr-comment-preview") | ||
| 221 | } | ||
| 222 | ``` | ||
| 223 | |||
| 224 | - [ ] **Step 5: Release notes on create** | ||
| 225 | |||
| 226 | In `gitbay/Views/Releases/ReleaseListView.swift`, replace | ||
| 227 | |||
| 228 | ```swift | ||
| 229 | Section("Notes") { | ||
| 230 | TextEditor(text: $notes) | ||
| 231 | .frame(minHeight: 140) | ||
| 232 | .autocorrectionDisabled() | ||
| 233 | } | ||
| 234 | ``` | ||
| 235 | |||
| 236 | with | ||
| 237 | |||
| 238 | ```swift | ||
| 239 | Section("Notes") { | ||
| 240 | TextEditor(text: $notes) | ||
| 241 | .frame(minHeight: 140) | ||
| 242 | .autocorrectionDisabled() | ||
| 243 | // release create sends no --format, and release notes | ||
| 244 | // render as markdown. | ||
| 245 | MarkupPreview(text: notes, format: "md", identifier: "release-notes-preview") | ||
| 246 | } | ||
| 247 | ``` | ||
| 248 | |||
| 249 | - [ ] **Step 6: Markup files in the file editor** | ||
| 250 | |||
| 251 | In `gitbay/Views/Repos/FileEditSheet.swift`, after the `Section { TextEditor(text: $content) … } header: { … } footer: { … }` block and before `Section("Message")`, add: | ||
| 252 | |||
| 253 | ```swift | ||
| 254 | if let format = MarkupPreview.format(forFile: model.fileName) { | ||
| 255 | Section { | ||
| 256 | MarkupPreview(text: content, format: format, identifier: "file-edit-preview") | ||
| 257 | } | ||
| 258 | } | ||
| 259 | ``` | ||
| 260 | |||
| 261 | - [ ] **Step 7: Build and run the unit target** | ||
| 262 | |||
| 263 | Expected: `Passed <BASE + 3> 0`. The call sites have no unit tests; Task 3's live step drives one end to end. | ||
| 264 | |||
| 265 | - [ ] **Step 8: Check the design greps** | ||
| 266 | |||
| 267 | ```bash | ||
| 268 | cd /Users/cmc/git/krz/gitbay-ios && grep -rnE '\.(green|red|blue|orange|yellow|purple)\b' gitbay/ --include=*.swift | grep -v gb; \ | ||
| 269 | grep -rn '\.font(\.' gitbay/Views/Shared/MarkupPreview.swift | grep -vE 'gbSans|gbMono' | ||
| 270 | ``` | ||
| 271 | |||
| 272 | Expected: no output from either. | ||
| 273 | |||
| 274 | - [ ] **Step 9: Commit** | ||
| 275 | |||
| 276 | ```bash | ||
| 277 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Views/Shared/ComposeSheet.swift gitbay/Views/MRs/MRListView.swift gitbay/Views/Issues/IssueView.swift gitbay/Views/MRs/MRView.swift gitbay/Views/Releases/ReleaseListView.swift gitbay/Views/Repos/FileEditSheet.swift && git commit -m "Preview under every markup editor | ||
| 278 | |||
| 279 | Issue and MR bodies on create and edit, their comments, release notes, | ||
| 280 | and a .md, .markdown or .org file in the file editor." | ||
| 281 | ``` | ||
| 282 | |||
| 283 | --- | ||
| 284 | |||
| 285 | ### Task 3: Live step, then the MR | ||
| 286 | |||
| 287 | **Files:** | ||
| 288 | - Modify: `gitbayUITests/LiveSmokeUITests.swift` (new `testMarkupPreviewFlow()` and its `setUpWithError` guard) | ||
| 289 | |||
| 290 | **Interfaces:** | ||
| 291 | - Consumes: `Self.fixtureRepo`, `Self.fixtureIssueTitle`, `requireFixtureRepo()`, `ensureFixtureIssue()`, `openRepo(_:)`, `scrollTo(_:swipes:)`; identifiers `issue-comment-field`, `issue-comment-preview` (Task 2). | ||
| 292 | |||
| 293 | The test types a comment and never sends it. | ||
| 294 | |||
| 295 | - [ ] **Step 1: Add the fixture guard** | ||
| 296 | |||
| 297 | In `setUpWithError()`, after the last `if name.contains(…)` block, add: | ||
| 298 | |||
| 299 | ```swift | ||
| 300 | if name.contains("testMarkupPreviewFlow") { | ||
| 301 | try requireFixtureRepo() | ||
| 302 | ensureFixtureIssue() | ||
| 303 | } | ||
| 304 | ``` | ||
| 305 | |||
| 306 | - [ ] **Step 2: Add the test** | ||
| 307 | |||
| 308 | Add after the last test in the class: | ||
| 309 | |||
| 310 | ```swift | ||
| 311 | /// A comment's preview renders its markup before anything is sent: | ||
| 312 | /// the bold markers are gone and the words remain. Nothing is posted. | ||
| 313 | func testMarkupPreviewFlow() throws { | ||
| 314 | openRepo(Self.fixtureRepo) | ||
| 315 | app.staticTexts["Issues"].firstMatch.tap() | ||
| 316 | let issue = app.staticTexts[Self.fixtureIssueTitle].firstMatch | ||
| 317 | XCTAssertTrue(issue.waitForExistence(timeout: 15), "fixture issue not listed") | ||
| 318 | issue.tap() | ||
| 319 | |||
| 320 | let field = app.descendants(matching: .any) | ||
| 321 | .matching(identifier: "issue-comment-field").firstMatch | ||
| 322 | XCTAssertTrue(scrollTo(field, swipes: 10), "no comment field on the issue") | ||
| 323 | field.tap() | ||
| 324 | let draft = "**ui-smoke preview**" | ||
| 325 | field.typeText(draft) | ||
| 326 | |||
| 327 | let preview = app.descendants(matching: .any) | ||
| 328 | .matching(identifier: "issue-comment-preview").firstMatch | ||
| 329 | XCTAssertTrue(scrollTo(preview), "no preview row for a non-empty comment") | ||
| 330 | preview.tap() | ||
| 331 | XCTAssertTrue(app.staticTexts | ||
| 332 | .containing(NSPredicate(format: "label CONTAINS 'ui-smoke preview'")).firstMatch | ||
| 333 | .waitForExistence(timeout: 5), "preview did not render the draft") | ||
| 334 | XCTAssertFalse(app.staticTexts | ||
| 335 | .containing(NSPredicate(format: "label CONTAINS '**ui-smoke'")).firstMatch.exists, | ||
| 336 | "preview shows the raw markup") | ||
| 337 | |||
| 338 | // Leave the fixture as found: clear the draft, send nothing. | ||
| 339 | XCTAssertTrue(scrollTo(field), "comment field scrolled away") | ||
| 340 | field.tap() | ||
| 341 | field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: draft.count)) | ||
| 342 | XCTAssertFalse(preview.exists, "preview row outlived an empty comment") | ||
| 343 | } | ||
| 344 | ``` | ||
| 345 | |||
| 346 | - [ ] **Step 3: Build the UI test target** | ||
| 347 | |||
| 348 | ```bash | ||
| 349 | cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 350 | -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' build-for-testing 2>&1 | grep -E "error:|TEST BUILD (SUCCEEDED|FAILED)" | tail -5 | ||
| 351 | ``` | ||
| 352 | |||
| 353 | Expected: `** TEST BUILD SUCCEEDED **`. | ||
| 354 | |||
| 355 | - [ ] **Step 4: Run the live test (needs a token from the user)** | ||
| 356 | |||
| 357 | Ask the user for an `ios-smoke` token (`ssh -F /dev/null -i ~/.ssh/gitbay-ios-smoke -o IdentitiesOnly=yes git@gitbay.org token create --name ui-smoke --scope full --ttl 1d`). | ||
| 358 | |||
| 359 | ```bash | ||
| 360 | cd /Users/cmc/git/krz/gitbay-ios && TEST_RUNNER_GITBAY_UITEST_LIVE=1 TEST_RUNNER_GITBAY_UITEST_TOKEN="$TOKEN" \ | ||
| 361 | xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' \ | ||
| 362 | -only-testing:gitbayUITests/LiveSmokeUITests/testMarkupPreviewFlow \ | ||
| 363 | -resultBundlePath /tmp/gb-live.xcresult test 2>&1 | grep -E "error:|passed|failed|TEST (SUCCEEDED|FAILED)" | tail -10 | ||
| 364 | ``` | ||
| 365 | |||
| 366 | Expected: `passed`. If the markdown renderer splits the bold run into a separate element whose label is exactly `ui-smoke preview`, the `CONTAINS` predicate still matches. | ||
| 367 | |||
| 368 | - [ ] **Step 5: Look at one preview in the simulator** | ||
| 369 | |||
| 370 | Launch the app on the iPhone 17 Pro simulator, open any issue's New Issue sheet, type a heading and a list in Org format, expand Preview, and screenshot it in light and dark. Confirm it uses the same type and colours as a rendered comment in a thread. Record the result in the MR body. | ||
| 371 | |||
| 372 | - [ ] **Step 6: Commit, push, open the MR** | ||
| 373 | |||
| 374 | ```bash | ||
| 375 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbayUITests/LiveSmokeUITests.swift && git commit -m "live suite: a comment preview renders its markup" && \ | ||
| 376 | git push -u origin markup-preview && gitbay mr create --source markup-preview --target main \ | ||
| 377 | --title "Preview markup before sending" --file - <<'EOF' | ||
| 378 | A collapsed Preview row under every markup editor: issue and MR create | ||
| 379 | and edit, their comments, release create and edit, and a .md, | ||
| 380 | .markdown or .org file in the file editor. It renders the draft with | ||
| 381 | FormattedBodyView, the view the app's threads use, so it shows what the | ||
| 382 | app will show. Like the threads, it resolves no autolinks; the web's | ||
| 383 | preview does. | ||
| 384 | |||
| 385 | Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md | ||
| 386 | EOF | ||
| 387 | ``` | ||
| 388 | |||
| 389 | --- | ||
| 390 | |||
| 391 | ### Task 4: Bump to 1.5.0 (14) | ||
| 392 | |||
| 393 | Starts only after the user has merged the MR from Task 3. | ||
| 394 | |||
| 395 | **Files:** | ||
| 396 | - Modify: `gitbay.xcodeproj/project.pbxproj` (the four version lines) | ||
| 397 | |||
| 398 | - [ ] **Step 1: Branch, bump, verify** | ||
| 399 | |||
| 400 | ```bash | ||
| 401 | cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c bump-1.5.0 && \ | ||
| 402 | sed -i '' -e 's/MARKETING_VERSION = 1.4.0;/MARKETING_VERSION = 1.5.0;/' \ | ||
| 403 | -e 's/CURRENT_PROJECT_VERSION = 13;/CURRENT_PROJECT_VERSION = 14;/' gitbay.xcodeproj/project.pbxproj && \ | ||
| 404 | git diff --stat && plutil -lint gitbay.xcodeproj/project.pbxproj | ||
| 405 | ``` | ||
| 406 | |||
| 407 | Expected: `1 file changed, 4 insertions(+), 4 deletions(-)` and `OK`. | ||
| 408 | |||
| 409 | - [ ] **Step 2: Both runtimes and a Release device build** | ||
| 410 | |||
| 411 | Run the unit target on iPhone 17 Pro (26.5), then: | ||
| 412 | |||
| 413 | ```bash | ||
| 414 | cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 415 | -destination 'id=9F8E5D84-0A15-4C64-8A45-994667A73817' -only-testing:gitbayTests test 2>&1 | grep -E "TEST (SUCCEEDED|FAILED)" | ||
| 416 | xcodebuild build -scheme gitbay -configuration Release -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO 2>&1 \ | ||
| 417 | | grep -E "error:|While running pass|BUILD (SUCCEEDED|FAILED)" | tail -5 | ||
| 418 | ``` | ||
| 419 | |||
| 420 | Expected: `** TEST SUCCEEDED **` and `** BUILD SUCCEEDED **`. | ||
| 421 | |||
| 422 | - [ ] **Step 3: Commit, push, open the MR** | ||
| 423 | |||
| 424 | ```bash | ||
| 425 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay.xcodeproj/project.pbxproj && git commit -m "Bump to 1.5.0 (14)" && \ | ||
| 426 | git push -u origin bump-1.5.0 && gitbay mr create --source bump-1.5.0 --target main --title "Bump to 1.5.0 (14)" --body "Markup previews." | ||
| 427 | ``` | ||
| 428 | |||
| 429 | Tagging `v1.5.0`, archiving and uploading are the user's. | ||
| 430 | |||
| 431 | - [ ] **Step 4: Flip the Parity rows upstream** | ||
| 432 | |||
| 433 | Once 1.5.0 is released, in `~/git/krz/gitbay` on a new branch `parity-ios-1.5.0`, set the iOS column to `yes` for `preview body markup` (Merge requests and Issues), `preview an edited markup file` and `preview release notes`. Commit, push, and open the MR titled "Parity: iOS 1.5.0 rows". | ||
docs/superpowers/plans/2026-09-22-1.6.0-admin.md added +1187
| @@ -0,0 +1,1187 @@ | |||
| 1 | # 1.6.0 Admin 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:** An instance admin can do from the app what the web's `/admin` and `/admin/users` pages do: read every worker queue and the server build, and list, filter, promote, demote, disable and enable accounts. | ||
| 6 | |||
| 7 | **Architecture:** Pure models in `gitbay/Admin/AdminModels.swift` decode `dashboard`'s admin-only `queues`/`server` blocks and `admin user list` rows, and turn them into the web's sections and per-row actions. Two view models read and write through the existing `GitbayClient`; the accounts list pages through `PagedListModel`. The account menu reads `whoami`'s `admin` flag to decide whether to offer the screen. Nothing beyond what the two web pages carry. | ||
| 8 | |||
| 9 | **Tech Stack:** Swift 6 language mode with default `MainActor` isolation, SwiftUI, Swift Testing, XCUITest. iOS 18.0 minimum, iOS 26.5 SDK. | ||
| 10 | |||
| 11 | **Spec:** `docs/superpowers/specs/2026-09-22-parity-followup-design.md` (section "1.6.0: admin") | ||
| 12 | |||
| 13 | ## Global Constraints | ||
| 14 | |||
| 15 | - **Never attribute anything to an assistant or model** — not in commits, code comments, MR bodies, or docs. No `Co-Authored-By` trailer. This is absolute. | ||
| 16 | - **Commits are signed.** `commit.gpgsign` is on; do not pass `--no-gpg-sign`. | ||
| 17 | - **Never push to `main`. Never merge.** One branch, `admin`, cut from `main` after 1.5.0 has merged. Task 5 is the version bump and waits for the user to merge it. | ||
| 18 | - **Never run an admin write against gitbay.org from a test or by hand.** Promote, demote, disable and enable act on real people's accounts. Unit tests stub every request; the live suite only checks that a non-admin is not offered the screen. | ||
| 19 | - **Match the web, no more.** No account show, invite, runners, repository administration, audit log or statistics. Promote only an active account; typed-name confirmation for demote and disable only. | ||
| 20 | - **Swift 6, default `MainActor` isolation, in the test target too.** Wire models, pure section types and any `Decodable` struct declared in a test are `nonisolated`, or their conformance is main-actor isolated and `JSONDecoder` refuses it. | ||
| 21 | - **File-system-synchronized groups.** New `.swift` files under `gitbay/` and `gitbayTests/` are picked up automatically. Do not edit `project.pbxproj` except for the version lines in Task 5. | ||
| 22 | - **Reads are `GET /api/v1/read?argv=…`; writes are `POST /api/v1/cmd`.** A stub's `match:` is a URL substring. Assert writes by filtering `stub.seen` on `method == "POST"`. A paged read's envelope is `{"data":{"items":[…],"next":"…"}}`. | ||
| 23 | - **Design tokens only.** `.gbSans`/`.gbMono` fonts, `GBChip` for chips, `GBNotice` for errors, `gb` palette colours. Run the design greps in Task 4. | ||
| 24 | - Comments explain *why*, in plain direct English, at the density of the surrounding code. No before/after commentary. | ||
| 25 | - **Run the whole unit target, and read the count.** | ||
| 26 | |||
| 27 | ```bash | ||
| 28 | cd /Users/cmc/git/krz/gitbay-ios && rm -rf /tmp/gb.xcresult && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 29 | -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' -only-testing:gitbayTests \ | ||
| 30 | -resultBundlePath /tmp/gb.xcresult test 2>&1 | grep -E "error:|failed|TEST (SUCCEEDED|FAILED)" | tail -20 | ||
| 31 | xcrun xcresulttool get test-results summary --path /tmp/gb.xcresult --format json \ | ||
| 32 | | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['result'], d['totalTestCount'], d['failedTests'])" | ||
| 33 | ``` | ||
| 34 | |||
| 35 | "Run the unit target" below means these two commands. Record the count before Task 1 as `BASE`. | ||
| 36 | |||
| 37 | --- | ||
| 38 | |||
| 39 | ### Task 1: Queue sections, as the web lays them out | ||
| 40 | |||
| 41 | **Files:** | ||
| 42 | - Create: `gitbay/Admin/AdminModels.swift` | ||
| 43 | - Create: `gitbayTests/AdminTests.swift` | ||
| 44 | |||
| 45 | **Interfaces:** | ||
| 46 | - Produces: | ||
| 47 | - `AdminDashboard: Decodable` with `queues: AdminQueues?`, `server: AdminDashboard.Server?` (`commit: String?`). | ||
| 48 | - `AdminQueues: Decodable` with `webhooks`, `mail`, `push`, `mirrors`, `builds`, `deps: AdminQueues.Queue`, and `func sections(when: (String) -> String = AdminQueues.relative) -> [QueueSection]`. | ||
| 49 | - `static func AdminQueues.relative(_ timestamp: String) -> String`. | ||
| 50 | - `QueueSection` (`id: String`, `heading: String`, `count: Int`, `summary: String?`, `rows: [QueueLine]`, `empty: String`); `QueueLine` (`id: Int`, `title: String`, `detail: String`). | ||
| 51 | |||
| 52 | - [ ] **Step 1: Branch** | ||
| 53 | |||
| 54 | ```bash | ||
| 55 | cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c admin | ||
| 56 | ``` | ||
| 57 | |||
| 58 | Run the unit target and record the count as `BASE`. | ||
| 59 | |||
| 60 | - [ ] **Step 2: Write the failing tests** | ||
| 61 | |||
| 62 | Create `gitbayTests/AdminTests.swift`: | ||
| 63 | |||
| 64 | ```swift | ||
| 65 | import Foundation | ||
| 66 | import Testing | ||
| 67 | @testable import gitbay | ||
| 68 | |||
| 69 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | ||
| 70 | let box = StubProtocol.box() | ||
| 71 | let client = GitbayClient( | ||
| 72 | instance: try GitbayInstance(url: "https://gitbay.org"), | ||
| 73 | token: "test-token", | ||
| 74 | session: box.session() | ||
| 75 | ) | ||
| 76 | return (client, box) | ||
| 77 | } | ||
| 78 | |||
| 79 | /// `dashboard` as an admin sees it, trimmed to the admin blocks, with | ||
| 80 | /// one row in every queue that lists rows. | ||
| 81 | private let adminDashboardJSON = """ | ||
| 82 | {"protocol_version":1,"data":{"unread":0,\ | ||
| 83 | "server":{"commit":"db7503f06f11"},\ | ||
| 84 | "queues":{\ | ||
| 85 | "webhooks":{"pending":2,"retrying":1,"failed":1,"oldest_pending":"2026-09-22T04:00:00Z","items":[\ | ||
| 86 | {"id":9,"repo":"krz/gitbay","url":"https://ci.example.com/hook","attempts":5,\ | ||
| 87 | "last_status":502,"last_error":"bad gateway","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T03:00:00Z"}]},\ | ||
| 88 | "mail":{"pending":0,"retrying":1,"failed":0,"items":[\ | ||
| 89 | {"id":4,"recipient":"a@example.com","subject":"krz/gitbay#12","attempts":2,\ | ||
| 90 | "last_error":"dial tcp: timeout","created_at":"2026-09-22T03:00:00Z"}]},\ | ||
| 91 | "mirrors":{"dirty":3,"errors":1,"items":[\ | ||
| 92 | {"id":1,"repo":"krz/solar","direction":"push","url":"https://github.com/x/solar",\ | ||
| 93 | "last_error":"auth failed"}]},\ | ||
| 94 | "builds":{"pending":1,"running":1,"items":[\ | ||
| 95 | {"repo":"krz/gitbay","number":1480,"job":"test","status":"running",\ | ||
| 96 | "created_at":"2026-09-22T04:40:00Z","started_at":"2026-09-22T04:41:00Z"},\ | ||
| 97 | {"repo":"krz/gitbay","number":1481,"job":"build","status":"pending",\ | ||
| 98 | "created_at":"2026-09-22T04:42:00Z","started_at":""}]},\ | ||
| 99 | "deps":{"errors":0,"items":[]},\ | ||
| 100 | "push":{"pending":0,"retrying":0,"failed":1,"items":[\ | ||
| 101 | {"id":7,"device_id":3,"title":"krz/gitbay!450 merged","attempts":3,\ | ||
| 102 | "last_error":"BadDeviceToken","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T04:00:00Z"}]}\ | ||
| 103 | }},"exit_code":0} | ||
| 104 | """ | ||
| 105 | |||
| 106 | nonisolated private struct Envelope: Decodable { let data: AdminDashboard } | ||
| 107 | |||
| 108 | private func sections() throws -> [QueueSection] { | ||
| 109 | let dashboard = try GitbayClient.decoder() | ||
| 110 | .decode(Envelope.self, from: Data(adminDashboardJSON.utf8)).data | ||
| 111 | let queues = try #require(dashboard.queues) | ||
| 112 | // A fixed stand-in for relative time keeps the text deterministic. | ||
| 113 | return queues.sections(when: { "at \($0)" }) | ||
| 114 | } | ||
| 115 | |||
| 116 | struct AdminQueueSectionTests { | ||
| 117 | |||
| 118 | @Test func sixSectionsInTheWebsOrder() throws { | ||
| 119 | let all = try sections() | ||
| 120 | #expect(all.map(\.heading) == [ | ||
| 121 | "Webhook deliveries", "Mail", "Push", "Mirrors", "Builds", "Dependency checks", | ||
| 122 | ]) | ||
| 123 | #expect(all.map(\.count) == [2, 0, 0, 1, 1, 0]) | ||
| 124 | } | ||
| 125 | |||
| 126 | @Test func countsLinesUseTheWebsWords() throws { | ||
| 127 | let all = try sections() | ||
| 128 | #expect(all[0].summary == "2 pending · 1 retrying · 1 dead-lettered · oldest pending at 2026-09-22T04:00:00Z") | ||
| 129 | #expect(all[1].summary == "0 pending · 1 retrying · 0 failed") | ||
| 130 | #expect(all[3].summary == "3 waiting for a sync · 1 with a failed last sync") | ||
| 131 | #expect(all[4].summary == "1 pending · 1 running") | ||
| 132 | #expect(all[5].summary == nil) | ||
| 133 | } | ||
| 134 | |||
| 135 | @Test func rowsSayWhatTheWebsTableColumnsSay() throws { | ||
| 136 | let all = try sections() | ||
| 137 | #expect(all[0].rows.map(\.title) == ["krz/gitbay"]) | ||
| 138 | #expect(all[0].rows[0].detail | ||
| 139 | == "https://ci.example.com/hook · 5 attempts · dead-lettered at 2026-09-22T05:00:00Z · 502 bad gateway") | ||
| 140 | #expect(all[1].rows[0].title == "a@example.com") | ||
| 141 | #expect(all[1].rows[0].detail == "krz/gitbay#12 · 2 attempts · retrying · dial tcp: timeout") | ||
| 142 | #expect(all[2].rows[0].title == "device 3") | ||
| 143 | #expect(all[2].rows[0].detail | ||
| 144 | == "krz/gitbay!450 merged · 3 attempts · failed at 2026-09-22T05:00:00Z · BadDeviceToken") | ||
| 145 | #expect(all[3].rows[0].detail == "push · https://github.com/x/solar · never synced · auth failed") | ||
| 146 | // A pending build has started_at "" and reports since it was queued. | ||
| 147 | #expect(all[4].rows.map(\.title) == ["krz/gitbay #1480", "krz/gitbay #1481"]) | ||
| 148 | #expect(all[4].rows[0].detail == "test · running · since at 2026-09-22T04:41:00Z") | ||
| 149 | #expect(all[4].rows[1].detail == "build · pending · since at 2026-09-22T04:42:00Z") | ||
| 150 | } | ||
| 151 | |||
| 152 | @Test func anEmptyQueueSaysSoInTheWebsWords() throws { | ||
| 153 | let deps = try #require(try sections().last) | ||
| 154 | #expect(deps.rows.isEmpty) | ||
| 155 | #expect(deps.empty == "No check has failed") | ||
| 156 | } | ||
| 157 | |||
| 158 | /// Everyone else's dashboard omits both blocks. | ||
| 159 | @Test func aNonAdminDashboardHasNoQueues() throws { | ||
| 160 | let json = #"{"protocol_version":1,"data":{"unread":3},"exit_code":0}"# | ||
| 161 | let dashboard = try GitbayClient.decoder().decode(Envelope.self, from: Data(json.utf8)).data | ||
| 162 | #expect(dashboard.queues == nil) | ||
| 163 | #expect(dashboard.server == nil) | ||
| 164 | } | ||
| 165 | |||
| 166 | @Test func anUnparseableTimestampIsShownAsIs() { | ||
| 167 | #expect(AdminQueues.relative("soon") == "soon") | ||
| 168 | } | ||
| 169 | } | ||
| 170 | ``` | ||
| 171 | |||
| 172 | - [ ] **Step 3: Run the unit target to verify it fails** | ||
| 173 | |||
| 174 | Expected: build FAILS with `cannot find type 'AdminDashboard' in scope` and `cannot find type 'QueueSection' in scope`. | ||
| 175 | |||
| 176 | - [ ] **Step 4: Write the models** | ||
| 177 | |||
| 178 | Create `gitbay/Admin/AdminModels.swift`: | ||
| 179 | |||
| 180 | ```swift | ||
| 181 | import Foundation | ||
| 182 | |||
| 183 | /// The admin-only half of `dashboard`: the server build and every | ||
| 184 | /// background worker's backlog. The web's `/admin` page is this read. | ||
| 185 | /// Both are absent for anyone who is not an instance admin. | ||
| 186 | nonisolated struct AdminDashboard: Decodable, Sendable, Hashable { | ||
| 187 | let queues: AdminQueues? | ||
| 188 | let server: Server? | ||
| 189 | |||
| 190 | nonisolated struct Server: Decodable, Sendable, Hashable { | ||
| 191 | let commit: String? | ||
| 192 | } | ||
| 193 | } | ||
| 194 | |||
| 195 | /// `dashboard`'s `queues` block. | ||
| 196 | nonisolated struct AdminQueues: Decodable, Sendable, Hashable { | ||
| 197 | let webhooks: Queue | ||
| 198 | let mail: Queue | ||
| 199 | let push: Queue | ||
| 200 | let mirrors: Queue | ||
| 201 | let builds: Queue | ||
| 202 | let deps: Queue | ||
| 203 | |||
| 204 | /// Each queue reports a different subset of these counts; the | ||
| 205 | /// absent ones stay nil. | ||
| 206 | nonisolated struct Queue: Decodable, Sendable, Hashable { | ||
| 207 | let pending: Int? | ||
| 208 | let retrying: Int? | ||
| 209 | let failed: Int? | ||
| 210 | let running: Int? | ||
| 211 | let dirty: Int? | ||
| 212 | let errors: Int? | ||
| 213 | let oldestPending: String? | ||
| 214 | let items: [Row]? | ||
| 215 | |||
| 216 | enum CodingKeys: String, CodingKey { | ||
| 217 | case pending, retrying, failed, running, dirty, errors, items | ||
| 218 | case oldestPending = "oldest_pending" | ||
| 219 | } | ||
| 220 | |||
| 221 | var rows: [Row] { items ?? [] } | ||
| 222 | } | ||
| 223 | |||
| 224 | /// A row of any queue; each kind fills its own fields. Timestamps | ||
| 225 | /// stay strings: a pending build's `started_at` is `""`, which no | ||
| 226 | /// date decoder accepts. | ||
| 227 | nonisolated struct Row: Decodable, Sendable, Hashable { | ||
| 228 | let repo: String? | ||
| 229 | let url: String? | ||
| 230 | let recipient: String? | ||
| 231 | let subject: String? | ||
| 232 | let title: String? | ||
| 233 | let deviceID: Int64? | ||
| 234 | let direction: String? | ||
| 235 | let number: Int64? | ||
| 236 | let job: String? | ||
| 237 | let status: String? | ||
| 238 | let attempts: Int64? | ||
| 239 | let lastStatus: Int64? | ||
| 240 | let lastError: String? | ||
| 241 | let failedAt: String? | ||
| 242 | let lastSync: String? | ||
| 243 | let lastCheck: String? | ||
| 244 | let startedAt: String? | ||
| 245 | let createdAt: String? | ||
| 246 | |||
| 247 | enum CodingKeys: String, CodingKey { | ||
| 248 | case repo, url, recipient, subject, title, direction, number, job, status, attempts | ||
| 249 | case deviceID = "device_id" | ||
| 250 | case lastStatus = "last_status" | ||
| 251 | case lastError = "last_error" | ||
| 252 | case failedAt = "failed_at" | ||
| 253 | case lastSync = "last_sync" | ||
| 254 | case lastCheck = "last_check" | ||
| 255 | case startedAt = "started_at" | ||
| 256 | case createdAt = "created_at" | ||
| 257 | } | ||
| 258 | } | ||
| 259 | |||
| 260 | /// The web's six sections, in its order and its words | ||
| 261 | /// (`internal/web/templates/admin.html`). `when` renders a timestamp; | ||
| 262 | /// tests pass a fixed one. | ||
| 263 | func sections(when: (String) -> String = AdminQueues.relative) -> [QueueSection] { | ||
| 264 | func oldest(_ queue: Queue) -> String { | ||
| 265 | guard let at = queue.oldestPending, !at.isEmpty else { return "" } | ||
| 266 | return " · oldest pending \(when(at))" | ||
| 267 | } | ||
| 268 | func n(_ value: Int?) -> Int { value ?? 0 } | ||
| 269 | func attempts(_ row: Row) -> String? { row.attempts.map { "\($0) attempts" } } | ||
| 270 | func state(_ row: Row, failed: String) -> String { | ||
| 271 | guard let at = row.failedAt, !at.isEmpty else { return "retrying" } | ||
| 272 | return "\(failed) \(when(at))" | ||
| 273 | } | ||
| 274 | func lines(_ queue: Queue, _ make: (Row) -> (String, [String?])) -> [QueueLine] { | ||
| 275 | queue.rows.enumerated().map { index, row in | ||
| 276 | let (title, parts) = make(row) | ||
| 277 | let detail = parts.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ") | ||
| 278 | return QueueLine(id: index, title: title, detail: detail) | ||
| 279 | } | ||
| 280 | } | ||
| 281 | |||
| 282 | return [ | ||
| 283 | QueueSection( | ||
| 284 | id: "webhooks", heading: "Webhook deliveries", count: n(webhooks.pending), | ||
| 285 | summary: "\(n(webhooks.pending)) pending · \(n(webhooks.retrying)) retrying · \(n(webhooks.failed)) dead-lettered" + oldest(webhooks), | ||
| 286 | rows: lines(webhooks) { row in | ||
| 287 | let error = [row.lastStatus.map(String.init), row.lastError] | ||
| 288 | .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ") | ||
| 289 | return (row.repo ?? "", [row.url, attempts(row), state(row, failed: "dead-lettered"), error]) | ||
| 290 | }, | ||
| 291 | empty: "Nothing retrying or dead-lettered"), | ||
| 292 | QueueSection( | ||
| 293 | id: "mail", heading: "Mail", count: n(mail.pending), | ||
| 294 | summary: "\(n(mail.pending)) pending · \(n(mail.retrying)) retrying · \(n(mail.failed)) failed" + oldest(mail), | ||
| 295 | rows: lines(mail) { row in | ||
| 296 | (row.recipient ?? "", [row.subject, attempts(row), state(row, failed: "failed"), row.lastError]) | ||
| 297 | }, | ||
| 298 | empty: "Nothing retrying or failed"), | ||
| 299 | // A push row names the device id, never the token. | ||
| 300 | QueueSection( | ||
| 301 | id: "push", heading: "Push", count: n(push.pending), | ||
| 302 | summary: "\(n(push.pending)) pending · \(n(push.retrying)) retrying · \(n(push.failed)) failed" + oldest(push), | ||
| 303 | rows: lines(push) { row in | ||
| 304 | ("device \(row.deviceID.map(String.init) ?? "?")", | ||
| 305 | [row.title, attempts(row), state(row, failed: "failed"), row.lastError]) | ||
| 306 | }, | ||
| 307 | empty: "Nothing retrying or failed"), | ||
| 308 | QueueSection( | ||
| 309 | id: "mirrors", heading: "Mirrors", count: n(mirrors.errors), | ||
| 310 | summary: "\(n(mirrors.dirty)) waiting for a sync · \(n(mirrors.errors)) with a failed last sync", | ||
| 311 | rows: lines(mirrors) { row in | ||
| 312 | let sync = row.lastSync.flatMap { $0.isEmpty ? nil : "last sync \(when($0))" } ?? "never synced" | ||
| 313 | return (row.repo ?? "", [row.direction, row.url, sync, row.lastError]) | ||
| 314 | }, | ||
| 315 | empty: "Every mirror's last sync succeeded"), | ||
| 316 | QueueSection( | ||
| 317 | id: "builds", heading: "Builds", count: n(builds.pending), | ||
| 318 | summary: "\(n(builds.pending)) pending · \(n(builds.running)) running" + oldest(builds), | ||
| 319 | rows: lines(builds) { row in | ||
| 320 | let started = row.startedAt.flatMap { $0.isEmpty ? nil : $0 } | ||
| 321 | let since = (started ?? row.createdAt).map { "since \(when($0))" } | ||
| 322 | return ("\(row.repo ?? "") #\(row.number.map(String.init) ?? "?")", [row.job, row.status, since]) | ||
| 323 | }, | ||
| 324 | empty: "No build running or pending"), | ||
| 325 | QueueSection( | ||
| 326 | id: "deps", heading: "Dependency checks", count: n(deps.errors), | ||
| 327 | summary: nil, | ||
| 328 | rows: lines(deps) { row in | ||
| 329 | let check = row.lastCheck.flatMap { $0.isEmpty ? nil : "last check \(when($0))" } ?? "never checked" | ||
| 330 | return (row.repo ?? "", [check, row.lastError]) | ||
| 331 | }, | ||
| 332 | empty: "No check has failed"), | ||
| 333 | ] | ||
| 334 | } | ||
| 335 | |||
| 336 | /// "5 minutes ago", or the timestamp itself when it does not parse. | ||
| 337 | static func relative(_ timestamp: String) -> String { | ||
| 338 | let fractional = ISO8601DateFormatter() | ||
| 339 | fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] | ||
| 340 | guard let date = fractional.date(from: timestamp) ?? ISO8601DateFormatter().date(from: timestamp) | ||
| 341 | else { return timestamp } | ||
| 342 | return date.formatted(.relative(presentation: .named)) | ||
| 343 | } | ||
| 344 | } | ||
| 345 | |||
| 346 | /// One section of the admin screen: a heading with its count, the counts | ||
| 347 | /// line, the rows, and what an empty queue says. | ||
| 348 | nonisolated struct QueueSection: Sendable, Hashable, Identifiable { | ||
| 349 | let id: String | ||
| 350 | let heading: String | ||
| 351 | let count: Int | ||
| 352 | let summary: String? | ||
| 353 | let rows: [QueueLine] | ||
| 354 | let empty: String | ||
| 355 | } | ||
| 356 | |||
| 357 | nonisolated struct QueueLine: Sendable, Hashable, Identifiable { | ||
| 358 | let id: Int | ||
| 359 | let title: String | ||
| 360 | let detail: String | ||
| 361 | } | ||
| 362 | ``` | ||
| 363 | |||
| 364 | - [ ] **Step 5: Run the unit target to verify it passes** | ||
| 365 | |||
| 366 | Expected: `Passed <BASE + 6> 0`. If a detail string differs, fix the model, not the test: the strings are the web template's. | ||
| 367 | |||
| 368 | - [ ] **Step 6: Commit** | ||
| 369 | |||
| 370 | ```bash | ||
| 371 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Admin/AdminModels.swift gitbayTests/AdminTests.swift && git commit -m "admin: decode dashboard's queues into the web's six sections" | ||
| 372 | ``` | ||
| 373 | |||
| 374 | --- | ||
| 375 | |||
| 376 | ### Task 2: Accounts, their actions, and who is an admin | ||
| 377 | |||
| 378 | **Files:** | ||
| 379 | - Modify: `gitbay/Admin/AdminModels.swift` (append `AdminUser`, `AdminAction`, `AdminUserFilter`) | ||
| 380 | - Create: `gitbay/Admin/AdminViewModels.swift` | ||
| 381 | - Modify: `gitbayTests/AdminTests.swift` (append suites) | ||
| 382 | |||
| 383 | **Interfaces:** | ||
| 384 | - Consumes: `PagedListModel<Element>(client:argv:emptyMessage:pageSize:)`, `GitbayClient.read(_:as:)`, `GitbayClient.run(_:)`, `LoadState`. | ||
| 385 | - Produces: | ||
| 386 | - `AdminUser: Decodable, Identifiable` — `username: String`, `state: String`, `admin: Bool`, `createdAt: Date`, `lastSeen: Date?`; `roleAction: AdminAction`, `canChangeRole: Bool`, `accessAction: AdminAction`. | ||
| 387 | - `AdminAction: String` — `.promote`, `.demote`, `.disable`, `.enable`; `needsTypedName: Bool`, `title: String`, `argv(_ username: String) -> [String]`, `message(for username: String) -> String`. | ||
| 388 | - `AdminUserFilter: String, CaseIterable, Identifiable` — `.all`, `.active`, `.pending`, `.disabled`, `.admin`; `label: String`, `flags() -> [String]`. | ||
| 389 | - `enum AdminCheck` — `static func isAdmin(_ client: GitbayClient) async -> Bool`. | ||
| 390 | - `AdminOverviewViewModel(client:)` — `state: LoadState<AdminOverviewViewModel.Overview>`, `load() async`; `Overview` has `commit: String?`, `sections: [QueueSection]`. | ||
| 391 | - `AdminUsersViewModel(client:)` — `list: PagedListModel<AdminUser>`, `filter: AdminUserFilter`, `actionError: String?`, `working: Bool`, `load() async`, `perform(_ action: AdminAction, on username: String) async`. | ||
| 392 | |||
| 393 | - [ ] **Step 1: Write the failing tests** | ||
| 394 | |||
| 395 | Append to `gitbayTests/AdminTests.swift`: | ||
| 396 | |||
| 397 | ```swift | ||
| 398 | private func user(_ name: String, state: String, admin: Bool = false) throws -> AdminUser { | ||
| 399 | let json = """ | ||
| 400 | {"username":"\(name)","state":"\(state)","admin":\(admin),\ | ||
| 401 | "created_at":"2026-08-28T21:55:00.752Z"} | ||
| 402 | """ | ||
| 403 | return try GitbayClient.decoder().decode(AdminUser.self, from: Data(json.utf8)) | ||
| 404 | } | ||
| 405 | |||
| 406 | struct AdminUserActionTests { | ||
| 407 | |||
| 408 | @Test func anAdminIsDemotedAndCanBeDisabled() throws { | ||
| 409 | let u = try user("cmc", state: "active", admin: true) | ||
| 410 | #expect(u.roleAction == .demote) | ||
| 411 | #expect(u.canChangeRole) | ||
| 412 | #expect(u.accessAction == .disable) | ||
| 413 | } | ||
| 414 | |||
| 415 | @Test func anActiveAccountCanBePromoted() throws { | ||
| 416 | let u = try user("blotter-ci", state: "active") | ||
| 417 | #expect(u.roleAction == .promote) | ||
| 418 | #expect(u.canChangeRole) | ||
| 419 | } | ||
| 420 | |||
| 421 | /// The web draws Promote on every non-admin row but disables it for | ||
| 422 | /// an account that is not active. | ||
| 423 | @Test func onlyAnActiveAccountCanBePromoted() throws { | ||
| 424 | #expect(try user("new", state: "pending").canChangeRole == false) | ||
| 425 | #expect(try user("gone", state: "disabled").canChangeRole == false) | ||
| 426 | } | ||
| 427 | |||
| 428 | @Test func aDisabledAccountIsEnabledAndAnyOtherDisabled() throws { | ||
| 429 | #expect(try user("gone", state: "disabled").accessAction == .enable) | ||
| 430 | #expect(try user("new", state: "pending").accessAction == .disable) | ||
| 431 | } | ||
| 432 | |||
| 433 | @Test func demoteAndDisableAskForTheTypedName() { | ||
| 434 | #expect(AdminAction.demote.needsTypedName) | ||
| 435 | #expect(AdminAction.disable.needsTypedName) | ||
| 436 | #expect(!AdminAction.promote.needsTypedName) | ||
| 437 | #expect(!AdminAction.enable.needsTypedName) | ||
| 438 | } | ||
| 439 | |||
| 440 | @Test func eachActionIsItsAdminUserCommand() { | ||
| 441 | #expect(AdminAction.promote.argv("x") == ["admin", "user", "promote", "x"]) | ||
| 442 | #expect(AdminAction.demote.argv("x") == ["admin", "user", "demote", "x"]) | ||
| 443 | #expect(AdminAction.disable.argv("x") == ["admin", "user", "disable", "x"]) | ||
| 444 | #expect(AdminAction.enable.argv("x") == ["admin", "user", "enable", "x"]) | ||
| 445 | } | ||
| 446 | |||
| 447 | @Test func theFiltersAreTheWebsFive() { | ||
| 448 | #expect(AdminUserFilter.allCases.map(\.label) == ["All", "Active", "Pending", "Disabled", "Admins"]) | ||
| 449 | #expect(AdminUserFilter.all.flags().isEmpty) | ||
| 450 | #expect(AdminUserFilter.admin.flags() == ["--state", "admin"]) | ||
| 451 | } | ||
| 452 | } | ||
| 453 | |||
| 454 | @MainActor | ||
| 455 | struct AdminCheckTests { | ||
| 456 | |||
| 457 | @Test func whoamisAdminFlagDecides() async throws { | ||
| 458 | let (client, stub) = try makeClient() | ||
| 459 | stub.enqueue(.init(status: 200, json: | ||
| 460 | #"{"protocol_version":1,"data":{"username":"cmc","admin":true,"key_scope":"full"}}"#)) | ||
| 461 | #expect(await AdminCheck.isAdmin(client)) | ||
| 462 | #expect(stub.seen.first?.url.query() == "argv=whoami") | ||
| 463 | } | ||
| 464 | |||
| 465 | @Test func aNonAdminIsNot() async throws { | ||
| 466 | let (client, stub) = try makeClient() | ||
| 467 | stub.enqueue(.init(status: 200, json: | ||
| 468 | #"{"protocol_version":1,"data":{"username":"ios-smoke","admin":false}}"#)) | ||
| 469 | #expect(await AdminCheck.isAdmin(client) == false) | ||
| 470 | } | ||
| 471 | |||
| 472 | /// The menu just leaves the entry out when the read fails. | ||
| 473 | @Test func aFailedReadIsNotAnAdmin() async throws { | ||
| 474 | let (client, stub) = try makeClient() | ||
| 475 | stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) | ||
| 476 | #expect(await AdminCheck.isAdmin(client) == false) | ||
| 477 | } | ||
| 478 | } | ||
| 479 | |||
| 480 | @MainActor | ||
| 481 | struct AdminOverviewViewModelTests { | ||
| 482 | |||
| 483 | @Test func readsDashboardIntoSections() async throws { | ||
| 484 | let (client, stub) = try makeClient() | ||
| 485 | stub.enqueue(.init(status: 200, json: adminDashboardJSON)) | ||
| 486 | let model = AdminOverviewViewModel(client: client) | ||
| 487 | |||
| 488 | await model.load() | ||
| 489 | |||
| 490 | let overview = try #require(model.state.value) | ||
| 491 | #expect(overview.commit == "db7503f06f11") | ||
| 492 | #expect(overview.sections.count == 6) | ||
| 493 | #expect(stub.seen.first?.url.query() == "argv=dashboard") | ||
| 494 | } | ||
| 495 | |||
| 496 | @Test func aDashboardWithoutQueuesIsAnEmptyState() async throws { | ||
| 497 | let (client, stub) = try makeClient() | ||
| 498 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"unread":0},"exit_code":0}"#)) | ||
| 499 | let model = AdminOverviewViewModel(client: client) | ||
| 500 | |||
| 501 | await model.load() | ||
| 502 | |||
| 503 | guard case .empty(let message) = model.state else { | ||
| 504 | Issue.record("expected .empty, got \(model.state)") | ||
| 505 | return | ||
| 506 | } | ||
| 507 | #expect(message == "Only instance admins can see the worker queues.") | ||
| 508 | } | ||
| 509 | } | ||
| 510 | |||
| 511 | private let userPageJSON = """ | ||
| 512 | {"protocol_version":1,"data":{"items":[\ | ||
| 513 | {"username":"apple-review","state":"active","admin":false,"created_at":"2026-08-28T21:55:00.752Z",\ | ||
| 514 | "last_seen":"2026-09-21T07:05:26.201Z"},\ | ||
| 515 | {"username":"bhargavkk","state":"pending","admin":false,"created_at":"2026-09-11T01:50:04.063Z"}],\ | ||
| 516 | "next":"YWRtaW4tdXNlcjpiaGFyZ2F2a2s"},"exit_code":0} | ||
| 517 | """ | ||
| 518 | |||
| 519 | @MainActor | ||
| 520 | struct AdminUsersViewModelTests { | ||
| 521 | |||
| 522 | @Test func listsEveryAccountByDefault() async throws { | ||
| 523 | let (client, stub) = try makeClient() | ||
| 524 | stub.enqueue(.init(status: 200, json: userPageJSON)) | ||
| 525 | let model = AdminUsersViewModel(client: client) | ||
| 526 | |||
| 527 | await model.load() | ||
| 528 | |||
| 529 | let users = try #require(model.list.state.value) | ||
| 530 | #expect(users.map(\.username) == ["apple-review", "bhargavkk"]) | ||
| 531 | #expect(users[0].lastSeen != nil) | ||
| 532 | #expect(users[1].lastSeen == nil) | ||
| 533 | #expect(model.list.hasMore) | ||
| 534 | #expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50") | ||
| 535 | } | ||
| 536 | |||
| 537 | @Test func aFilterReloadsWithItsState() async throws { | ||
| 538 | let (client, stub) = try makeClient() | ||
| 539 | stub.enqueue(.init(status: 200, json: userPageJSON)) | ||
| 540 | let model = AdminUsersViewModel(client: client) | ||
| 541 | await model.load() | ||
| 542 | |||
| 543 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) | ||
| 544 | model.filter = .disabled | ||
| 545 | await until { | ||
| 546 | if case .empty = model.list.state { return true } | ||
| 547 | return false | ||
| 548 | } | ||
| 549 | |||
| 550 | #expect(stub.seen.last?.url.query() | ||
| 551 | == "argv=admin&argv=user&argv=list&argv=--state&argv=disabled&argv=--limit&argv=50") | ||
| 552 | guard case .empty(let message) = model.list.state else { return } | ||
| 553 | #expect(message == "No account matches") | ||
| 554 | } | ||
| 555 | |||
| 556 | @Test func anActionSendsItsCommandAndReloads() async throws { | ||
| 557 | let (client, stub) = try makeClient() | ||
| 558 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "cmd")) | ||
| 559 | stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list")) | ||
| 560 | let model = AdminUsersViewModel(client: client) | ||
| 561 | |||
| 562 | await model.perform(.disable, on: "bhargavkk") | ||
| 563 | |||
| 564 | let write = try #require(stub.seen.first { $0.method == "POST" }) | ||
| 565 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | ||
| 566 | #expect(body["argv"] as? [String] == ["admin", "user", "disable", "bhargavkk"]) | ||
| 567 | #expect(model.actionError == nil) | ||
| 568 | #expect(model.list.state.value?.count == 2) | ||
| 569 | } | ||
| 570 | |||
| 571 | @Test func aRefusalIsShownVerbatim() async throws { | ||
| 572 | let (client, stub) = try makeClient() | ||
| 573 | stub.enqueue(.init(status: 200, json: | ||
| 574 | #"{"protocol_version":1,"error":"cmc is the last admin","exit_code":4}"#, match: "cmd")) | ||
| 575 | let model = AdminUsersViewModel(client: client) | ||
| 576 | |||
| 577 | await model.perform(.demote, on: "cmc") | ||
| 578 | |||
| 579 | #expect(model.actionError == "cmc is the last admin") | ||
| 580 | } | ||
| 581 | } | ||
| 582 | ``` | ||
| 583 | |||
| 584 | - [ ] **Step 2: Run the unit target to verify it fails** | ||
| 585 | |||
| 586 | Expected: build FAILS with `cannot find type 'AdminUser' in scope`, `cannot find 'AdminCheck' in scope`, `cannot find 'AdminOverviewViewModel' in scope`, `cannot find 'AdminUsersViewModel' in scope`. | ||
| 587 | |||
| 588 | - [ ] **Step 3: Append the account types to the models** | ||
| 589 | |||
| 590 | Append to `gitbay/Admin/AdminModels.swift`: | ||
| 591 | |||
| 592 | ```swift | ||
| 593 | /// One row of `admin user list`. | ||
| 594 | nonisolated struct AdminUser: Decodable, Sendable, Hashable, Identifiable { | ||
| 595 | let username: String | ||
| 596 | /// active, pending or disabled. | ||
| 597 | let state: String | ||
| 598 | let admin: Bool | ||
| 599 | let createdAt: Date | ||
| 600 | /// Absent for an account that has never signed in. | ||
| 601 | let lastSeen: Date? | ||
| 602 | |||
| 603 | enum CodingKeys: String, CodingKey { | ||
| 604 | case username, state, admin | ||
| 605 | case createdAt = "created_at" | ||
| 606 | case lastSeen = "last_seen" | ||
| 607 | } | ||
| 608 | |||
| 609 | var id: String { username } | ||
| 610 | |||
| 611 | /// The role button the web draws: Demote for an admin, Promote for | ||
| 612 | /// anyone else. | ||
| 613 | var roleAction: AdminAction { admin ? .demote : .promote } | ||
| 614 | /// Only an active account can be promoted; the web draws the button | ||
| 615 | /// disabled for the rest. | ||
| 616 | var canChangeRole: Bool { admin || state == "active" } | ||
| 617 | var accessAction: AdminAction { state == "disabled" ? .enable : .disable } | ||
| 618 | } | ||
| 619 | |||
| 620 | nonisolated enum AdminAction: String, Sendable, Hashable { | ||
| 621 | case promote, demote, disable, enable | ||
| 622 | |||
| 623 | /// Demote and disable take something away from the account, and the | ||
| 624 | /// web asks for the username typed before either. | ||
| 625 | var needsTypedName: Bool { self == .demote || self == .disable } | ||
| 626 | |||
| 627 | var title: String { rawValue.capitalized } | ||
| 628 | |||
| 629 | func argv(_ username: String) -> [String] { ["admin", "user", rawValue, username] } | ||
| 630 | |||
| 631 | func message(for username: String) -> String { | ||
| 632 | switch self { | ||
| 633 | case .promote: "\(username) becomes an instance admin." | ||
| 634 | case .demote: "\(username) stops being an instance admin. Type the username to confirm." | ||
| 635 | case .disable: "SSH, web sessions and API tokens for \(username) are refused until the account is enabled. Type the username to confirm." | ||
| 636 | case .enable: "\(username) can sign in again." | ||
| 637 | } | ||
| 638 | } | ||
| 639 | } | ||
| 640 | |||
| 641 | /// The web's five filters over `admin user list`. | ||
| 642 | nonisolated enum AdminUserFilter: String, CaseIterable, Identifiable, Sendable { | ||
| 643 | case all, active, pending, disabled, admin | ||
| 644 | |||
| 645 | var id: String { rawValue } | ||
| 646 | var label: String { self == .admin ? "Admins" : rawValue.capitalized } | ||
| 647 | |||
| 648 | func flags() -> [String] { self == .all ? [] : ["--state", rawValue] } | ||
| 649 | } | ||
| 650 | ``` | ||
| 651 | |||
| 652 | - [ ] **Step 4: Write the view models** | ||
| 653 | |||
| 654 | Create `gitbay/Admin/AdminViewModels.swift`: | ||
| 655 | |||
| 656 | ```swift | ||
| 657 | import Foundation | ||
| 658 | import Observation | ||
| 659 | |||
| 660 | /// Whether the signed-in account is an instance admin, from `whoami`. A | ||
| 661 | /// failed read counts as not: the account menu then leaves Admin out. | ||
| 662 | enum AdminCheck { | ||
| 663 | |||
| 664 | nonisolated private struct Who: Decodable, Sendable { | ||
| 665 | let admin: Bool? | ||
| 666 | } | ||
| 667 | |||
| 668 | static func isAdmin(_ client: GitbayClient) async -> Bool { | ||
| 669 | ((try? await client.read(["whoami"], as: Who.self))?.admin) ?? false | ||
| 670 | } | ||
| 671 | } | ||
| 672 | |||
| 673 | /// The admin screen: `dashboard`'s server build and worker queues, the | ||
| 674 | /// read the web's /admin page makes. | ||
| 675 | @Observable | ||
| 676 | @MainActor | ||
| 677 | final class AdminOverviewViewModel { | ||
| 678 | |||
| 679 | nonisolated struct Overview: Sendable, Hashable { | ||
| 680 | let commit: String? | ||
| 681 | let sections: [QueueSection] | ||
| 682 | } | ||
| 683 | |||
| 684 | private(set) var state: LoadState<Overview> = .loading | ||
| 685 | |||
| 686 | private let client: GitbayClient | ||
| 687 | |||
| 688 | init(client: GitbayClient) { | ||
| 689 | self.client = client | ||
| 690 | } | ||
| 691 | |||
| 692 | func load() async { | ||
| 693 | do { | ||
| 694 | let dashboard = try await client.read(["dashboard"], as: AdminDashboard.self) | ||
| 695 | guard let queues = dashboard.queues else { | ||
| 696 | state = .empty("Only instance admins can see the worker queues.") | ||
| 697 | return | ||
| 698 | } | ||
| 699 | state = .loaded(Overview(commit: dashboard.server?.commit, sections: queues.sections())) | ||
| 700 | } catch { | ||
| 701 | state = .from(error) | ||
| 702 | } | ||
| 703 | } | ||
| 704 | } | ||
| 705 | |||
| 706 | /// `admin user list`, narrowed by the web's five filters, and the four | ||
| 707 | /// writes /admin/users makes per row. | ||
| 708 | @Observable | ||
| 709 | @MainActor | ||
| 710 | final class AdminUsersViewModel { | ||
| 711 | |||
| 712 | let list: PagedListModel<AdminUser> | ||
| 713 | var filter = AdminUserFilter.all { | ||
| 714 | didSet { | ||
| 715 | guard filter != oldValue else { return } | ||
| 716 | list.argv = ["admin", "user", "list"] + filter.flags() | ||
| 717 | reloadTask?.cancel() | ||
| 718 | reloadTask = Task { await list.reload() } | ||
| 719 | } | ||
| 720 | } | ||
| 721 | private var reloadTask: Task<Void, Never>? | ||
| 722 | private(set) var actionError: String? | ||
| 723 | private(set) var working = false | ||
| 724 | |||
| 725 | private let client: GitbayClient | ||
| 726 | |||
| 727 | init(client: GitbayClient) { | ||
| 728 | self.client = client | ||
| 729 | list = PagedListModel( | ||
| 730 | client: client, | ||
| 731 | argv: ["admin", "user", "list"], | ||
| 732 | emptyMessage: "No account matches" | ||
| 733 | ) | ||
| 734 | } | ||
| 735 | |||
| 736 | func load() async { | ||
| 737 | reloadTask?.cancel() | ||
| 738 | await list.reload() | ||
| 739 | } | ||
| 740 | |||
| 741 | func perform(_ action: AdminAction, on username: String) async { | ||
| 742 | working = true | ||
| 743 | actionError = nil | ||
| 744 | defer { working = false } | ||
| 745 | do { | ||
| 746 | try await client.run(action.argv(username)) | ||
| 747 | await list.reload() | ||
| 748 | } catch let error as GitbayError { | ||
| 749 | actionError = error.userFacingMessage | ||
| 750 | } catch { | ||
| 751 | actionError = GitbayError.transport(error).userFacingMessage | ||
| 752 | } | ||
| 753 | } | ||
| 754 | } | ||
| 755 | ``` | ||
| 756 | |||
| 757 | - [ ] **Step 5: Run the unit target to verify it passes** | ||
| 758 | |||
| 759 | Expected: `Passed <BASE + 22> 0` — Task 1's 6, plus 7 action, 3 check, 2 overview and 4 accounts tests. | ||
| 760 | |||
| 761 | If `aFailedReadIsNotAnAdmin` retries: the client honours `Retry-After` only on 429, so a 500 fails once. | ||
| 762 | |||
| 763 | - [ ] **Step 6: Commit** | ||
| 764 | |||
| 765 | ```bash | ||
| 766 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Admin/AdminModels.swift gitbay/Admin/AdminViewModels.swift gitbayTests/AdminTests.swift && git commit -m "admin: accounts, their actions, and who is an admin | ||
| 767 | |||
| 768 | admin user list with the web's five filters; promote, demote, disable | ||
| 769 | and enable with the web's rules for which a row offers; whoami's admin | ||
| 770 | flag for the account menu." | ||
| 771 | ``` | ||
| 772 | |||
| 773 | --- | ||
| 774 | |||
| 775 | ### Task 3: The screens and the menu entry | ||
| 776 | |||
| 777 | **Files:** | ||
| 778 | - Create: `gitbay/Views/Admin/AdminView.swift` | ||
| 779 | - Create: `gitbay/Views/Admin/AdminUsersView.swift` | ||
| 780 | - Modify: `gitbay/Views/Repos/RepoRoute.swift:26` (two cases after `case notifications`) | ||
| 781 | - Modify: `gitbay/ContentView.swift:204-205` (two destinations after `.notifications`) | ||
| 782 | - Modify: `gitbay/Views/Repos/RepoListView.swift:250-291` (`AccountMenu`) | ||
| 783 | |||
| 784 | **Interfaces:** | ||
| 785 | - Consumes: everything Task 2 produces; `LoadStateOverlay(state:)`, `PageFooter(list:)`, `GBChip`, `GBNotice`, `SessionStore.client`, `SessionStore.current`. | ||
| 786 | - Produces: `RepoRoute.admin`, `RepoRoute.adminUsers`; accessibility identifiers `account-menu-admin`, `admin-accounts`, `admin-user-filter`, `admin-user-menu-<username>`. | ||
| 787 | |||
| 788 | - [ ] **Step 1: Add the routes** | ||
| 789 | |||
| 790 | In `gitbay/Views/Repos/RepoRoute.swift`, after ` case notifications` add: | ||
| 791 | |||
| 792 | ```swift | ||
| 793 | case admin | ||
| 794 | case adminUsers | ||
| 795 | ``` | ||
| 796 | |||
| 797 | In `gitbay/ContentView.swift`, in `destination(_ route: RepoRoute, client:)`, after | ||
| 798 | |||
| 799 | ```swift | ||
| 800 | case .notifications: | ||
| 801 | NotificationsView(client: client) | ||
| 802 | ``` | ||
| 803 | |||
| 804 | add | ||
| 805 | |||
| 806 | ```swift | ||
| 807 | case .admin: | ||
| 808 | AdminView(client: client) | ||
| 809 | case .adminUsers: | ||
| 810 | AdminUsersView(client: client) | ||
| 811 | ``` | ||
| 812 | |||
| 813 | - [ ] **Step 2: The admin screen** | ||
| 814 | |||
| 815 | Create `gitbay/Views/Admin/AdminView.swift`: | ||
| 816 | |||
| 817 | ```swift | ||
| 818 | import SwiftUI | ||
| 819 | |||
| 820 | /// The web's /admin page: the server build, a way to the accounts, and | ||
| 821 | /// every worker queue's backlog in the web's order and words. | ||
| 822 | struct AdminView: View { | ||
| 823 | |||
| 824 | @State private var model: AdminOverviewViewModel | ||
| 825 | |||
| 826 | init(client: GitbayClient) { | ||
| 827 | _model = State(initialValue: AdminOverviewViewModel(client: client)) | ||
| 828 | } | ||
| 829 | |||
| 830 | var body: some View { | ||
| 831 | List { | ||
| 832 | if let overview = model.state.value { | ||
| 833 | Section { | ||
| 834 | NavigationLink(value: RepoRoute.adminUsers) { | ||
| 835 | Label("Accounts", systemImage: "person.2") | ||
| 836 | } | ||
| 837 | .accessibilityIdentifier("admin-accounts") | ||
| 838 | } footer: { | ||
| 839 | if let commit = overview.commit { | ||
| 840 | Text("Server build \(commit).") | ||
| 841 | } | ||
| 842 | } | ||
| 843 | ForEach(overview.sections) { section in | ||
| 844 | Section { | ||
| 845 | if section.rows.isEmpty { | ||
| 846 | Text(section.empty) | ||
| 847 | .font(.gbSans(.subheadline)) | ||
| 848 | .foregroundStyle(.secondary) | ||
| 849 | } else { | ||
| 850 | ForEach(section.rows) { line in | ||
| 851 | VStack(alignment: .leading, spacing: 2) { | ||
| 852 | Text(line.title) | ||
| 853 | .font(.gbSans(.subheadline).weight(.medium)) | ||
| 854 | Text(line.detail) | ||
| 855 | .font(.gbSans(.caption)) | ||
| 856 | .foregroundStyle(.secondary) | ||
| 857 | } | ||
| 858 | } | ||
| 859 | } | ||
| 860 | } header: { | ||
| 861 | Text("\(section.heading) \(section.count)") | ||
| 862 | } footer: { | ||
| 863 | if let summary = section.summary { | ||
| 864 | Text(summary) | ||
| 865 | } | ||
| 866 | } | ||
| 867 | } | ||
| 868 | } | ||
| 869 | } | ||
| 870 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 871 | .navigationTitle("Admin") | ||
| 872 | .navigationBarTitleDisplayMode(.inline) | ||
| 873 | .task { await model.load() } | ||
| 874 | .refreshable { await model.load() } | ||
| 875 | } | ||
| 876 | } | ||
| 877 | ``` | ||
| 878 | |||
| 879 | - [ ] **Step 3: The accounts screen** | ||
| 880 | |||
| 881 | Create `gitbay/Views/Admin/AdminUsersView.swift`: | ||
| 882 | |||
| 883 | ```swift | ||
| 884 | import SwiftUI | ||
| 885 | |||
| 886 | /// The web's /admin/users: every account, filtered by state, with the | ||
| 887 | /// role and access action each row allows. Demote and disable ask for | ||
| 888 | /// the username typed, as the web does. | ||
| 889 | struct AdminUsersView: View { | ||
| 890 | |||
| 891 | @State private var model: AdminUsersViewModel | ||
| 892 | @State private var pending: Pending? | ||
| 893 | @State private var typed = "" | ||
| 894 | |||
| 895 | nonisolated struct Pending: Hashable, Sendable { | ||
| 896 | let action: AdminAction | ||
| 897 | let username: String | ||
| 898 | } | ||
| 899 | |||
| 900 | init(client: GitbayClient) { | ||
| 901 | _model = State(initialValue: AdminUsersViewModel(client: client)) | ||
| 902 | } | ||
| 903 | |||
| 904 | var body: some View { | ||
| 905 | List { | ||
| 906 | Picker("State", selection: Bindable(model).filter) { | ||
| 907 | ForEach(AdminUserFilter.allCases) { filter in | ||
| 908 | Text(filter.label).tag(filter) | ||
| 909 | } | ||
| 910 | } | ||
| 911 | .pickerStyle(.segmented) | ||
| 912 | .listRowBackground(Color.clear) | ||
| 913 | .listRowInsets(EdgeInsets()) | ||
| 914 | .accessibilityIdentifier("admin-user-filter") | ||
| 915 | |||
| 916 | if let error = model.actionError { | ||
| 917 | Section { | ||
| 918 | GBNotice(error, .gbWarn) | ||
| 919 | } | ||
| 920 | } | ||
| 921 | ForEach(model.list.state.value ?? []) { user in | ||
| 922 | row(user) | ||
| 923 | } | ||
| 924 | PageFooter(list: model.list) | ||
| 925 | } | ||
| 926 | .overlay { LoadStateOverlay(state: model.list.state) } | ||
| 927 | .navigationTitle("Accounts") | ||
| 928 | .navigationBarTitleDisplayMode(.inline) | ||
| 929 | .task { await model.load() } | ||
| 930 | .refreshable { await model.load() } | ||
| 931 | .alert( | ||
| 932 | pending.map { "\($0.action.title) \($0.username)?" } ?? "", | ||
| 933 | isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }), | ||
| 934 | presenting: pending | ||
| 935 | ) { pending in | ||
| 936 | if pending.action.needsTypedName { | ||
| 937 | TextField(pending.username, text: $typed) | ||
| 938 | .autocorrectionDisabled() | ||
| 939 | .textInputAutocapitalization(.never) | ||
| 940 | } | ||
| 941 | Button(pending.action.title, role: pending.action.needsTypedName ? .destructive : nil) { | ||
| 942 | Task { await model.perform(pending.action, on: pending.username) } | ||
| 943 | } | ||
| 944 | .disabled(pending.action.needsTypedName && typed != pending.username) | ||
| 945 | Button("Cancel", role: .cancel) {} | ||
| 946 | } message: { pending in | ||
| 947 | Text(pending.action.message(for: pending.username)) | ||
| 948 | } | ||
| 949 | } | ||
| 950 | |||
| 951 | private func row(_ user: AdminUser) -> some View { | ||
| 952 | HStack { | ||
| 953 | VStack(alignment: .leading, spacing: 3) { | ||
| 954 | HStack(spacing: 6) { | ||
| 955 | Text(user.username) | ||
| 956 | .font(.gbSans(.subheadline).weight(.medium)) | ||
| 957 | if user.admin { | ||
| 958 | GBChip("admin", .gbAccent) | ||
| 959 | } | ||
| 960 | GBChip(user.state, stateColor(user.state)) | ||
| 961 | } | ||
| 962 | Text(seen(user)) | ||
| 963 | .font(.gbSans(.caption)) | ||
| 964 | .foregroundStyle(.secondary) | ||
| 965 | } | ||
| 966 | Spacer() | ||
| 967 | Menu { | ||
| 968 | Button(user.roleAction.title) { ask(user.roleAction, user) } | ||
| 969 | .disabled(!user.canChangeRole || model.working) | ||
| 970 | Button(user.accessAction.title, | ||
| 971 | role: user.accessAction == .disable ? .destructive : nil) { | ||
| 972 | ask(user.accessAction, user) | ||
| 973 | } | ||
| 974 | .disabled(model.working) | ||
| 975 | } label: { | ||
| 976 | Image(systemName: "ellipsis.circle") | ||
| 977 | } | ||
| 978 | .accessibilityIdentifier("admin-user-menu-\(user.username)") | ||
| 979 | } | ||
| 980 | } | ||
| 981 | |||
| 982 | private func ask(_ action: AdminAction, _ user: AdminUser) { | ||
| 983 | typed = "" | ||
| 984 | pending = Pending(action: action, username: user.username) | ||
| 985 | } | ||
| 986 | |||
| 987 | /// The web's chip classes: open for active, closed for disabled, | ||
| 988 | /// neutral otherwise. | ||
| 989 | private func stateColor(_ state: String) -> Color { | ||
| 990 | switch state { | ||
| 991 | case "active": .gbOK | ||
| 992 | case "disabled": .gbBad | ||
| 993 | default: .secondary | ||
| 994 | } | ||
| 995 | } | ||
| 996 | |||
| 997 | private func seen(_ user: AdminUser) -> String { | ||
| 998 | let created = "joined \(user.createdAt.formatted(date: .abbreviated, time: .omitted))" | ||
| 999 | let last = user.lastSeen.map { "last seen \($0.formatted(.relative(presentation: .named)))" } | ||
| 1000 | ?? "never seen" | ||
| 1001 | return "\(created) · \(last)" | ||
| 1002 | } | ||
| 1003 | } | ||
| 1004 | ``` | ||
| 1005 | |||
| 1006 | - [ ] **Step 4: Offer it from the account menu, to admins only** | ||
| 1007 | |||
| 1008 | In `gitbay/Views/Repos/RepoListView.swift`, in `AccountMenu`: | ||
| 1009 | |||
| 1010 | Add after `@Environment(SessionStore.self) private var session`: | ||
| 1011 | |||
| 1012 | ```swift | ||
| 1013 | /// Read from `whoami` when the menu appears and on an account switch. | ||
| 1014 | /// The menu has no model; this is the one read that reaches it. | ||
| 1015 | @State private var isAdmin = false | ||
| 1016 | ``` | ||
| 1017 | |||
| 1018 | Inside the `Section(current.label) { … }`, after the Snippets `NavigationLink`, add: | ||
| 1019 | |||
| 1020 | ```swift | ||
| 1021 | if isAdmin { | ||
| 1022 | NavigationLink(value: RepoRoute.admin) { | ||
| 1023 | Label("Admin", systemImage: "gearshape.2") | ||
| 1024 | } | ||
| 1025 | .accessibilityIdentifier("account-menu-admin") | ||
| 1026 | } | ||
| 1027 | ``` | ||
| 1028 | |||
| 1029 | Replace the menu's label | ||
| 1030 | |||
| 1031 | ```swift | ||
| 1032 | } label: { | ||
| 1033 | Image(systemName: "person.crop.circle") | ||
| 1034 | } | ||
| 1035 | ``` | ||
| 1036 | |||
| 1037 | with | ||
| 1038 | |||
| 1039 | ```swift | ||
| 1040 | } label: { | ||
| 1041 | Image(systemName: "person.crop.circle") | ||
| 1042 | // ToolbarContent takes no .task; the label is a view. | ||
| 1043 | .task(id: session.current?.id) { | ||
| 1044 | isAdmin = false | ||
| 1045 | if let client = session.client { | ||
| 1046 | isAdmin = await AdminCheck.isAdmin(client) | ||
| 1047 | } | ||
| 1048 | } | ||
| 1049 | } | ||
| 1050 | ``` | ||
| 1051 | |||
| 1052 | - [ ] **Step 5: Build and run the unit target** | ||
| 1053 | |||
| 1054 | Expected: same count as after Task 2, 0 failed. | ||
| 1055 | |||
| 1056 | - [ ] **Step 6: Commit** | ||
| 1057 | |||
| 1058 | ```bash | ||
| 1059 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay/Views/Admin gitbay/Views/Repos/RepoRoute.swift gitbay/ContentView.swift gitbay/Views/Repos/RepoListView.swift && git commit -m "admin: the queues and accounts screens, offered to admins | ||
| 1060 | |||
| 1061 | The account menu shows Admin when whoami says the account is an | ||
| 1062 | instance admin." | ||
| 1063 | ``` | ||
| 1064 | |||
| 1065 | --- | ||
| 1066 | |||
| 1067 | ### Task 4: Live and manual checks, then the MR | ||
| 1068 | |||
| 1069 | **Files:** | ||
| 1070 | - Modify: `gitbayUITests/LiveSmokeUITests.swift` (`testProfileAndNavigationFlows`, the account menu step) | ||
| 1071 | |||
| 1072 | **Interfaces:** | ||
| 1073 | - Consumes: `account-menu`, `account-menu-admin` (Task 3). | ||
| 1074 | |||
| 1075 | - [ ] **Step 1: Assert a non-admin is not offered Admin** | ||
| 1076 | |||
| 1077 | In `testProfileAndNavigationFlows()`, replace | ||
| 1078 | |||
| 1079 | ```swift | ||
| 1080 | XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5), | ||
| 1081 | "keys not in the account menu") | ||
| 1082 | app.tap() // dismiss the menu; the profile is already on screen | ||
| 1083 | ``` | ||
| 1084 | |||
| 1085 | with | ||
| 1086 | |||
| 1087 | ```swift | ||
| 1088 | XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5), | ||
| 1089 | "keys not in the account menu") | ||
| 1090 | // ios-smoke is not an instance admin. | ||
| 1091 | XCTAssertFalse(app.descendants(matching: .any) | ||
| 1092 | .matching(identifier: "account-menu-admin").firstMatch.exists, | ||
| 1093 | "a non-admin is offered Admin") | ||
| 1094 | app.tap() // dismiss the menu; the profile is already on screen | ||
| 1095 | ``` | ||
| 1096 | |||
| 1097 | - [ ] **Step 2: Build the UI test target and run the test (needs a token from the user)** | ||
| 1098 | |||
| 1099 | ```bash | ||
| 1100 | cd /Users/cmc/git/krz/gitbay-ios && TEST_RUNNER_GITBAY_UITEST_LIVE=1 TEST_RUNNER_GITBAY_UITEST_TOKEN="$TOKEN" \ | ||
| 1101 | xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' \ | ||
| 1102 | -only-testing:gitbayUITests/LiveSmokeUITests/testProfileAndNavigationFlows \ | ||
| 1103 | -resultBundlePath /tmp/gb-live.xcresult test 2>&1 | grep -E "error:|passed|failed|TEST (SUCCEEDED|FAILED)" | tail -10 | ||
| 1104 | ``` | ||
| 1105 | |||
| 1106 | Expected: `passed`. The token is for `ios-smoke`, minted by the user as in earlier plans. | ||
| 1107 | |||
| 1108 | - [ ] **Step 3: Read-only look as an admin (the user's call)** | ||
| 1109 | |||
| 1110 | Ask the user whether they want to sign the simulator in as `cmc` to look at the screens. If yes, they paste their own token into the app; never type it yourself. Then, on the iPhone 17 Pro simulator: open My Profile → account menu → Admin, confirm six sections and the server build match `https://gitbay.org/admin`; open Accounts, switch through the five filters, scroll to the end of All. Open one row's menu and check its two items against the web's row for the same account. **Do not confirm any action.** Screenshot Admin and Accounts in light and dark, and afterwards remind the user to sign the simulator back out or reset its keychain (`xcrun simctl keychain 0A03DF15-0F45-4726-8080-DBFE919F46B6 reset`) before the next live run. | ||
| 1111 | |||
| 1112 | - [ ] **Step 4: Design greps** | ||
| 1113 | |||
| 1114 | ```bash | ||
| 1115 | cd /Users/cmc/git/krz/gitbay-ios && grep -rnE '\.(green|red|blue|orange|yellow|purple)\b' gitbay/ --include=*.swift | grep -v gb; \ | ||
| 1116 | grep -rn '\.font(\.' gitbay/Views/Admin | grep -vE 'gbSans|gbMono'; \ | ||
| 1117 | grep -rn 'cornerRadius:' gitbay/Views/Admin | grep -v 'cornerRadius: 2)' | ||
| 1118 | ``` | ||
| 1119 | |||
| 1120 | Expected: no output. | ||
| 1121 | |||
| 1122 | - [ ] **Step 5: Both runtimes and a Release device build** | ||
| 1123 | |||
| 1124 | Run the unit target on iPhone 17 Pro (26.5), then: | ||
| 1125 | |||
| 1126 | ```bash | ||
| 1127 | cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ | ||
| 1128 | -destination 'id=9F8E5D84-0A15-4C64-8A45-994667A73817' -only-testing:gitbayTests test 2>&1 | grep -E "TEST (SUCCEEDED|FAILED)" | ||
| 1129 | xcodebuild build -scheme gitbay -configuration Release -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO 2>&1 \ | ||
| 1130 | | grep -E "error:|While running pass|BUILD (SUCCEEDED|FAILED)" | tail -5 | ||
| 1131 | ``` | ||
| 1132 | |||
| 1133 | Expected: `** TEST SUCCEEDED **` and `** BUILD SUCCEEDED **`. On iOS 18, `formatted(.relative(presentation: .named))` and the `switch` expressions in `AdminAction` are both available. | ||
| 1134 | |||
| 1135 | - [ ] **Step 6: Commit, push, open the MR** | ||
| 1136 | |||
| 1137 | ```bash | ||
| 1138 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbayUITests/LiveSmokeUITests.swift && git commit -m "live suite: a non-admin is not offered Admin" && \ | ||
| 1139 | git push -u origin admin && gitbay mr create --source admin --target main \ | ||
| 1140 | --title "Admin: worker queues and accounts, as the web has them" --file - <<'EOF' | ||
| 1141 | The web's /admin and /admin/users, for an instance admin. | ||
| 1142 | |||
| 1143 | - Admin: the server build and dashboard's six worker queues, with the | ||
| 1144 | web's headings, counts lines, rows and empty sentences. | ||
| 1145 | - Accounts: admin user list with the web's five filters, paged. Each | ||
| 1146 | row offers the web's role and access actions; demote and disable ask | ||
| 1147 | for the username typed. | ||
| 1148 | - The account menu shows Admin when whoami says the account is an | ||
| 1149 | admin. | ||
| 1150 | |||
| 1151 | No account show, invite, runners, repository administration, audit | ||
| 1152 | log or statistics: the web has none of them either. | ||
| 1153 | |||
| 1154 | Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md | ||
| 1155 | EOF | ||
| 1156 | ``` | ||
| 1157 | |||
| 1158 | --- | ||
| 1159 | |||
| 1160 | ### Task 5: Bump to 1.6.0 (15) | ||
| 1161 | |||
| 1162 | Starts only after the user has merged the MR from Task 4. | ||
| 1163 | |||
| 1164 | **Files:** | ||
| 1165 | - Modify: `gitbay.xcodeproj/project.pbxproj` (the four version lines) | ||
| 1166 | |||
| 1167 | - [ ] **Step 1: Branch, bump, verify, open the MR** | ||
| 1168 | |||
| 1169 | ```bash | ||
| 1170 | cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c bump-1.6.0 && \ | ||
| 1171 | sed -i '' -e 's/MARKETING_VERSION = 1.5.0;/MARKETING_VERSION = 1.6.0;/' \ | ||
| 1172 | -e 's/CURRENT_PROJECT_VERSION = 14;/CURRENT_PROJECT_VERSION = 15;/' gitbay.xcodeproj/project.pbxproj && \ | ||
| 1173 | git diff --stat && plutil -lint gitbay.xcodeproj/project.pbxproj | ||
| 1174 | ``` | ||
| 1175 | |||
| 1176 | Expected: `1 file changed, 4 insertions(+), 4 deletions(-)` and `OK`. Run the unit target, then: | ||
| 1177 | |||
| 1178 | ```bash | ||
| 1179 | cd /Users/cmc/git/krz/gitbay-ios && git add gitbay.xcodeproj/project.pbxproj && git commit -m "Bump to 1.6.0 (15)" && \ | ||
| 1180 | git push -u origin bump-1.6.0 && gitbay mr create --source bump-1.6.0 --target main --title "Bump to 1.6.0 (15)" --body "Admin: worker queues and accounts." | ||
| 1181 | ``` | ||
| 1182 | |||
| 1183 | Tagging `v1.6.0`, archiving and uploading are the user's. | ||
| 1184 | |||
| 1185 | - [ ] **Step 2: Flip the Parity rows upstream** | ||
| 1186 | |||
| 1187 | Once 1.6.0 is released, in `~/git/krz/gitbay` on a new branch `parity-ios-1.6.0`, set the iOS column to `yes` for `account list, filter by state`, `promote, demote`, `disable, enable` and `worker queues` under Administration. Commit, push, and open the MR titled "Parity: iOS 1.6.0 rows". | ||
docs/superpowers/specs/2026-09-22-parity-followup-design.md added +220
| @@ -0,0 +1,220 @@ | |||
| 1 | # Parity follow-up: gitbay v1.28.1 to v1.34.1 | ||
| 2 | |||
| 3 | An audit on 2026-09-22 of the Parity wiki page (`krz/gitbay`, | ||
| 4 | `.gitbay/wiki/Parity.org`) and of every gitbay release since the app's | ||
| 5 | v1.2.0 (server v1.28.1 through v1.34.1, the last unreleased on main) | ||
| 6 | found one regression, three gaps the page records, two it does not, and | ||
| 7 | one row that is wrong. This spec covers all of them in four releases. | ||
| 8 | |||
| 9 | Push notifications (v1.32.0, v1.32.1) are already done on main and are | ||
| 10 | not restated here; see `2026-09-20-push-notifications-design.md`. | ||
| 11 | |||
| 12 | ## Findings | ||
| 13 | |||
| 14 | | # | Finding | Source | Kind | | ||
| 15 | |---|---------|--------|------| | ||
| 16 | | 1 | Profile and org-profile save fail: `unknown flag "--about-format"`, exit 2 | v1.31.0 | regression | | ||
| 17 | | 2 | `build list` returns `subject`; the app does not decode it | v1.33.0 | unrowed gap | | ||
| 18 | | 3 | `build list` pages with `--limit`/`--cursor`; the app does not | v1.33.0 | wrong row (page says yes) | | ||
| 19 | | 4 | MR labels: `mr label --add/--remove` | page | gap | | ||
| 20 | | 5 | MR filter by label: `mr list --label` | page | gap | | ||
| 21 | | 6 | Preview body markup: issue, MR, file editor, release notes | page, v1.30.0 | gap (4 rows) | | ||
| 22 | | 7 | Admin: account list and state filter, promote/demote, disable/enable, worker queues | page, v1.30.0 | gap (4 rows) | | ||
| 23 | |||
| 24 | Finding 1 was confirmed against gitbay.org. `ProfileEdit.flags()` | ||
| 25 | appends `--about-format <f> --file -` on every save, and | ||
| 26 | `parseProfileFlags` in `internal/control/profile.go` accepts only | ||
| 27 | `--description`, `--website` and `--link`. The shipped 1.2.0 is | ||
| 28 | affected. | ||
| 29 | |||
| 30 | ## Releases | ||
| 31 | |||
| 32 | | Version | Carries | | ||
| 33 | |---------|---------| | ||
| 34 | | 1.3.0 (12) | push (on main) and finding 1 | | ||
| 35 | | 1.4.0 (13) | findings 2–5 | | ||
| 36 | | 1.5.0 (14) | finding 6 | | ||
| 37 | | 1.6.0 (15) | finding 7 | | ||
| 38 | |||
| 39 | Each is a squash-merged MR per feature, then a bump MR touching the | ||
| 40 | four `MARKETING_VERSION`/`CURRENT_PROJECT_VERSION` lines, then a | ||
| 41 | lightweight tag on the bump's merge commit. | ||
| 42 | |||
| 43 | ## 1.3.0: profile about | ||
| 44 | |||
| 45 | The about text has been a file since v1.31.0: `profile/README.md`, | ||
| 46 | `.org` or `.markdown`, resolved in that order, on the default branch of | ||
| 47 | `<owner>/.gitbay`. `profile show` returns it as `about`, `about_format` | ||
| 48 | and `about_path`. `profile set` and `org profile` take description, | ||
| 49 | website and links only. | ||
| 50 | |||
| 51 | The web's account page no longer edits the text. It links to the file | ||
| 52 | editor and, when there is no file, offers to create the repository with | ||
| 53 | a starter README (`internal/httpd/account.go`, the `profile-repo` | ||
| 54 | form). The app does the same. | ||
| 55 | |||
| 56 | - `ProfileEdit` drops `about`, `aboutFormat` and `aboutStdin`, and | ||
| 57 | `flags()` drops the `--about-format`/`--file -` tail. | ||
| 58 | `ProfileViewModel.saveProfile` stops passing stdin. The same argv | ||
| 59 | serves `profile set` and `org profile <org>`. | ||
| 60 | - `ProfileEditSheet` drops the about `TextEditor` and format picker. | ||
| 61 | - `Profile` decodes `about_path`. `aboutFile` is deleted; | ||
| 62 | `ReadmeView` receives `aboutPath`, so the file's extension picks the | ||
| 63 | renderer. | ||
| 64 | - `ProfileView`'s about section, for a viewer where `canEdit` is true, | ||
| 65 | gains an Edit action that pushes | ||
| 66 | `.file(repo: "<owner>/.gitbay", path: aboutPath, ref: nil)`. The file | ||
| 67 | screen's existing edit action and `repo commit-file` do the write. | ||
| 68 | - With no `about_path` and `canEdit` true, the section offers "Create | ||
| 69 | about file". It runs `repo show <owner>/.gitbay`; on exit 3 (not | ||
| 70 | found) it runs `repo create <owner>/.gitbay`. It then runs | ||
| 71 | `repo commit-file <owner>/.gitbay profile/README.md --ref <branch> | ||
| 72 | --message "add profile about" --file -`, where `<branch>` is the | ||
| 73 | existing repository's `default_branch` from `repo show` (the server | ||
| 74 | reads the about file from the default branch) or `main` for a | ||
| 75 | repository just created, with the web's starter text | ||
| 76 | (`# <owner>\n\nThis is the about text on your profile.\n`), and | ||
| 77 | reloads. Any other failure is shown and stops the flow. `repo create` accepts `<org>/.gitbay` from an org admin | ||
| 78 | (`resolveNewRepoOwner`, `internal/control/repo.go`), so the offer is | ||
| 79 | the same for users and organizations. | ||
| 80 | |||
| 81 | Tests: `ProfileEdit.flags()` carries no `--about-format` or `--file`; | ||
| 82 | `saveProfile` sends no stdin; `Profile` decodes `about_path`; the | ||
| 83 | create flow issues `repo create` then `repo commit-file` with the | ||
| 84 | starter on stdin, and skips `repo create` when `repo show` succeeds. The live | ||
| 85 | suite's profile step saves a description and asserts success. | ||
| 86 | |||
| 87 | ## 1.4.0: build list and MR labels | ||
| 88 | |||
| 89 | ### Build list | ||
| 90 | |||
| 91 | - `Build` gains `subject: String?`. | ||
| 92 | - `BuildRow` leads with `#<n> <subject>`, falling back to | ||
| 93 | `#<n> <shortSHA>` when the commit is gone. The meta line carries job, | ||
| 94 | ref and `shortSHA`. | ||
| 95 | - `BuildListViewModel` replaces `readList` with | ||
| 96 | `PagedListModel<Build>(pageSize: 30)`, the web's page size. The | ||
| 97 | `sorted { $0.number > $1.number }` goes: cursor pages arrive in | ||
| 98 | server order and a client sort would reorder across page boundaries. | ||
| 99 | A filter change sets `argv` and calls `reload()`. | ||
| 100 | - `BuildListView` gains `PageFooter`, as the issue and MR lists have. | ||
| 101 | |||
| 102 | `load()` never set `.loading` before; reload tests wait on the final | ||
| 103 | state, not on "not loading". | ||
| 104 | |||
| 105 | ### MR labels | ||
| 106 | |||
| 107 | A copy of the issue implementation. | ||
| 108 | |||
| 109 | - `MRDetailViewModel` loads `label list <repo>` for the chips' colours | ||
| 110 | and gains `addLabel`/`removeLabel`, sending | ||
| 111 | `mr label <repo> <n> --add|--remove <l>`, as `IssueDetailViewModel` | ||
| 112 | does for `issue label`. | ||
| 113 | - `MRView` shows the label chips, each removable, and the add field | ||
| 114 | `IssueView` has. | ||
| 115 | - `MRFilter` gains `label: String?`, rendered as `--label`. Its doc | ||
| 116 | comment, which says the command takes no `--label`, is corrected. | ||
| 117 | The MR filter sheet gains the label picker the issue sheet has. | ||
| 118 | |||
| 119 | Tests: `Build` decodes with and without `subject`; the row title | ||
| 120 | falls back; the build list sends `--limit 30`, appends the second page | ||
| 121 | on `loadMore`, and does not reorder; `MRFilter.flags()` with a label; | ||
| 122 | `mr label` argv for add and remove. | ||
| 123 | |||
| 124 | ## 1.5.0: markup previews | ||
| 125 | |||
| 126 | The web previews by posting the draft back to its own form, where the | ||
| 127 | server renders it with autolinks resolved for the viewer. No command | ||
| 128 | renders markup (the CLI row is `n/a`), and the app resolves no | ||
| 129 | autolinks anywhere. The app previews locally with `FormattedBodyView`, | ||
| 130 | the view its threads, bodies and comments already render with. The | ||
| 131 | preview therefore matches what the app shows, not what the web shows: | ||
| 132 | `#N` stays plain text in both the preview and the thread. | ||
| 133 | |||
| 134 | - New `MarkupPreview(text:format:identifier:)` in | ||
| 135 | `gitbay/Views/Shared/`: a collapsed "Preview" disclosure row that | ||
| 136 | renders `FormattedBodyView(text:format:)` when expanded, and "Nothing | ||
| 137 | to preview." for blank text. It sits under the editor rather than | ||
| 138 | replacing it, so the draft stays visible and editable, as on the web. | ||
| 139 | - `MarkupPreview.format(forFile:)` maps a file name to the format its | ||
| 140 | contents render in: `org` for `.org`, `md` for `.md` and | ||
| 141 | `.markdown`, nil for anything else. | ||
| 142 | - Call sites, each passing the format it already sends: | ||
| 143 | - `ComposeSheet`'s body: issue create, issue and MR edit, release | ||
| 144 | edit. The format follows the sheet's `format` binding, which is | ||
| 145 | `md` where the sheet shows no picker. | ||
| 146 | - `MRCreateSheet` in `MRListView`: its own `format`. | ||
| 147 | - The comment rows in `IssueView` and `MRView`: their | ||
| 148 | `commentFormat` picker. Shown only while the comment is non-empty. | ||
| 149 | - `ReleaseCreateSheet` in `ReleaseListView`: `md`, since | ||
| 150 | `release create` sends no `--format` and the app renders notes as | ||
| 151 | markdown. | ||
| 152 | - `FileEditSheet`: only when `format(forFile:)` is non-nil, with that | ||
| 153 | format. | ||
| 154 | |||
| 155 | Tests: `format(forFile:)` for `.org`, `.md`, `.markdown`, `.MD`, | ||
| 156 | `.swift` and a name without an extension. The live suite types a | ||
| 157 | comment on the fixture issue, expands its preview and finds the | ||
| 158 | rendered text, then clears the field without sending. | ||
| 159 | |||
| 160 | ## 1.6.0: admin | ||
| 161 | |||
| 162 | Matches the web's `/admin` and `/admin/users` and nothing else. Account | ||
| 163 | show, invite, runners, repository administration, the audit log and | ||
| 164 | instance statistics stay `no` on both surfaces. | ||
| 165 | |||
| 166 | - **Who sees it.** `whoami` returns `admin`. The My Profile tab reads it | ||
| 167 | when it appears (and again on an account switch) and passes it to | ||
| 168 | `AccountMenu`, which shows Admin only when it is true. The menu has no | ||
| 169 | model of its own and the dashboard's data lives on another tab, so | ||
| 170 | this is the one read that reaches it; `SessionStore` is unchanged. | ||
| 171 | - **The admin screen** reads `dashboard` and decodes its admin-only | ||
| 172 | `queues` and `server` blocks, the read the web's `/admin` page makes | ||
| 173 | (`internal/httpd/admin.go`). It shows the server commit, a link to | ||
| 174 | Accounts, and the web's six sections in the web's order — webhook | ||
| 175 | deliveries, mail, push, mirrors, builds, dependency checks — each with | ||
| 176 | its heading count, its counts line, its retrying or failed rows, and | ||
| 177 | the web's empty-queue sentence. A dashboard without `queues` (the | ||
| 178 | caller is not an admin) is an empty state saying so. | ||
| 179 | - **Accounts** pages `admin user list [--state active|pending|disabled|admin]` | ||
| 180 | through `PagedListModel`, with the web's five filters (all, active, | ||
| 181 | pending, disabled, admins) as a segmented picker. Each row carries the | ||
| 182 | web's two actions, in a per-row menu: Demote for an admin, Promote | ||
| 183 | for an active account and a disabled Promote for anyone else; Enable | ||
| 184 | for a disabled account and Disable otherwise. Each sends | ||
| 185 | `admin user promote|demote|disable|enable <username>` and reloads. | ||
| 186 | - Demote and disable ask for the username to be typed before they | ||
| 187 | send, as the web does. Promote and enable ask with a plain alert. | ||
| 188 | |||
| 189 | Tests: the six sections' headings, counts, summaries and row text from | ||
| 190 | a recorded payload; a dashboard without `queues`; `whoami`'s `admin`, | ||
| 191 | including a failed read counting as not admin; each row state's two | ||
| 192 | actions; `admin user list` argv per filter and across a page; each | ||
| 193 | write's argv and its reload; a refusal surfacing. The live suite | ||
| 194 | asserts that `ios-smoke`, which is not an admin, is not offered Admin. | ||
| 195 | |||
| 196 | ## Upstream: krz/gitbay | ||
| 197 | |||
| 198 | One MR alongside 1.3.0: | ||
| 199 | |||
| 200 | - Parity: iOS `build list paging (limit, cursor)` from `yes` to `no`; | ||
| 201 | a row for "build row names its commit" (cli yes, web yes, ios no); | ||
| 202 | a sentence under the preview paragraph that the iOS preview is the | ||
| 203 | app's own rendering and resolves no autolinks. | ||
| 204 | - The published v1.31.0 release notes give the about file as | ||
| 205 | `about.org` or `about.md`; they are edited with `release edit` to | ||
| 206 | `profile/README.md` or `profile/README.org`. The CHANGELOG entry for | ||
| 207 | v1.31.0 already names the right path and is unchanged. | ||
| 208 | |||
| 209 | Later MRs flip rows as each release ships: the build rows at 1.4.0, | ||
| 210 | MR labels and filter by label at 1.4.0, the four preview rows at 1.5.0, | ||
| 211 | and account list, promote/demote, disable/enable and worker queues at | ||
| 212 | 1.6.0. | ||
| 213 | |||
| 214 | ## Not in scope | ||
| 215 | |||
| 216 | - Autolink resolution in the app. | ||
| 217 | - A server-side `render` command. | ||
| 218 | - Admin beyond what the web carries. | ||
| 219 | - Bookmarks as a profile section: the app keeps them as their own | ||
| 220 | screen, and the Parity row stays `no`. | ||