Commit 34f139b808

34f139b808df62ac5a78d5c49979cc460dc698c4

parent: c704bdfa94

Verified · cmc

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

docs: plan for 1.6.0, admin

Layout: unified · split

docs/superpowers/plans/2026-09-22-1.6.0-admin.md added +1187
@@ -0,0 +1,1187 @@
1# 1.6.0 Admin Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** An instance admin can do from the app what the web's `/admin` and `/admin/users` pages do: read every worker queue and the server build, and list, filter, promote, demote, disable and enable accounts.
6
7**Architecture:** Pure models in `gitbay/Admin/AdminModels.swift` decode `dashboard`'s admin-only `queues`/`server` blocks and `admin user list` rows, and turn them into the web's sections and per-row actions. Two view models read and write through the existing `GitbayClient`; the accounts list pages through `PagedListModel`. The account menu reads `whoami`'s `admin` flag to decide whether to offer the screen. Nothing beyond what the two web pages carry.
8
9**Tech Stack:** Swift 6 language mode with default `MainActor` isolation, SwiftUI, Swift Testing, XCUITest. iOS 18.0 minimum, iOS 26.5 SDK.
10
11**Spec:** `docs/superpowers/specs/2026-09-22-parity-followup-design.md` (section "1.6.0: admin")
12
13## Global Constraints
14
15- **Never attribute anything to an assistant or model** — not in commits, code comments, MR bodies, or docs. No `Co-Authored-By` trailer. This is absolute.
16- **Commits are signed.** `commit.gpgsign` is on; do not pass `--no-gpg-sign`.
17- **Never push to `main`. Never merge.** One branch, `admin`, cut from `main` after 1.5.0 has merged. Task 5 is the version bump and waits for the user to merge it.
18- **Never run an admin write against gitbay.org from a test or by hand.** Promote, demote, disable and enable act on real people's accounts. Unit tests stub every request; the live suite only checks that a non-admin is not offered the screen.
19- **Match the web, no more.** No account show, invite, runners, repository administration, audit log or statistics. Promote only an active account; typed-name confirmation for demote and disable only.
20- **Swift 6, default `MainActor` isolation, in the test target too.** Wire models, pure section types and any `Decodable` struct declared in a test are `nonisolated`, or their conformance is main-actor isolated and `JSONDecoder` refuses it.
21- **File-system-synchronized groups.** New `.swift` files under `gitbay/` and `gitbayTests/` are picked up automatically. Do not edit `project.pbxproj` except for the version lines in Task 5.
22- **Reads are `GET /api/v1/read?argv=…`; writes are `POST /api/v1/cmd`.** A stub's `match:` is a URL substring. Assert writes by filtering `stub.seen` on `method == "POST"`. A paged read's envelope is `{"data":{"items":[…],"next":"…"}}`.
23- **Design tokens only.** `.gbSans`/`.gbMono` fonts, `GBChip` for chips, `GBNotice` for errors, `gb` palette colours. Run the design greps in Task 4.
24- Comments explain *why*, in plain direct English, at the density of the surrounding code. No before/after commentary.
25- **Run the whole unit target, and read the count.**
26
27 ```bash
28 cd /Users/cmc/git/krz/gitbay-ios && rm -rf /tmp/gb.xcresult && xcodebuild -project gitbay.xcodeproj -scheme gitbay \
29 -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' -only-testing:gitbayTests \
30 -resultBundlePath /tmp/gb.xcresult test 2>&1 | grep -E "error:|failed|TEST (SUCCEEDED|FAILED)" | tail -20
31 xcrun xcresulttool get test-results summary --path /tmp/gb.xcresult --format json \
32 | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['result'], d['totalTestCount'], d['failedTests'])"
33 ```
34
35 "Run the unit target" below means these two commands. Record the count before Task 1 as `BASE`.
36
37---
38
39### Task 1: Queue sections, as the web lays them out
40
41**Files:**
42- Create: `gitbay/Admin/AdminModels.swift`
43- Create: `gitbayTests/AdminTests.swift`
44
45**Interfaces:**
46- Produces:
47 - `AdminDashboard: Decodable` with `queues: AdminQueues?`, `server: AdminDashboard.Server?` (`commit: String?`).
48 - `AdminQueues: Decodable` with `webhooks`, `mail`, `push`, `mirrors`, `builds`, `deps: AdminQueues.Queue`, and `func sections(when: (String) -> String = AdminQueues.relative) -> [QueueSection]`.
49 - `static func AdminQueues.relative(_ timestamp: String) -> String`.
50 - `QueueSection` (`id: String`, `heading: String`, `count: Int`, `summary: String?`, `rows: [QueueLine]`, `empty: String`); `QueueLine` (`id: Int`, `title: String`, `detail: String`).
51
52- [ ] **Step 1: Branch**
53
54```bash
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".