a native ios client for gitbay

client ios swift

https://gitbay.org

Commit d48039b69f

d48039b69fcc7064eebb7903827d18fa87b01a69

parent: 43f4f01b01

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-27T05:27:23Z

dashboard: what needs me, aggregated client-side

Open MRs, issues assigned to me, and recent builds across every
reachable repo, in a tab alongside the repo list. No cross-repo
aggregate exists yet, so this fans out mr list + issue list per repo,
width 4, skipping archived repos and only asking for builds where
something is open. A 429 stops the scan and the screen says how far it
got instead of pretending it finished.

First load against the real account measured the gap this creates: 66
repos cost ~130 reads and hit the 120/min bucket at repo 64. That
number is the case for the aggregate command; issue to follow on
krz/gitbay.

The token field submits from the keyboard (paste, Go) as well as the
button.

Ref #11
gitbay/ContentView.swift +45 −15
@@ -5,28 +5,52 @@ struct ContentView: View {
55 @Environment(SessionStore.self) private var session
66
77 var body: some View {
8 NavigationStack {
9 if let client = session.client {
10 RepoListView(client: client)
11 .id(session.current?.id) // fresh screens on account switch
12 .navigationDestination(for: RepoRoute.self) { route in
13 destination(route, client: client)
8 if let client = session.client, let account = session.current {
9 TabView {
10 Tab("Dashboard", systemImage: "square.grid.2x2") {
11 NavigationStack {
12 DashboardView(client: client, username: account.username)
13 .navigationDestinations(client: client)
1414 }
15 .navigationDestination(for: MRRoute.self) { route in
16 destination(route, client: client)
15 }
16 Tab("Repositories", systemImage: "books.vertical") {
17 NavigationStack {
18 RepoListView(client: client)
19 .navigationDestinations(client: client)
1720 }
18 .navigationDestination(for: IssueRoute.self) { route in
19 destination(route, client: client)
20 }
21 .navigationDestination(for: BuildRoute.self) { route in
22 destination(route, client: client)
23 }
24 } else {
21 }
22 }
23 .id(account.id) // fresh screens on account switch
24 } else {
25 NavigationStack {
2526 SignInView()
2627 }
2728 }
2829 }
2930
31}
32
33/// Every value-routed screen, attachable to any stack root.
34private struct RouteDestinations: ViewModifier {
35
36 let client: GitbayClient
37
38 func body(content: Content) -> some View {
39 content
40 .navigationDestination(for: RepoRoute.self) { route in
41 destination(route, client: client)
42 }
43 .navigationDestination(for: MRRoute.self) { route in
44 destination(route, client: client)
45 }
46 .navigationDestination(for: IssueRoute.self) { route in
47 destination(route, client: client)
48 }
49 .navigationDestination(for: BuildRoute.self) { route in
50 destination(route, client: client)
51 }
52 }
53
3054 @ViewBuilder
3155 private func destination(_ route: RepoRoute, client: GitbayClient) -> some View {
3256 switch route {
@@ -76,6 +100,12 @@ struct ContentView: View {
76100 }
77101 }
78102
103extension View {
104 func navigationDestinations(client: GitbayClient) -> some View {
105 modifier(RouteDestinations(client: client))
106 }
107}
108
79109 #Preview {
80110 ContentView()
81111 .environment(SessionStore())
gitbay/Dashboard/DashboardViewModel.swift added +164
@@ -0,0 +1,164 @@
1import Foundation
2import Observation
3
4/// "What needs me": open MRs, issues assigned to me, recent builds.
5///
6/// The API has no cross-repo aggregate yet (a recorded gap), so this
7/// fans out per repo with bounded concurrency and shows results as they
8/// arrive. ETag revalidation makes the refresh cheap in bytes, but it is
9/// still ~2N requests against a 120/min bucket the shape that argues
10/// for the one aggregate command; see the filed issue. A 429 stops the
11/// scan and shows what arrived.
12@Observable
13@MainActor
14final class DashboardViewModel {
15
16 nonisolated struct RepoMR: Sendable, Hashable, Identifiable {
17 let repo: String
18 let mr: MergeRequest
19 var id: String { "\(repo)!\(mr.number)" }
20 }
21
22 nonisolated struct RepoIssue: Sendable, Hashable, Identifiable {
23 let repo: String
24 let issue: Issue
25 var id: String { "\(repo)#\(issue.number)" }
26 }
27
28 nonisolated struct RepoBuild: Sendable, Hashable, Identifiable {
29 let repo: String
30 let build: Build
31 var id: String { "\(repo)#\(build.number)" }
32 }
33
34 private(set) var openMRs: [RepoMR] = []
35 private(set) var assignedIssues: [RepoIssue] = []
36 private(set) var recentBuilds: [RepoBuild] = []
37 private(set) var scanning = false
38 private(set) var scannedRepos = 0
39 private(set) var totalRepos = 0
40 /// Set when the scan was cut short (rate limit, error) partial
41 /// results are on screen and honesty about it beats silence.
42 private(set) var scanNote: String?
43
44 private let client: GitbayClient
45 private let username: String
46 /// In-flight cap. Low on purpose: the phone shares one 120/min bucket
47 /// with everything else the user does.
48 private let width = 4
49
50 init(client: GitbayClient, username: String) {
51 self.client = client
52 self.username = username
53 }
54
55 func load() async {
56 scanning = true
57 scanNote = nil
58 defer { scanning = false }
59
60 let repos: [RepoSummary]
61 do {
62 repos = try await client.readList(["repo", "list"], of: RepoSummary.self)
63 .filter { !$0.isArchived }
64 } catch {
65 scanNote = LoadState<Never>.from(error).failureMessage
66 return
67 }
68 totalRepos = repos.count
69 scannedRepos = 0
70
71 var mrs: [RepoMR] = []
72 var issues: [RepoIssue] = []
73 var builds: [RepoBuild] = []
74
75 // Fan out width-at-a-time; each repo costs up to three reads.
76 var iterator = repos.makeIterator()
77 var stop = false
78 while !stop {
79 var batch: [RepoSummary] = []
80 for _ in 0..<width {
81 if let next = iterator.next() { batch.append(next) }
82 }
83 if batch.isEmpty { break }
84
85 await withTaskGroup(of: RepoScan?.self) { group in
86 for repo in batch {
87 group.addTask { [client, username] in
88 await Self.scan(repo.path, client: client, username: username)
89 }
90 }
91 for await result in group {
92 scannedRepos += 1
93 guard let result else {
94 stop = true
95 continue
96 }
97 mrs.append(contentsOf: result.mrs.map { RepoMR(repo: result.repo, mr: $0) })
98 issues.append(contentsOf: result.issues.map { RepoIssue(repo: result.repo, issue: $0) })
99 builds.append(contentsOf: result.builds.map { RepoBuild(repo: result.repo, build: $0) })
100 }
101 }
102 publish(mrs: mrs, issues: issues, builds: builds)
103 }
104 if stop {
105 scanNote = "Rate limited part way — showing \(scannedRepos) of \(totalRepos) repositories."
106 }
107 }
108
109 private func publish(mrs: [RepoMR], issues: [RepoIssue], builds: [RepoBuild]) {
110 openMRs = mrs.sorted { $0.mr.createdAt > $1.mr.createdAt }
111 assignedIssues = issues.sorted { $0.issue.createdAt > $1.issue.createdAt }
112 recentBuilds = Array(builds.sorted { $0.build.createdAt > $1.build.createdAt }.prefix(10))
113 }
114
115 private nonisolated struct RepoScan: Sendable {
116 let repo: String
117 let mrs: [MergeRequest]
118 let issues: [Issue]
119 let builds: [Build]
120 }
121
122 /// One repo's slice of the dashboard. nil means "stop the scan"
123 /// the rate limiter said so.
124 private nonisolated static func scan(
125 _ repo: String,
126 client: GitbayClient,
127 username: String
128 ) async -> RepoScan? {
129 do {
130 let mrs = try await client.readList(
131 ["mr", "list", repo, "--state", "open"], of: MergeRequest.self
132 )
133 let issues = try await client.readList(
134 ["issue", "list", repo, "--state", "open"], of: Issue.self
135 ).filter { $0.assignees?.contains(username) == true }
136 // Builds only where something else is happening; a third call
137 // per silent repo is what the missing aggregate would spare.
138 var builds: [Build] = []
139 if !mrs.isEmpty || !issues.isEmpty {
140 builds = Array(try await client.readList(
141 ["build", "list", repo], of: Build.self
142 ).prefix(3))
143 }
144 return RepoScan(repo: repo, mrs: mrs, issues: issues, builds: builds)
145 } catch let error as GitbayError {
146 if case .rateLimited = error { return nil }
147 // One repo failing (permissions changed, whatever) should not
148 // hide the rest of the dashboard.
149 return RepoScan(repo: repo, mrs: [], issues: [], builds: [])
150 } catch {
151 return RepoScan(repo: repo, mrs: [], issues: [], builds: [])
152 }
153 }
154}
155
156extension LoadState {
157 /// The message of a `.failed`/`.empty`, for callers that only need text.
158 var failureMessage: String? {
159 switch self {
160 case .failed(let message), .empty(let message): message
161 default: nil
162 }
163 }
164}
gitbay/Views/Dashboard/DashboardView.swift added +114
@@ -0,0 +1,114 @@
1import SwiftUI
2
3/// What needs me, across every repo I can reach.
4struct DashboardView: View {
5
6 @State private var model: DashboardViewModel
7
8 init(client: GitbayClient, username: String) {
9 _model = State(initialValue: DashboardViewModel(client: client, username: username))
10 }
11
12 var body: some View {
13 List {
14 if let note = model.scanNote {
15 Section {
16 Label(note, systemImage: "exclamationmark.triangle")
17 .font(.caption)
18 .foregroundStyle(.orange)
19 }
20 }
21
22 Section("Needs review") {
23 if model.openMRs.isEmpty {
24 emptyRow(model.scanning ? "Scanning…" : "No open merge requests.")
25 } else {
26 ForEach(model.openMRs) { entry in
27 NavigationLink(value: MRRoute.mr(repo: entry.repo, number: entry.mr.number)) {
28 VStack(alignment: .leading, spacing: 2) {
29 Text(entry.repo)
30 .font(.caption)
31 .foregroundStyle(.secondary)
32 MRRow(mr: entry.mr)
33 }
34 }
35 }
36 }
37 }
38
39 Section("Assigned to me") {
40 if model.assignedIssues.isEmpty {
41 emptyRow(model.scanning ? "Scanning…" : "No assigned issues.")
42 } else {
43 ForEach(model.assignedIssues) { entry in
44 NavigationLink(value: IssueRoute.issue(repo: entry.repo, number: entry.issue.number)) {
45 VStack(alignment: .leading, spacing: 2) {
46 Text(entry.repo)
47 .font(.caption)
48 .foregroundStyle(.secondary)
49 HStack(spacing: 6) {
50 Text("#\(entry.issue.number)")
51 .font(.caption.monospaced())
52 .foregroundStyle(.secondary)
53 Text(entry.issue.title)
54 .font(.subheadline.weight(.medium))
55 .lineLimit(2)
56 }
57 }
58 }
59 }
60 }
61 }
62
63 Section("Recent builds") {
64 if model.recentBuilds.isEmpty {
65 emptyRow(model.scanning ? "Scanning…" : "No recent builds where something is open.")
66 } else {
67 ForEach(model.recentBuilds) { entry in
68 NavigationLink(value: BuildRoute.log(repo: entry.repo, number: entry.build.number)) {
69 HStack(spacing: 8) {
70 Image(systemName: entry.build.status == "success"
71 ? "checkmark.circle.fill"
72 : entry.build.status == "failure" ? "xmark.circle.fill" : "circle.dotted")
73 .foregroundStyle(entry.build.status == "success"
74 ? .green : entry.build.status == "failure" ? .red : .orange)
75 VStack(alignment: .leading, spacing: 2) {
76 Text(entry.repo)
77 .font(.caption)
78 .foregroundStyle(.secondary)
79 Text("#\(entry.build.number) \(entry.build.job)")
80 .font(.subheadline)
81 }
82 Spacer()
83 Text(entry.build.createdAt, format: .relative(presentation: .named))
84 .font(.caption)
85 .foregroundStyle(.tertiary)
86 }
87 }
88 }
89 }
90 }
91
92 if model.scanning {
93 Section {
94 HStack {
95 ProgressView()
96 Text("Scanning \(model.scannedRepos)/\(model.totalRepos) repositories…")
97 .font(.caption)
98 .foregroundStyle(.secondary)
99 }
100 }
101 }
102 }
103 .navigationTitle("Dashboard")
104 .toolbar { AccountMenu() }
105 .task { await model.load() }
106 .refreshable { await model.load() }
107 }
108
109 private func emptyRow(_ text: String) -> some View {
110 Text(text)
111 .font(.subheadline)
112 .foregroundStyle(.secondary)
113 }
114}
gitbay/Views/SignInView.swift +2
@@ -35,6 +35,8 @@ struct SignInView: View {
3535 .autocorrectionDisabled()
3636 .textInputAutocapitalization(.never)
3737 .focused($tokenFieldFocused)
38 .submitLabel(.go)
39 .onSubmit { signIn() }
3840 } header: {
3941 Text("Token")
4042 } footer: {
gitbayTests/DashboardViewModelTests.swift added +103
@@ -0,0 +1,103 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private let twoRepoList = """
16 {"protocol_version":1,"data":[\
17 {"path":"krz/gitbay","visibility":"public"},\
18 {"path":"krz/dust","visibility":"public","archived":true},\
19 {"path":"krz/hutch","visibility":"public"}\
20 ],"exit_code":0}
21 """
22
23private let openMRJSON = """
24 {"protocol_version":1,"data":[\
25 {"number":7,"title":"fix","state":"open","author":"krz","source":"fix",\
26 "target_ref":"main","head_sha":"aa","created_at":"2026-08-22T10:00:00.000Z"}\
27 ],"exit_code":0}
28 """
29
30private let assignedIssueJSON = """
31 {"protocol_version":1,"data":[\
32 {"number":3,"title":"mine","state":"open","author":"krz","assignees":["cmc"],\
33 "created_at":"2026-08-23T10:00:00.000Z"},\
34 {"number":4,"title":"theirs","state":"open","author":"krz","assignees":["krz"],\
35 "created_at":"2026-08-23T11:00:00.000Z"}\
36 ],"exit_code":0}
37 """
38
39private let emptyJSON = #"{"protocol_version":1,"exit_code":0}"#
40
41private let buildJSON = """
42 {"protocol_version":1,"data":[\
43 {"number":9,"job":"ci","status":"success","sha":"aa00000000",\
44 "ref":"refs/heads/main","created_at":"2026-08-22T10:05:00.000Z"}\
45 ],"exit_code":0}
46 """
47
48@MainActor
49struct DashboardViewModelTests {
50
51 @Test func aggregatesAcrossReposSkippingArchivedOnes() async throws {
52 let (client, stub) = try makeClient()
53 stub.enqueue(.init(status: 200, json: twoRepoList, match: "argv=repo&argv=list"))
54 // krz/gitbay: one open MR, one assigned + one unassigned issue, a build.
55 stub.enqueue(.init(status: 200, json: openMRJSON, match: "argv=mr&argv=list&argv=krz/gitbay"))
56 stub.enqueue(.init(status: 200, json: assignedIssueJSON, match: "argv=issue&argv=list&argv=krz/gitbay"))
57 stub.enqueue(.init(status: 200, json: buildJSON, match: "argv=build&argv=list&argv=krz/gitbay"))
58 // krz/hutch: nothing open no build call should follow.
59 stub.enqueue(.init(status: 200, json: emptyJSON, match: "argv=mr&argv=list&argv=krz/hutch"))
60 stub.enqueue(.init(status: 200, json: emptyJSON, match: "argv=issue&argv=list&argv=krz/hutch"))
61 let model = DashboardViewModel(client: client, username: "cmc")
62
63 await model.load()
64
65 #expect(model.openMRs.map(\.id) == ["krz/gitbay!7"])
66 // Only issues assigned to me, and never from the archived repo.
67 #expect(model.assignedIssues.map(\.id) == ["krz/gitbay#3"])
68 #expect(model.recentBuilds.map(\.id) == ["krz/gitbay#9"])
69 #expect(model.scanNote == nil)
70 // The archived repo cost zero requests; the quiet repo cost two.
71 let scanned = stub.seen.map { $0.url.query() ?? "" }
72 #expect(!scanned.contains { $0.contains("krz/dust") })
73 #expect(scanned.count { $0.contains("krz/hutch") } == 2)
74 }
75
76 @Test func rateLimitStopsTheScanAndSaysSo() async throws {
77 let (client, stub) = try makeClient()
78 stub.enqueue(.init(status: 200, json: twoRepoList, match: "argv=repo&argv=list"))
79 stub.enqueue(.init(status: 200, json: openMRJSON, match: "argv=mr&argv=list&argv=krz/gitbay"))
80 stub.enqueue(.init(status: 200, json: assignedIssueJSON, match: "argv=issue&argv=list&argv=krz/gitbay"))
81 stub.enqueue(.init(status: 200, json: buildJSON, match: "argv=build&argv=list&argv=krz/gitbay"))
82 stub.enqueue(.init(
83 status: 429,
84 headers: ["Retry-After": "30"],
85 json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"#,
86 match: "krz/hutch"
87 ))
88 stub.enqueue(.init(
89 status: 429,
90 headers: ["Retry-After": "30"],
91 json: #"{"protocol_version":1,"error":"rate limited; retry in 30s"}"#,
92 match: "krz/hutch"
93 ))
94 let model = DashboardViewModel(client: client, username: "cmc")
95
96 await model.load()
97
98 // What arrived before the limit is still on screen.
99 #expect(model.openMRs.count == 1)
100 let note = try #require(model.scanNote)
101 #expect(note.contains("Rate limited"))
102 }
103}