Commit cfbeb7f5a5

cfbeb7f5a53d27cce9dbde05b92071270b370059

parent: fc12b3c9dc

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-22 17:32 UTC

docs: plan for 1.3.0, profile about

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
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.