docs/superpowers/plans/2026-09-06-mr06-notifications.md
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
MainActorisolation. Wire models arenonisolated structs. - Swift Testing only — never XCTest.
gitbayTestsis hermetic and offline; network goes throughStubProtocol.- New files under
gitbay/andgitbayTests/need noproject.pbxprojedit. - The label model is
IssueLabel, neverLabel. - Never mention Claude, LLMs or AI in commits, comments, or the merge request. No
Co-Authored-Bytrailer. - 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
IssueListViewModelfor thePagedListModelshape andRepoSettingsViewModel.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
unreadinDashboardData - Step 2:
NotificationsView - Step 3: Route case +
ContentViewdestination - 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 inboxgoes toyesfor 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.