Build list: name the commit, page past thirty !123

merged merged by cmc on 2026-09-22 21:46 UTC · krz/gitbay-ios:build-list into main

13 files changed, +181 −3924

Layout: unified · split

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