docs/superpowers/plans/2026-09-22-1.6.0-admin.md

v1.10.0
gitbay-ios/docs/superpowers/plans/2026-09-22-1.6.0-admin.md rendered · source · history · blame · raw

1187 lines · 49470 bytes

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-By trailer. This is absolute.

  • Commits are signed. commit.gpgsign is on; do not pass --no-gpg-sign.

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

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

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

  • 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":"…"}}.

  • Design tokens only. .gbSans/.gbMono fonts, GBChip for chips, GBNotice for errors, gb palette 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: Decodable with queues: AdminQueues?, server: AdminDashboard.Server? (commit: String?).
    • AdminQueues: Decodable with webhooks, mail, push, mirrors, builds, deps: AdminQueues.Queue, and func 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 (append AdminUser, 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; Overview has commit: 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 after case 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 identifiers account-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".