Commit d48039b69f
Verified · cmc
gitbay/ContentView.swift +45 −15
| @@ -5,28 +5,52 @@ struct ContentView: View { | ||
| 5 | 5 | @Environment(SessionStore.self) private var session |
| 6 | 6 | |
| 7 | 7 | 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) | |
| 14 | 14 | } |
| 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) | |
| 17 | 20 | } |
| 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 { | |
| 25 | 26 | SignInView() |
| 26 | 27 | } |
| 27 | 28 | } |
| 28 | 29 | } |
| 29 | 30 | |
| 31 | } | |
| 32 | ||
| 33 | /// Every value-routed screen, attachable to any stack root. | |
| 34 | private 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 | ||
| 30 | 54 | @ViewBuilder |
| 31 | 55 | private func destination(_ route: RepoRoute, client: GitbayClient) -> some View { |
| 32 | 56 | switch route { |
| @@ -76,6 +100,12 @@ struct ContentView: View { | ||
| 76 | 100 | } |
| 77 | 101 | } |
| 78 | 102 | |
| 103 | extension View { | |
| 104 | func navigationDestinations(client: GitbayClient) -> some View { | |
| 105 | modifier(RouteDestinations(client: client)) | |
| 106 | } | |
| 107 | } | |
| 108 | ||
| 79 | 109 | #Preview { |
| 80 | 110 | ContentView() |
| 81 | 111 | .environment(SessionStore()) |
gitbay/Dashboard/DashboardViewModel.swift added +164
| @@ -0,0 +1,164 @@ | ||
| 1 | import Foundation | |
| 2 | import 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 | |
| 14 | final 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 | ||
| 156 | extension 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 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// What needs me, across every repo I can reach. | |
| 4 | struct 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 { | ||
| 35 | 35 | .autocorrectionDisabled() |
| 36 | 36 | .textInputAutocapitalization(.never) |
| 37 | 37 | .focused($tokenFieldFocused) |
| 38 | .submitLabel(.go) | |
| 39 | .onSubmit { signIn() } | |
| 38 | 40 | } header: { |
| 39 | 41 | Text("Token") |
| 40 | 42 | } footer: { |
gitbayTests/DashboardViewModelTests.swift added +103
| @@ -0,0 +1,103 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private 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 | ||
| 15 | private 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 | ||
| 23 | private 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 | ||
| 30 | private 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 | ||
| 39 | private let emptyJSON = #"{"protocol_version":1,"exit_code":0}"# | |
| 40 | ||
| 41 | private 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 | |
| 49 | struct 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 | } | |