Commit cfbeb7f5a5
Verified · cmc
Layout: unified · split
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. | ||