docs/superpowers/plans/2026-09-22-1.3.0-profile-about.md

v1.14.0
gitbay-ios/docs/superpowers/plans/2026-09-22-1.3.0-profile-about.md rendered · source · history · blame · raw

786 lines · 33464 bytes

  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
 52cd /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
 57In `gitbayTests/ProfileEditTests.swift`:
 58
 59Replace 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
 70Delete `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
 84In `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
100In `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
132Run the unit target (see Global Constraints).
133Expected: `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
137Replace the whole of `gitbay/Discovery/ProfileEdit.swift` with:
138
139```swift
140import 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.
151nonisolated 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
208In `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
222with:
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
237In `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
246and 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
250Run the unit target.
251Expected: `Passed 460 0` — two about tests and one seeding test deleted, one flag test added.
252
253- [ ] **Step 8: Commit**
254
255```bash
256cd /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
258gitbay v1.31.0 moved the about text into <owner>/.gitbay, and profile
259set and org profile refuse --about-format and --file as unknown flags,
260so 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
278In `gitbayTests/ProfileCommitTests.swift`, in `userProfileJSON`, change
279
280```
281    "about":"* Christian\\n\\nOrg-Mode.","about_format":"org",\
282```
283
284to
285
286```
287    "about":"* Christian\\n\\nOrg-Mode.","about_format":"org","about_path":"profile/README.org",\
288```
289
290In `aUserProfileCarriesOrgsReposAndActivity()`, replace
291
292```swift
293        // The format picks the filename the about text renders under.
294        #expect(profile.aboutFile == "about.org")
295```
296
297with
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
304In `anOrgProfileCarriesMembersInsteadOfOrgs()`, replace
305
306```swift
307        // Markdown is the default for anything but org.
308        #expect(profile.aboutFile == "about.md")
309```
310
311with
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
320Expected: build FAILS with `value of type 'ProfileViewModel.Profile' has no member 'aboutPath'`.
321
322- [ ] **Step 3: Decode `about_path`, drop the format guess**
323
324In `gitbay/Discovery/ProfileViewModel.swift`, in `Profile`:
325
326Replace
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
335with
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
346In `CodingKeys`, replace `case aboutFormat = "about_format"` with `case aboutPath = "about_path"`.
347
348Delete 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
359In `gitbay/Views/Discovery/ProfileView.swift`, in `aboutSection`, replace
360
361```swift
362                ReadmeView(name: profile.aboutFile, content: about)
363```
364
365with
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
375Expected: `Passed 460 0`.
376
377- [ ] **Step 6: Commit**
378
379```bash
380cd /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
398Append to `gitbayTests/ProfileCommitTests.swift`:
399
400```swift
401private 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
408private let okCmdJSON = #"{"protocol_version":1,"exit_code":0}"#
409
410@MainActor
411struct 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
487Expected: build FAILS with `value of type 'ProfileViewModel' has no member 'createAboutFile'` and `… no member 'aboutRepo'`.
488
489- [ ] **Step 3: Implement `createAboutFile`**
490
491In `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
532Expected: `Passed 464 0`.
533
534- [ ] **Step 5: Add the Edit and Create actions to the about section**
535
536In `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
576Expected: `Passed 464 0`. The view has no unit test; Task 4's live step covers it.
577
578- [ ] **Step 7: Commit**
579
580```bash
581cd /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
583The about section links to profile/README.* in <owner>/.gitbay for a
584viewer who may write it, and offers to create the repository and a
585starter 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
598The `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
602In `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
629cd /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
633Expected: `** 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
637The 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
640cd /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
647Expected: `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
652cd /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
658cd /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
663Expected: `** TEST SUCCEEDED **`, same count as on iOS 26.5.
664
665- [ ] **Step 6: Release build for device**
666
667```bash
668cd /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
672Expected: `** BUILD SUCCEEDED **`.
673
674- [ ] **Step 7: Push the branch and open the MR**
675
676```bash
677cd /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'
679gitbay v1.31.0 moved the profile about text into <owner>/.gitbay and
680removed --about-format and --file from profile set and org profile.
681Every 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
689Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md
690EOF
691```
692
693Main 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
708cd /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
713In `.gitbay/wiki/Parity.org`, replace
714
715```
716| build list paging (limit, cursor) | yes | yes | yes |
717```
718
719with
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
728In the same file, the preview paragraph ends with
729
730```
731=issue show= already renders. Diff-line comments are left out — they
732sit inside the diff, where a page-level preview has nowhere to go.
733```
734
735Append after it, as a new paragraph:
736
737```
738
739The iOS client resolves no autolinks: =#N= and =owner/name#N= render as
740text in its threads, so a preview there will show the app's rendering
741rather than the one the web page shows.
742```
743
744- [ ] **Step 4: Commit and open the MR**
745
746```bash
747cd /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
749The iOS build list reads one unpaged page and leads each row with a sha.
750Also note that the iOS client resolves no autolinks." && git push -u origin parity-ios-followup && \
751gitbay 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.
758EOF
759```
760
761- [ ] **Step 5: Correct the v1.31.0 release notes (confirm with the user first)**
762
763This 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
766cd /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
768python3 - <<'EOF'
769p = '/tmp/v1.31.0-notes.org'
770s = open(p).read()
771old = "at =about.org= or\n  =about.md= in =<owner>/.gitbay=,"
772new = "at =profile/README.md=\n  or =profile/README.org= in =<owner>/.gitbay=,"
773assert old in s, "sentence not found; the notes changed since the audit"
774open(p, 'w').write(s.replace(old, new))
775EOF
776diff <(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
779Expected: the diff shows only the two changed lines. After the user confirms:
780
781```bash
782cd /Users/cmc/git/krz/gitbay && gitbay release edit krz/gitbay v1.31.0 --file - < /tmp/v1.31.0-notes.org && \
783gitbay release show krz/gitbay v1.31.0 | grep -n "profile/README"
784```
785
786No `--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.