docs/superpowers/plans/2026-09-22-1.6.0-admin.md
1187 lines · 49470 bytes
7 symbols in this file
1.6.0 Admin Implementation Plan
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.
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.
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.
Tech Stack: Swift 6 language mode with default MainActor isolation, SwiftUI, Swift Testing, XCUITest. iOS 18.0 minimum, iOS 26.5 SDK.
Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md (section "1.6.0: admin")
Global Constraints
-
Never attribute anything to an assistant or model — not in commits, code comments, MR bodies, or docs. No
Co-Authored-Bytrailer. This is absolute. -
Commits are signed.
commit.gpgsignis on; do not pass--no-gpg-sign. -
Never push to
main. Never merge. One branch,admin, cut frommainafter 1.5.0 has merged. Task 5 is the version bump and waits for the user to merge it. -
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.
-
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.
-
Swift 6, default
MainActorisolation, in the test target too. Wire models, pure section types and anyDecodablestruct declared in a test arenonisolated, or their conformance is main-actor isolated andJSONDecoderrefuses it. -
File-system-synchronized groups. New
.swiftfiles undergitbay/andgitbayTests/are picked up automatically. Do not editproject.pbxprojexcept for the version lines in Task 5. -
Reads are
GET /api/v1/read?argv=…; writes arePOST /api/v1/cmd. A stub'smatch:is a URL substring. Assert writes by filteringstub.seenonmethod == "POST". A paged read's envelope is{"data":{"items":[…],"next":"…"}}. -
Design tokens only.
.gbSans/.gbMonofonts,GBChipfor chips,GBNoticefor errors,gbpalette colours. Run the design greps in Task 4. -
Comments explain why, in plain direct English, at the density of the surrounding code. No before/after commentary.
-
Run the whole unit target, and read the count.
cd /Users/cmc/git/krz/gitbay-ios && rm -rf /tmp/gb.xcresult && xcodebuild -project gitbay.xcodeproj -scheme gitbay \ -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' -only-testing:gitbayTests \ -resultBundlePath /tmp/gb.xcresult test 2>&1 | grep -E "error:|failed|TEST (SUCCEEDED|FAILED)" | tail -20 xcrun xcresulttool get test-results summary --path /tmp/gb.xcresult --format json \ | python3 -c "import json,sys; d=json.load(sys.stdin); print(d['result'], d['totalTestCount'], d['failedTests'])""Run the unit target" below means these two commands. Record the count before Task 1 as
BASE.
Task 1: Queue sections, as the web lays them out
Files:
- Create:
gitbay/Admin/AdminModels.swift - Create:
gitbayTests/AdminTests.swift
Interfaces:
-
Produces:
AdminDashboard: Decodablewithqueues: AdminQueues?,server: AdminDashboard.Server?(commit: String?).AdminQueues: Decodablewithwebhooks,mail,push,mirrors,builds,deps: AdminQueues.Queue, andfunc sections(when: (String) -> String = AdminQueues.relative) -> [QueueSection].static func AdminQueues.relative(_ timestamp: String) -> String.QueueSection(id: String,heading: String,count: Int,summary: String?,rows: [QueueLine],empty: String);QueueLine(id: Int,title: String,detail: String).
-
Step 1: Branch
cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c admin
Run the unit target and record the count as BASE.
- Step 2: Write the failing tests
Create gitbayTests/AdminTests.swift:
import Foundation
import Testing
@testable import gitbay
private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
let box = StubProtocol.box()
let client = GitbayClient(
instance: try GitbayInstance(url: "https://gitbay.org"),
token: "test-token",
session: box.session()
)
return (client, box)
}
/// `dashboard` as an admin sees it, trimmed to the admin blocks, with
/// one row in every queue that lists rows.
private let adminDashboardJSON = """
{"protocol_version":1,"data":{"unread":0,\
"server":{"commit":"db7503f06f11"},\
"queues":{\
"webhooks":{"pending":2,"retrying":1,"failed":1,"oldest_pending":"2026-09-22T04:00:00Z","items":[\
{"id":9,"repo":"krz/gitbay","url":"https://ci.example.com/hook","attempts":5,\
"last_status":502,"last_error":"bad gateway","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T03:00:00Z"}]},\
"mail":{"pending":0,"retrying":1,"failed":0,"items":[\
{"id":4,"recipient":"a@example.com","subject":"krz/gitbay#12","attempts":2,\
"last_error":"dial tcp: timeout","created_at":"2026-09-22T03:00:00Z"}]},\
"mirrors":{"dirty":3,"errors":1,"items":[\
{"id":1,"repo":"krz/solar","direction":"push","url":"https://github.com/x/solar",\
"last_error":"auth failed"}]},\
"builds":{"pending":1,"running":1,"items":[\
{"repo":"krz/gitbay","number":1480,"job":"test","status":"running",\
"created_at":"2026-09-22T04:40:00Z","started_at":"2026-09-22T04:41:00Z"},\
{"repo":"krz/gitbay","number":1481,"job":"build","status":"pending",\
"created_at":"2026-09-22T04:42:00Z","started_at":""}]},\
"deps":{"errors":0,"items":[]},\
"push":{"pending":0,"retrying":0,"failed":1,"items":[\
{"id":7,"device_id":3,"title":"krz/gitbay!450 merged","attempts":3,\
"last_error":"BadDeviceToken","failed_at":"2026-09-22T05:00:00Z","created_at":"2026-09-22T04:00:00Z"}]}\
}},"exit_code":0}
"""
nonisolated private struct Envelope: Decodable { let data: AdminDashboard }
private func sections() throws -> [QueueSection] {
let dashboard = try GitbayClient.decoder()
.decode(Envelope.self, from: Data(adminDashboardJSON.utf8)).data
let queues = try #require(dashboard.queues)
// A fixed stand-in for relative time keeps the text deterministic.
return queues.sections(when: { "at \($0)" })
}
struct AdminQueueSectionTests {
@Test func sixSectionsInTheWebsOrder() throws {
let all = try sections()
#expect(all.map(\.heading) == [
"Webhook deliveries", "Mail", "Push", "Mirrors", "Builds", "Dependency checks",
])
#expect(all.map(\.count) == [2, 0, 0, 1, 1, 0])
}
@Test func countsLinesUseTheWebsWords() throws {
let all = try sections()
#expect(all[0].summary == "2 pending · 1 retrying · 1 dead-lettered · oldest pending at 2026-09-22T04:00:00Z")
#expect(all[1].summary == "0 pending · 1 retrying · 0 failed")
#expect(all[3].summary == "3 waiting for a sync · 1 with a failed last sync")
#expect(all[4].summary == "1 pending · 1 running")
#expect(all[5].summary == nil)
}
@Test func rowsSayWhatTheWebsTableColumnsSay() throws {
let all = try sections()
#expect(all[0].rows.map(\.title) == ["krz/gitbay"])
#expect(all[0].rows[0].detail
== "https://ci.example.com/hook · 5 attempts · dead-lettered at 2026-09-22T05:00:00Z · 502 bad gateway")
#expect(all[1].rows[0].title == "a@example.com")
#expect(all[1].rows[0].detail == "krz/gitbay#12 · 2 attempts · retrying · dial tcp: timeout")
#expect(all[2].rows[0].title == "device 3")
#expect(all[2].rows[0].detail
== "krz/gitbay!450 merged · 3 attempts · failed at 2026-09-22T05:00:00Z · BadDeviceToken")
#expect(all[3].rows[0].detail == "push · https://github.com/x/solar · never synced · auth failed")
// A pending build has started_at "" and reports since it was queued.
#expect(all[4].rows.map(\.title) == ["krz/gitbay #1480", "krz/gitbay #1481"])
#expect(all[4].rows[0].detail == "test · running · since at 2026-09-22T04:41:00Z")
#expect(all[4].rows[1].detail == "build · pending · since at 2026-09-22T04:42:00Z")
}
@Test func anEmptyQueueSaysSoInTheWebsWords() throws {
let deps = try #require(try sections().last)
#expect(deps.rows.isEmpty)
#expect(deps.empty == "No check has failed")
}
/// Everyone else's dashboard omits both blocks.
@Test func aNonAdminDashboardHasNoQueues() throws {
let json = #"{"protocol_version":1,"data":{"unread":3},"exit_code":0}"#
let dashboard = try GitbayClient.decoder().decode(Envelope.self, from: Data(json.utf8)).data
#expect(dashboard.queues == nil)
#expect(dashboard.server == nil)
}
@Test func anUnparseableTimestampIsShownAsIs() {
#expect(AdminQueues.relative("soon") == "soon")
}
}
- Step 3: Run the unit target to verify it fails
Expected: build FAILS with cannot find type 'AdminDashboard' in scope and cannot find type 'QueueSection' in scope.
- Step 4: Write the models
Create gitbay/Admin/AdminModels.swift:
import Foundation
/// The admin-only half of `dashboard`: the server build and every
/// background worker's backlog. The web's `/admin` page is this read.
/// Both are absent for anyone who is not an instance admin.
nonisolated struct AdminDashboard: Decodable, Sendable, Hashable {
let queues: AdminQueues?
let server: Server?
nonisolated struct Server: Decodable, Sendable, Hashable {
let commit: String?
}
}
/// `dashboard`'s `queues` block.
nonisolated struct AdminQueues: Decodable, Sendable, Hashable {
let webhooks: Queue
let mail: Queue
let push: Queue
let mirrors: Queue
let builds: Queue
let deps: Queue
/// Each queue reports a different subset of these counts; the
/// absent ones stay nil.
nonisolated struct Queue: Decodable, Sendable, Hashable {
let pending: Int?
let retrying: Int?
let failed: Int?
let running: Int?
let dirty: Int?
let errors: Int?
let oldestPending: String?
let items: [Row]?
enum CodingKeys: String, CodingKey {
case pending, retrying, failed, running, dirty, errors, items
case oldestPending = "oldest_pending"
}
var rows: [Row] { items ?? [] }
}
/// A row of any queue; each kind fills its own fields. Timestamps
/// stay strings: a pending build's `started_at` is `""`, which no
/// date decoder accepts.
nonisolated struct Row: Decodable, Sendable, Hashable {
let repo: String?
let url: String?
let recipient: String?
let subject: String?
let title: String?
let deviceID: Int64?
let direction: String?
let number: Int64?
let job: String?
let status: String?
let attempts: Int64?
let lastStatus: Int64?
let lastError: String?
let failedAt: String?
let lastSync: String?
let lastCheck: String?
let startedAt: String?
let createdAt: String?
enum CodingKeys: String, CodingKey {
case repo, url, recipient, subject, title, direction, number, job, status, attempts
case deviceID = "device_id"
case lastStatus = "last_status"
case lastError = "last_error"
case failedAt = "failed_at"
case lastSync = "last_sync"
case lastCheck = "last_check"
case startedAt = "started_at"
case createdAt = "created_at"
}
}
/// The web's six sections, in its order and its words
/// (`internal/web/templates/admin.html`). `when` renders a timestamp;
/// tests pass a fixed one.
func sections(when: (String) -> String = AdminQueues.relative) -> [QueueSection] {
func oldest(_ queue: Queue) -> String {
guard let at = queue.oldestPending, !at.isEmpty else { return "" }
return " · oldest pending \(when(at))"
}
func n(_ value: Int?) -> Int { value ?? 0 }
func attempts(_ row: Row) -> String? { row.attempts.map { "\($0) attempts" } }
func state(_ row: Row, failed: String) -> String {
guard let at = row.failedAt, !at.isEmpty else { return "retrying" }
return "\(failed) \(when(at))"
}
func lines(_ queue: Queue, _ make: (Row) -> (String, [String?])) -> [QueueLine] {
queue.rows.enumerated().map { index, row in
let (title, parts) = make(row)
let detail = parts.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ")
return QueueLine(id: index, title: title, detail: detail)
}
}
return [
QueueSection(
id: "webhooks", heading: "Webhook deliveries", count: n(webhooks.pending),
summary: "\(n(webhooks.pending)) pending · \(n(webhooks.retrying)) retrying · \(n(webhooks.failed)) dead-lettered" + oldest(webhooks),
rows: lines(webhooks) { row in
let error = [row.lastStatus.map(String.init), row.lastError]
.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ")
return (row.repo ?? "", [row.url, attempts(row), state(row, failed: "dead-lettered"), error])
},
empty: "Nothing retrying or dead-lettered"),
QueueSection(
id: "mail", heading: "Mail", count: n(mail.pending),
summary: "\(n(mail.pending)) pending · \(n(mail.retrying)) retrying · \(n(mail.failed)) failed" + oldest(mail),
rows: lines(mail) { row in
(row.recipient ?? "", [row.subject, attempts(row), state(row, failed: "failed"), row.lastError])
},
empty: "Nothing retrying or failed"),
// A push row names the device id, never the token.
QueueSection(
id: "push", heading: "Push", count: n(push.pending),
summary: "\(n(push.pending)) pending · \(n(push.retrying)) retrying · \(n(push.failed)) failed" + oldest(push),
rows: lines(push) { row in
("device \(row.deviceID.map(String.init) ?? "?")",
[row.title, attempts(row), state(row, failed: "failed"), row.lastError])
},
empty: "Nothing retrying or failed"),
QueueSection(
id: "mirrors", heading: "Mirrors", count: n(mirrors.errors),
summary: "\(n(mirrors.dirty)) waiting for a sync · \(n(mirrors.errors)) with a failed last sync",
rows: lines(mirrors) { row in
let sync = row.lastSync.flatMap { $0.isEmpty ? nil : "last sync \(when($0))" } ?? "never synced"
return (row.repo ?? "", [row.direction, row.url, sync, row.lastError])
},
empty: "Every mirror's last sync succeeded"),
QueueSection(
id: "builds", heading: "Builds", count: n(builds.pending),
summary: "\(n(builds.pending)) pending · \(n(builds.running)) running" + oldest(builds),
rows: lines(builds) { row in
let started = row.startedAt.flatMap { $0.isEmpty ? nil : $0 }
let since = (started ?? row.createdAt).map { "since \(when($0))" }
return ("\(row.repo ?? "") #\(row.number.map(String.init) ?? "?")", [row.job, row.status, since])
},
empty: "No build running or pending"),
QueueSection(
id: "deps", heading: "Dependency checks", count: n(deps.errors),
summary: nil,
rows: lines(deps) { row in
let check = row.lastCheck.flatMap { $0.isEmpty ? nil : "last check \(when($0))" } ?? "never checked"
return (row.repo ?? "", [check, row.lastError])
},
empty: "No check has failed"),
]
}
/// "5 minutes ago", or the timestamp itself when it does not parse.
static func relative(_ timestamp: String) -> String {
let fractional = ISO8601DateFormatter()
fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
guard let date = fractional.date(from: timestamp) ?? ISO8601DateFormatter().date(from: timestamp)
else { return timestamp }
return date.formatted(.relative(presentation: .named))
}
}
/// One section of the admin screen: a heading with its count, the counts
/// line, the rows, and what an empty queue says.
nonisolated struct QueueSection: Sendable, Hashable, Identifiable {
let id: String
let heading: String
let count: Int
let summary: String?
let rows: [QueueLine]
let empty: String
}
nonisolated struct QueueLine: Sendable, Hashable, Identifiable {
let id: Int
let title: String
let detail: String
}
- Step 5: Run the unit target to verify it passes
Expected: Passed <BASE + 6> 0. If a detail string differs, fix the model, not the test: the strings are the web template's.
- Step 6: Commit
cd /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"
Task 2: Accounts, their actions, and who is an admin
Files:
- Modify:
gitbay/Admin/AdminModels.swift(appendAdminUser,AdminAction,AdminUserFilter) - Create:
gitbay/Admin/AdminViewModels.swift - Modify:
gitbayTests/AdminTests.swift(append suites)
Interfaces:
-
Consumes:
PagedListModel<Element>(client:argv:emptyMessage:pageSize:),GitbayClient.read(_:as:),GitbayClient.run(_:),LoadState. -
Produces:
AdminUser: Decodable, Identifiable—username: String,state: String,admin: Bool,createdAt: Date,lastSeen: Date?;roleAction: AdminAction,canChangeRole: Bool,accessAction: AdminAction.AdminAction: String—.promote,.demote,.disable,.enable;needsTypedName: Bool,title: String,argv(_ username: String) -> [String],message(for username: String) -> String.AdminUserFilter: String, CaseIterable, Identifiable—.all,.active,.pending,.disabled,.admin;label: String,flags() -> [String].enum AdminCheck—static func isAdmin(_ client: GitbayClient) async -> Bool.AdminOverviewViewModel(client:)—state: LoadState<AdminOverviewViewModel.Overview>,load() async;Overviewhascommit: String?,sections: [QueueSection].AdminUsersViewModel(client:)—list: PagedListModel<AdminUser>,filter: AdminUserFilter,actionError: String?,working: Bool,load() async,perform(_ action: AdminAction, on username: String) async.
-
Step 1: Write the failing tests
Append to gitbayTests/AdminTests.swift:
private func user(_ name: String, state: String, admin: Bool = false) throws -> AdminUser {
let json = """
{"username":"\(name)","state":"\(state)","admin":\(admin),\
"created_at":"2026-08-28T21:55:00.752Z"}
"""
return try GitbayClient.decoder().decode(AdminUser.self, from: Data(json.utf8))
}
struct AdminUserActionTests {
@Test func anAdminIsDemotedAndCanBeDisabled() throws {
let u = try user("cmc", state: "active", admin: true)
#expect(u.roleAction == .demote)
#expect(u.canChangeRole)
#expect(u.accessAction == .disable)
}
@Test func anActiveAccountCanBePromoted() throws {
let u = try user("blotter-ci", state: "active")
#expect(u.roleAction == .promote)
#expect(u.canChangeRole)
}
/// The web draws Promote on every non-admin row but disables it for
/// an account that is not active.
@Test func onlyAnActiveAccountCanBePromoted() throws {
#expect(try user("new", state: "pending").canChangeRole == false)
#expect(try user("gone", state: "disabled").canChangeRole == false)
}
@Test func aDisabledAccountIsEnabledAndAnyOtherDisabled() throws {
#expect(try user("gone", state: "disabled").accessAction == .enable)
#expect(try user("new", state: "pending").accessAction == .disable)
}
@Test func demoteAndDisableAskForTheTypedName() {
#expect(AdminAction.demote.needsTypedName)
#expect(AdminAction.disable.needsTypedName)
#expect(!AdminAction.promote.needsTypedName)
#expect(!AdminAction.enable.needsTypedName)
}
@Test func eachActionIsItsAdminUserCommand() {
#expect(AdminAction.promote.argv("x") == ["admin", "user", "promote", "x"])
#expect(AdminAction.demote.argv("x") == ["admin", "user", "demote", "x"])
#expect(AdminAction.disable.argv("x") == ["admin", "user", "disable", "x"])
#expect(AdminAction.enable.argv("x") == ["admin", "user", "enable", "x"])
}
@Test func theFiltersAreTheWebsFive() {
#expect(AdminUserFilter.allCases.map(\.label) == ["All", "Active", "Pending", "Disabled", "Admins"])
#expect(AdminUserFilter.all.flags().isEmpty)
#expect(AdminUserFilter.admin.flags() == ["--state", "admin"])
}
}
@MainActor
struct AdminCheckTests {
@Test func whoamisAdminFlagDecides() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json:
#"{"protocol_version":1,"data":{"username":"cmc","admin":true,"key_scope":"full"}}"#))
#expect(await AdminCheck.isAdmin(client))
#expect(stub.seen.first?.url.query() == "argv=whoami")
}
@Test func aNonAdminIsNot() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json:
#"{"protocol_version":1,"data":{"username":"ios-smoke","admin":false}}"#))
#expect(await AdminCheck.isAdmin(client) == false)
}
/// The menu just leaves the entry out when the read fails.
@Test func aFailedReadIsNotAnAdmin() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#))
#expect(await AdminCheck.isAdmin(client) == false)
}
}
@MainActor
struct AdminOverviewViewModelTests {
@Test func readsDashboardIntoSections() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json: adminDashboardJSON))
let model = AdminOverviewViewModel(client: client)
await model.load()
let overview = try #require(model.state.value)
#expect(overview.commit == "db7503f06f11")
#expect(overview.sections.count == 6)
#expect(stub.seen.first?.url.query() == "argv=dashboard")
}
@Test func aDashboardWithoutQueuesIsAnEmptyState() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"unread":0},"exit_code":0}"#))
let model = AdminOverviewViewModel(client: client)
await model.load()
guard case .empty(let message) = model.state else {
Issue.record("expected .empty, got \(model.state)")
return
}
#expect(message == "Only instance admins can see the worker queues.")
}
}
private let userPageJSON = """
{"protocol_version":1,"data":{"items":[\
{"username":"apple-review","state":"active","admin":false,"created_at":"2026-08-28T21:55:00.752Z",\
"last_seen":"2026-09-21T07:05:26.201Z"},\
{"username":"bhargavkk","state":"pending","admin":false,"created_at":"2026-09-11T01:50:04.063Z"}],\
"next":"YWRtaW4tdXNlcjpiaGFyZ2F2a2s"},"exit_code":0}
"""
@MainActor
struct AdminUsersViewModelTests {
@Test func listsEveryAccountByDefault() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json: userPageJSON))
let model = AdminUsersViewModel(client: client)
await model.load()
let users = try #require(model.list.state.value)
#expect(users.map(\.username) == ["apple-review", "bhargavkk"])
#expect(users[0].lastSeen != nil)
#expect(users[1].lastSeen == nil)
#expect(model.list.hasMore)
#expect(stub.seen.first?.url.query() == "argv=admin&argv=user&argv=list&argv=--limit&argv=50")
}
@Test func aFilterReloadsWithItsState() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json: userPageJSON))
let model = AdminUsersViewModel(client: client)
await model.load()
stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#))
model.filter = .disabled
await until {
if case .empty = model.list.state { return true }
return false
}
#expect(stub.seen.last?.url.query()
== "argv=admin&argv=user&argv=list&argv=--state&argv=disabled&argv=--limit&argv=50")
guard case .empty(let message) = model.list.state else { return }
#expect(message == "No account matches")
}
@Test func anActionSendsItsCommandAndReloads() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#, match: "cmd"))
stub.enqueue(.init(status: 200, json: userPageJSON, match: "argv=list"))
let model = AdminUsersViewModel(client: client)
await model.perform(.disable, on: "bhargavkk")
let write = try #require(stub.seen.first { $0.method == "POST" })
let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any])
#expect(body["argv"] as? [String] == ["admin", "user", "disable", "bhargavkk"])
#expect(model.actionError == nil)
#expect(model.list.state.value?.count == 2)
}
@Test func aRefusalIsShownVerbatim() async throws {
let (client, stub) = try makeClient()
stub.enqueue(.init(status: 200, json:
#"{"protocol_version":1,"error":"cmc is the last admin","exit_code":4}"#, match: "cmd"))
let model = AdminUsersViewModel(client: client)
await model.perform(.demote, on: "cmc")
#expect(model.actionError == "cmc is the last admin")
}
}
- Step 2: Run the unit target to verify it fails
Expected: build FAILS with cannot find type 'AdminUser' in scope, cannot find 'AdminCheck' in scope, cannot find 'AdminOverviewViewModel' in scope, cannot find 'AdminUsersViewModel' in scope.
- Step 3: Append the account types to the models
Append to gitbay/Admin/AdminModels.swift:
/// One row of `admin user list`.
nonisolated struct AdminUser: Decodable, Sendable, Hashable, Identifiable {
let username: String
/// active, pending or disabled.
let state: String
let admin: Bool
let createdAt: Date
/// Absent for an account that has never signed in.
let lastSeen: Date?
enum CodingKeys: String, CodingKey {
case username, state, admin
case createdAt = "created_at"
case lastSeen = "last_seen"
}
var id: String { username }
/// The role button the web draws: Demote for an admin, Promote for
/// anyone else.
var roleAction: AdminAction { admin ? .demote : .promote }
/// Only an active account can be promoted; the web draws the button
/// disabled for the rest.
var canChangeRole: Bool { admin || state == "active" }
var accessAction: AdminAction { state == "disabled" ? .enable : .disable }
}
nonisolated enum AdminAction: String, Sendable, Hashable {
case promote, demote, disable, enable
/// Demote and disable take something away from the account, and the
/// web asks for the username typed before either.
var needsTypedName: Bool { self == .demote || self == .disable }
var title: String { rawValue.capitalized }
func argv(_ username: String) -> [String] { ["admin", "user", rawValue, username] }
func message(for username: String) -> String {
switch self {
case .promote: "\(username) becomes an instance admin."
case .demote: "\(username) stops being an instance admin. Type the username to confirm."
case .disable: "SSH, web sessions and API tokens for \(username) are refused until the account is enabled. Type the username to confirm."
case .enable: "\(username) can sign in again."
}
}
}
/// The web's five filters over `admin user list`.
nonisolated enum AdminUserFilter: String, CaseIterable, Identifiable, Sendable {
case all, active, pending, disabled, admin
var id: String { rawValue }
var label: String { self == .admin ? "Admins" : rawValue.capitalized }
func flags() -> [String] { self == .all ? [] : ["--state", rawValue] }
}
- Step 4: Write the view models
Create gitbay/Admin/AdminViewModels.swift:
import Foundation
import Observation
/// Whether the signed-in account is an instance admin, from `whoami`. A
/// failed read counts as not: the account menu then leaves Admin out.
enum AdminCheck {
nonisolated private struct Who: Decodable, Sendable {
let admin: Bool?
}
static func isAdmin(_ client: GitbayClient) async -> Bool {
((try? await client.read(["whoami"], as: Who.self))?.admin) ?? false
}
}
/// The admin screen: `dashboard`'s server build and worker queues, the
/// read the web's /admin page makes.
@Observable
@MainActor
final class AdminOverviewViewModel {
nonisolated struct Overview: Sendable, Hashable {
let commit: String?
let sections: [QueueSection]
}
private(set) var state: LoadState<Overview> = .loading
private let client: GitbayClient
init(client: GitbayClient) {
self.client = client
}
func load() async {
do {
let dashboard = try await client.read(["dashboard"], as: AdminDashboard.self)
guard let queues = dashboard.queues else {
state = .empty("Only instance admins can see the worker queues.")
return
}
state = .loaded(Overview(commit: dashboard.server?.commit, sections: queues.sections()))
} catch {
state = .from(error)
}
}
}
/// `admin user list`, narrowed by the web's five filters, and the four
/// writes /admin/users makes per row.
@Observable
@MainActor
final class AdminUsersViewModel {
let list: PagedListModel<AdminUser>
var filter = AdminUserFilter.all {
didSet {
guard filter != oldValue else { return }
list.argv = ["admin", "user", "list"] + filter.flags()
reloadTask?.cancel()
reloadTask = Task { await list.reload() }
}
}
private var reloadTask: Task<Void, Never>?
private(set) var actionError: String?
private(set) var working = false
private let client: GitbayClient
init(client: GitbayClient) {
self.client = client
list = PagedListModel(
client: client,
argv: ["admin", "user", "list"],
emptyMessage: "No account matches"
)
}
func load() async {
reloadTask?.cancel()
await list.reload()
}
func perform(_ action: AdminAction, on username: String) async {
working = true
actionError = nil
defer { working = false }
do {
try await client.run(action.argv(username))
await list.reload()
} catch let error as GitbayError {
actionError = error.userFacingMessage
} catch {
actionError = GitbayError.transport(error).userFacingMessage
}
}
}
- Step 5: Run the unit target to verify it passes
Expected: Passed <BASE + 22> 0 — Task 1's 6, plus 7 action, 3 check, 2 overview and 4 accounts tests.
If aFailedReadIsNotAnAdmin retries: the client honours Retry-After only on 429, so a 500 fails once.
- Step 6: Commit
cd /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
admin user list with the web's five filters; promote, demote, disable
and enable with the web's rules for which a row offers; whoami's admin
flag for the account menu."
Task 3: The screens and the menu entry
Files:
- Create:
gitbay/Views/Admin/AdminView.swift - Create:
gitbay/Views/Admin/AdminUsersView.swift - Modify:
gitbay/Views/Repos/RepoRoute.swift:26(two cases aftercase notifications) - Modify:
gitbay/ContentView.swift:204-205(two destinations after.notifications) - Modify:
gitbay/Views/Repos/RepoListView.swift:250-291(AccountMenu)
Interfaces:
-
Consumes: everything Task 2 produces;
LoadStateOverlay(state:),PageFooter(list:),GBChip,GBNotice,SessionStore.client,SessionStore.current. -
Produces:
RepoRoute.admin,RepoRoute.adminUsers; accessibility identifiersaccount-menu-admin,admin-accounts,admin-user-filter,admin-user-menu-<username>. -
Step 1: Add the routes
In gitbay/Views/Repos/RepoRoute.swift, after case notifications add:
case admin
case adminUsers
In gitbay/ContentView.swift, in destination(_ route: RepoRoute, client:), after
case .notifications:
NotificationsView(client: client)
add
case .admin:
AdminView(client: client)
case .adminUsers:
AdminUsersView(client: client)
- Step 2: The admin screen
Create gitbay/Views/Admin/AdminView.swift:
import SwiftUI
/// The web's /admin page: the server build, a way to the accounts, and
/// every worker queue's backlog in the web's order and words.
struct AdminView: View {
@State private var model: AdminOverviewViewModel
init(client: GitbayClient) {
_model = State(initialValue: AdminOverviewViewModel(client: client))
}
var body: some View {
List {
if let overview = model.state.value {
Section {
NavigationLink(value: RepoRoute.adminUsers) {
Label("Accounts", systemImage: "person.2")
}
.accessibilityIdentifier("admin-accounts")
} footer: {
if let commit = overview.commit {
Text("Server build \(commit).")
}
}
ForEach(overview.sections) { section in
Section {
if section.rows.isEmpty {
Text(section.empty)
.font(.gbSans(.subheadline))
.foregroundStyle(.secondary)
} else {
ForEach(section.rows) { line in
VStack(alignment: .leading, spacing: 2) {
Text(line.title)
.font(.gbSans(.subheadline).weight(.medium))
Text(line.detail)
.font(.gbSans(.caption))
.foregroundStyle(.secondary)
}
}
}
} header: {
Text("\(section.heading) \(section.count)")
} footer: {
if let summary = section.summary {
Text(summary)
}
}
}
}
}
.overlay { LoadStateOverlay(state: model.state) }
.navigationTitle("Admin")
.navigationBarTitleDisplayMode(.inline)
.task { await model.load() }
.refreshable { await model.load() }
}
}
- Step 3: The accounts screen
Create gitbay/Views/Admin/AdminUsersView.swift:
import SwiftUI
/// The web's /admin/users: every account, filtered by state, with the
/// role and access action each row allows. Demote and disable ask for
/// the username typed, as the web does.
struct AdminUsersView: View {
@State private var model: AdminUsersViewModel
@State private var pending: Pending?
@State private var typed = ""
nonisolated struct Pending: Hashable, Sendable {
let action: AdminAction
let username: String
}
init(client: GitbayClient) {
_model = State(initialValue: AdminUsersViewModel(client: client))
}
var body: some View {
List {
Picker("State", selection: Bindable(model).filter) {
ForEach(AdminUserFilter.allCases) { filter in
Text(filter.label).tag(filter)
}
}
.pickerStyle(.segmented)
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets())
.accessibilityIdentifier("admin-user-filter")
if let error = model.actionError {
Section {
GBNotice(error, .gbWarn)
}
}
ForEach(model.list.state.value ?? []) { user in
row(user)
}
PageFooter(list: model.list)
}
.overlay { LoadStateOverlay(state: model.list.state) }
.navigationTitle("Accounts")
.navigationBarTitleDisplayMode(.inline)
.task { await model.load() }
.refreshable { await model.load() }
.alert(
pending.map { "\($0.action.title) \($0.username)?" } ?? "",
isPresented: Binding(get: { pending != nil }, set: { if !$0 { pending = nil } }),
presenting: pending
) { pending in
if pending.action.needsTypedName {
TextField(pending.username, text: $typed)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
Button(pending.action.title, role: pending.action.needsTypedName ? .destructive : nil) {
Task { await model.perform(pending.action, on: pending.username) }
}
.disabled(pending.action.needsTypedName && typed != pending.username)
Button("Cancel", role: .cancel) {}
} message: { pending in
Text(pending.action.message(for: pending.username))
}
}
private func row(_ user: AdminUser) -> some View {
HStack {
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(user.username)
.font(.gbSans(.subheadline).weight(.medium))
if user.admin {
GBChip("admin", .gbAccent)
}
GBChip(user.state, stateColor(user.state))
}
Text(seen(user))
.font(.gbSans(.caption))
.foregroundStyle(.secondary)
}
Spacer()
Menu {
Button(user.roleAction.title) { ask(user.roleAction, user) }
.disabled(!user.canChangeRole || model.working)
Button(user.accessAction.title,
role: user.accessAction == .disable ? .destructive : nil) {
ask(user.accessAction, user)
}
.disabled(model.working)
} label: {
Image(systemName: "ellipsis.circle")
}
.accessibilityIdentifier("admin-user-menu-\(user.username)")
}
}
private func ask(_ action: AdminAction, _ user: AdminUser) {
typed = ""
pending = Pending(action: action, username: user.username)
}
/// The web's chip classes: open for active, closed for disabled,
/// neutral otherwise.
private func stateColor(_ state: String) -> Color {
switch state {
case "active": .gbOK
case "disabled": .gbBad
default: .secondary
}
}
private func seen(_ user: AdminUser) -> String {
let created = "joined \(user.createdAt.formatted(date: .abbreviated, time: .omitted))"
let last = user.lastSeen.map { "last seen \($0.formatted(.relative(presentation: .named)))" }
?? "never seen"
return "\(created) · \(last)"
}
}
- Step 4: Offer it from the account menu, to admins only
In gitbay/Views/Repos/RepoListView.swift, in AccountMenu:
Add after @Environment(SessionStore.self) private var session:
/// Read from `whoami` when the menu appears and on an account switch.
/// The menu has no model; this is the one read that reaches it.
@State private var isAdmin = false
Inside the Section(current.label) { … }, after the Snippets NavigationLink, add:
if isAdmin {
NavigationLink(value: RepoRoute.admin) {
Label("Admin", systemImage: "gearshape.2")
}
.accessibilityIdentifier("account-menu-admin")
}
Replace the menu's label
} label: {
Image(systemName: "person.crop.circle")
}
with
} label: {
Image(systemName: "person.crop.circle")
// ToolbarContent takes no .task; the label is a view.
.task(id: session.current?.id) {
isAdmin = false
if let client = session.client {
isAdmin = await AdminCheck.isAdmin(client)
}
}
}
- Step 5: Build and run the unit target
Expected: same count as after Task 2, 0 failed.
- Step 6: Commit
cd /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
The account menu shows Admin when whoami says the account is an
instance admin."
Task 4: Live and manual checks, then the MR
Files:
- Modify:
gitbayUITests/LiveSmokeUITests.swift(testProfileAndNavigationFlows, the account menu step)
Interfaces:
-
Consumes:
account-menu,account-menu-admin(Task 3). -
Step 1: Assert a non-admin is not offered Admin
In testProfileAndNavigationFlows(), replace
XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5),
"keys not in the account menu")
app.tap() // dismiss the menu; the profile is already on screen
with
XCTAssertTrue(app.buttons["Keys & Email"].firstMatch.waitForExistence(timeout: 5),
"keys not in the account menu")
// ios-smoke is not an instance admin.
XCTAssertFalse(app.descendants(matching: .any)
.matching(identifier: "account-menu-admin").firstMatch.exists,
"a non-admin is offered Admin")
app.tap() // dismiss the menu; the profile is already on screen
- Step 2: Build the UI test target and run the test (needs a token from the user)
cd /Users/cmc/git/krz/gitbay-ios && TEST_RUNNER_GITBAY_UITEST_LIVE=1 TEST_RUNNER_GITBAY_UITEST_TOKEN="$TOKEN" \
xcodebuild -project gitbay.xcodeproj -scheme gitbay -destination 'id=0A03DF15-0F45-4726-8080-DBFE919F46B6' \
-only-testing:gitbayUITests/LiveSmokeUITests/testProfileAndNavigationFlows \
-resultBundlePath /tmp/gb-live.xcresult test 2>&1 | grep -E "error:|passed|failed|TEST (SUCCEEDED|FAILED)" | tail -10
Expected: passed. The token is for ios-smoke, minted by the user as in earlier plans.
- Step 3: Read-only look as an admin (the user's call)
Ask 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.
- Step 4: Design greps
cd /Users/cmc/git/krz/gitbay-ios && grep -rnE '\.(green|red|blue|orange|yellow|purple)\b' gitbay/ --include=*.swift | grep -v gb; \
grep -rn '\.font(\.' gitbay/Views/Admin | grep -vE 'gbSans|gbMono'; \
grep -rn 'cornerRadius:' gitbay/Views/Admin | grep -v 'cornerRadius: 2)'
Expected: no output.
- Step 5: Both runtimes and a Release device build
Run the unit target on iPhone 17 Pro (26.5), then:
cd /Users/cmc/git/krz/gitbay-ios && xcodebuild -project gitbay.xcodeproj -scheme gitbay \
-destination 'id=9F8E5D84-0A15-4C64-8A45-994667A73817' -only-testing:gitbayTests test 2>&1 | grep -E "TEST (SUCCEEDED|FAILED)"
xcodebuild build -scheme gitbay -configuration Release -destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO 2>&1 \
| grep -E "error:|While running pass|BUILD (SUCCEEDED|FAILED)" | tail -5
Expected: ** TEST SUCCEEDED ** and ** BUILD SUCCEEDED **. On iOS 18, formatted(.relative(presentation: .named)) and the switch expressions in AdminAction are both available.
- Step 6: Commit, push, open the MR
cd /Users/cmc/git/krz/gitbay-ios && git add gitbayUITests/LiveSmokeUITests.swift && git commit -m "live suite: a non-admin is not offered Admin" && \
git push -u origin admin && gitbay mr create --source admin --target main \
--title "Admin: worker queues and accounts, as the web has them" --file - <<'EOF'
The web's /admin and /admin/users, for an instance admin.
- Admin: the server build and dashboard's six worker queues, with the
web's headings, counts lines, rows and empty sentences.
- Accounts: admin user list with the web's five filters, paged. Each
row offers the web's role and access actions; demote and disable ask
for the username typed.
- The account menu shows Admin when whoami says the account is an
admin.
No account show, invite, runners, repository administration, audit
log or statistics: the web has none of them either.
Spec: docs/superpowers/specs/2026-09-22-parity-followup-design.md
EOF
Task 5: Bump to 1.6.0 (15)
Starts only after the user has merged the MR from Task 4.
Files:
-
Modify:
gitbay.xcodeproj/project.pbxproj(the four version lines) -
Step 1: Branch, bump, verify, open the MR
cd /Users/cmc/git/krz/gitbay-ios && git switch main && git pull --ff-only && git switch -c bump-1.6.0 && \
sed -i '' -e 's/MARKETING_VERSION = 1.5.0;/MARKETING_VERSION = 1.6.0;/' \
-e 's/CURRENT_PROJECT_VERSION = 14;/CURRENT_PROJECT_VERSION = 15;/' gitbay.xcodeproj/project.pbxproj && \
git diff --stat && plutil -lint gitbay.xcodeproj/project.pbxproj
Expected: 1 file changed, 4 insertions(+), 4 deletions(-) and OK. Run the unit target, then:
cd /Users/cmc/git/krz/gitbay-ios && git add gitbay.xcodeproj/project.pbxproj && git commit -m "Bump to 1.6.0 (15)" && \
git 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."
Tagging v1.6.0, archiving and uploading are the user's.
- Step 2: Flip the Parity rows upstream
Once 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".