docs/superpowers/plans/2026-09-06-mr06-notifications.md

36835f99e6d463f3e18c5c3fc3562bb92513f52b
gitbay-ios/docs/superpowers/plans/2026-09-06-mr06-notifications.md rendered · source · history · blame · raw

345 lines · 15964 bytes

7 symbols in this file

MR 6: Notification inbox 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: Close the notification inbox parity row — read the inbox, mark things read, and carry the unread count on the Dashboard.

Architecture: notifications list is paginated, so it goes through the existing PagedListModel like every other list. notifications read marks one or all. The unread badge costs no extra request: dashboard already emits unread for exactly this purpose and iOS simply does not decode it yet.

Spec: docs/superpowers/specs/2026-09-06-ios-parity-design.md

Global Constraints

  • Swift 6 language mode, default MainActor isolation. Wire models are nonisolated structs.
  • Swift Testing only — never XCTest.
  • gitbayTests is hermetic and offline; network goes through StubProtocol.
  • New files under gitbay/ and gitbayTests/ need no project.pbxproj edit.
  • The label model is IssueLabel, never Label.
  • Never mention Claude, LLMs or AI in commits, comments, or the merge request. No Co-Authored-By trailer.
  • Never commit to main.

The commands, verbatim from the registry:

notifications list [--all] [--limit <n>] [--cursor <c>]
notifications read <id>... | --all

notifications list is paginated — unlike search in MR 3 — so it uses PagedListModel, which appends --limit/--cursor itself. The default listing is unread only; --all includes read ones.

The item shape (internal/control/notifications.go:117-125):

{"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",
 "summary":"build 966 failed: test on runner-podman-exec-144",
 "path":"krz/gitbay/builds/966","created_at":"2026-09-06T22:14:46.912Z",
 "read_at":"2026-09-06T22:20:24.040Z"}

Everything is always present except read_at, which is omitempty — absent means unread, and that is the common case for the default listing.

Counting tests reliably:

grep -cE "^Test case '[^']*' passed" /tmp/out.txt
grep -cE "^Test case '[^']*' failed" /tmp/out.txt
grep -cE "^Test case '[^']*' skipped" /tmp/out.txt
grep -rhoE "@Test(\([^)]*\))? func" gitbayTests/*.swift | wc -l

declared - skipped == passed. One test is always skipped (LiveInstanceTests).

Baseline: 285 declared, 284 passed, 1 skipped, 0 failed.


File Structure

File Responsibility
gitbay/Account/NotificationsViewModel.swift (create) Notification wire model, routing, and the paged view model
gitbayTests/NotificationTests.swift (create) Every test in this plan
gitbay/Views/Account/NotificationsView.swift (create) The inbox screen
gitbay/Dashboard/DashboardModels.swift (modify) Decode unread
gitbay/Views/Dashboard/DashboardView.swift (modify) Badge + entry point
gitbay/Views/Repos/RepoRoute.swift (modify) notifications case
gitbay/ContentView.swift (modify) Route to NotificationsView

Task 1: The model, its routing, and the view model

Files: Create gitbay/Account/NotificationsViewModel.swift; test in gitbayTests/NotificationTests.swift

Interfaces produced:

  • nonisolated struct Notification: Decodable, Sendable, Hashable, Identifiable — id: Int64, repo: String, kind: String, actor: String, summary: String, path: String, createdAt: Date, readAt: Date?; var isUnread: Bool; var destination: NotificationDestination?
  • nonisolated enum NotificationDestination: Hashable, Sendable — .issue(repo:number:), .mr(repo:number:), .build(repo:number:)
  • @Observable @MainActor final class NotificationsViewModel — list: PagedListModel<Notification>, showAll: Bool, state, actionError, working, load(), markRead(_:), markAllRead()

The routing table, derived from the server (internal/control/issue.go:153,279,349, mr.go:322,702,778,832,1173,1198,1382, diffcomment.go:111, build.go:522, deps/worker.go:249) and confirmed against live data. path is a web-style route, <owner>/<repo>/<section>/<n>:

kind path destination
issue <owner>/<repo>/issues/<n> .issue
mr <owner>/<repo>/mrs/<n> .mr
build <owner>/<repo>/builds/<n> .build

The merge request segment is mrs, not merge_requests. Matching the wrong spelling produces a silently untappable row — no error, no crash, just a row that does nothing.

Parse defensively: split on /, require exactly four components, require the last to be an integer, and return nil for anything else. A nil destination renders an untappable row rather than crashing — the same rule the search results screen follows for a missing number.

  • Step 1: Write the failing tests

Create gitbayTests/NotificationTests.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)
}

private func argvFrom(_ url: URL) -> [String] {
    URLComponents(url: url, resolvingAgainstBaseURL: false)?
        .queryItems?.filter { $0.name == "argv" }.compactMap(\.value) ?? []
}

private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
    let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
    return try #require(body["argv"] as? [String])
}

private func decode(_ json: String) throws -> Notification {
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .iso8601
    return try decoder.decode(Notification.self, from: Data(json.utf8))
}

private let unreadBuild = """
    {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\
    "summary":"build 966 failed","path":"krz/gitbay/builds/966",\
    "created_at":"2026-09-06T22:14:46Z"}
    """

struct NotificationDecodingTests {

    /// read_at is omitempty; absent means unread, and that is the common
    /// case for the default listing.
    @Test func anAbsentReadAtMeansUnread() throws {
        let n = try decode(unreadBuild)
        #expect(n.readAt == nil)
        #expect(n.isUnread)
    }

    @Test func aPresentReadAtMeansRead() throws {
        let n = try decode("""
            {"id":15,"repo":"krz/gitbay","kind":"build","actor":"ci",\
            "summary":"x","path":"krz/gitbay/builds/966",\
            "created_at":"2026-09-06T22:14:46Z","read_at":"2026-09-06T22:20:24Z"}
            """)
        #expect(n.readAt != nil)
        #expect(n.isUnread == false)
    }

    @Test func everyKindRoutesToItsScreen() throws {
        let issue = try decode("""
            {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\
            "path":"krz/gitbay/issues/168","created_at":"2026-09-06T22:14:46Z"}
            """)
        #expect(issue.destination == .issue(repo: "krz/gitbay", number: 168))

        // The segment is "mrs", not "merge_requests".
        let mr = try decode("""
            {"id":2,"repo":"krz/gitbay","kind":"mr","actor":"cmc","summary":"x",\
            "path":"krz/gitbay/mrs/282","created_at":"2026-09-06T22:14:46Z"}
            """)
        #expect(mr.destination == .mr(repo: "krz/gitbay", number: 282))

        let build = try decode(unreadBuild)
        #expect(build.destination == .build(repo: "krz/gitbay", number: 966))
    }

    /// A path the app cannot route must yield nil, not a crash and not a
    /// wrong destination.
    @Test func anUnroutablePathIsNil() throws {
        for path in ["krz/gitbay/wiki/Home", "krz/gitbay/issues", "krz/gitbay/issues/notanumber",
                     "too/short", "krz/gitbay/issues/1/extra", ""] {
            let n = try decode("""
                {"id":1,"repo":"krz/gitbay","kind":"issue","actor":"cmc","summary":"x",\
                "path":"\(path)","created_at":"2026-09-06T22:14:46Z"}
                """)
            #expect(n.destination == nil, "\(path) should not route")
        }
    }

    /// The repo comes from the path, not the `repo` field, so a
    /// mismatch cannot send the user to the wrong repository.
    @Test func theDestinationRepoComesFromThePath() throws {
        let n = try decode("""
            {"id":1,"repo":"other/repo","kind":"issue","actor":"cmc","summary":"x",\
            "path":"krz/gitbay/issues/7","created_at":"2026-09-06T22:14:46Z"}
            """)
        #expect(n.destination == .issue(repo: "krz/gitbay", number: 7))
    }
}

@MainActor
struct NotificationsViewModelTests {

    private let page = """
        {"protocol_version":1,"data":{"items":[\(unreadBuild)]},"exit_code":0}
        """

    @Test func theDefaultListingIsUnreadOnly() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: page))
        let model = NotificationsViewModel(client: client)
        await model.load()

        let argv = argvFrom(try #require(stub.seen.last).url)
        #expect(argv.prefix(2) == ["notifications", "list"])
        #expect(argv.contains("--all") == false)
        #expect(model.state.value?.count == 1)
    }

    @Test func showingAllAddsTheFlag() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: page))
        let model = NotificationsViewModel(client: client)
        await model.load()

        stub.enqueue(.init(status: 200, json: page))
        model.showAll = true
        try await Task.sleep(for: .milliseconds(150))

        #expect(argvFrom(try #require(stub.seen.last).url).contains("--all"))
    }

    @Test func markingOneReadSendsItsId() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: page))
        let model = NotificationsViewModel(client: client)
        await model.load()

        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"exit_code":0}
            """))
        stub.enqueue(.init(status: 200, json: page))
        await model.markRead(15)

        let write = try #require(stub.seen.first { $0.method == "POST" })
        #expect(try argvOf(write) == ["notifications", "read", "15"])
    }

    @Test func markingAllReadUsesTheFlagNotAList() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: page))
        let model = NotificationsViewModel(client: client)
        await model.load()

        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"exit_code":0}
            """))
        stub.enqueue(.init(status: 200, json: page))
        await model.markAllRead()

        let write = try #require(stub.seen.first { $0.method == "POST" })
        #expect(try argvOf(write) == ["notifications", "read", "--all"])
    }

    @Test func anEmptyInboxIsAnEmptyStateNotAFailure() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"data":{"items":[]},"exit_code":0}
            """))
        let model = NotificationsViewModel(client: client)
        await model.load()

        guard case .empty = model.state else {
            Testing.Issue.record("expected empty, got \(model.state)")
            return
        }
    }

    @Test func aRefusalSurfacesAndDoesNotReload() async throws {
        let (client, stub) = try makeClient()
        stub.enqueue(.init(status: 200, json: page))
        let model = NotificationsViewModel(client: client)
        await model.load()
        let before = stub.seen.count

        stub.enqueue(.init(status: 200, json: """
            {"protocol_version":1,"error":"denied","exit_code":4}
            """))
        await model.markRead(15)

        #expect(model.actionError?.isEmpty == false)
        #expect(model.working == false)
        // One POST, no reload after a refusal.
        #expect(stub.seen.count == before + 1)
    }
}
  • Step 2: Run to verify failure.
  • Step 3: Implement. Follow IssueListViewModel for the PagedListModel shape and RepoSettingsViewModel.perform(argv:) for the write half (copy it; do not extract a shared helper — that duplication is a standing decision in this codebase).
  • Step 4: Run the tests.
  • Step 5: Commit — git commit -m "Notification inbox model and view model"

Task 2: The screen, the badge and the route

Files: Create gitbay/Views/Account/NotificationsView.swift; modify DashboardModels.swift, DashboardView.swift, RepoRoute.swift, ContentView.swift

The badge is free. dashboard already emits unread: int — the server comments it as existing "so a client showing one does not need a second read to fill it" (internal/control/dashboard.go:78). iOS does not decode it yet.

DashboardData has a hand-written init(from:) (DashboardModels.swift:27-37) that decodes each field with decodeIfPresent ?? []. Adding unread means editing that initialiser — check field by field that you drop nothing, the same care MR 4's MRDetail initialiser needed. Decode it as decodeIfPresent(Int.self, forKey: .unread) ?? 0, since a server predating the field would omit it.

The screen: a List over the paged notifications with PageFooter, a toggle for unread-only versus all, a "Mark all read" action, and per-row swipe-to-mark-read. An unread row should read as unread — the web uses the count, so weight or a dot is enough; do not invent a colour that means nothing elsewhere in the app.

Each row shows actor, summary and the relative createdAt, and navigates to destination when it is non-nil. A row whose destination is nil renders plainly and is not tappable.

Entry point: a toolbar bell on the Dashboard carrying the unread count, matching where the web puts it (its rail). DashboardView already has a .toolbar with a .topBarTrailing item — add beside it.

Add RepoRoute.notifications and its ContentView destination.

  • Step 1: Decode unread in DashboardData
  • Step 2: NotificationsView
  • Step 3: Route case + ContentView destination
  • Step 4: Dashboard bell with the count
  • Step 5: Build and run the full suite. No drop.
  • Step 6: Commit — git commit -m "Notification inbox screen and dashboard badge"

Task 3: Flip the parity row and open the merge request

  • Step 1: In the Accounts table, notification inbox goes to yes for iOS. Touch no other row.

Land it via a worktree off origin/main in krz/gitbay, merged --strategy ff. That repo requires signed commits, so squash and rebase merges are refused, and its working tree usually holds unrelated work — never switch its branch.

  • Step 2: Run the full suite; record the real number.
  • Step 3: Open the merge request.

Notes for whoever executes this

notifications list IS paginated. Use PagedListModel. This differs from search in MR 3, which is not.

The merge request path segment is mrs. Not merge_requests. The wrong spelling gives a silently untappable row.

Absent read_at means unread, and it is the common case. Do not require the key.

Take the repo from the path, not the repo field — a test pins this, so a row can never send the user to a different repository than the one its link names.

The badge needs no extra request. If you find yourself adding a notifications list call to fill the Dashboard count, stop: dashboard already carries it.