Commit 3d76c278d1
Unsigned
Layout: unified · split
gitbay/ContentView.swift +1 −1
| @@ -9,7 +9,7 @@ struct ContentView: View { | |||
| 9 | TabView { | 9 | TabView { |
| 10 | Tab("Dashboard", systemImage: "square.grid.2x2") { | 10 | Tab("Dashboard", systemImage: "square.grid.2x2") { |
| 11 | NavigationStack { | 11 | NavigationStack { |
| 12 | DashboardView(client: client, username: account.username) | 12 | DashboardView(client: client) |
| 13 | .navigationDestinations(client: client) | 13 | .navigationDestinations(client: client) |
| 14 | } | 14 | } |
| 15 | } | 15 | } |
gitbay/Dashboard/DashboardModels.swift added +54
| @@ -0,0 +1,54 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// The `dashboard` command: the whole account aggregate in one read. | ||
| 4 | /// `internal/control/dashboard.go` — arrays are always present. | ||
| 5 | nonisolated struct DashboardData: Decodable, Sendable, Hashable { | ||
| 6 | /// Pinned repos share `repo list`'s row shape. | ||
| 7 | let pinned: [RepoSummary] | ||
| 8 | let openMRs: [DashboardItem] | ||
| 9 | let assignedIssues: [DashboardItem] | ||
| 10 | let builds: [DashboardBuild] | ||
| 11 | |||
| 12 | enum CodingKeys: String, CodingKey { | ||
| 13 | case pinned, builds | ||
| 14 | case openMRs = "open_mrs" | ||
| 15 | case assignedIssues = "assigned_issues" | ||
| 16 | } | ||
| 17 | } | ||
| 18 | |||
| 19 | /// One open issue or MR row, repo resolved server-side. | ||
| 20 | nonisolated struct DashboardItem: Decodable, Sendable, Hashable, Identifiable { | ||
| 21 | let repo: String | ||
| 22 | let number: Int64 | ||
| 23 | let title: String | ||
| 24 | let author: String | ||
| 25 | let state: String | ||
| 26 | let updatedAt: Date | ||
| 27 | |||
| 28 | enum CodingKeys: String, CodingKey { | ||
| 29 | case repo, number, title, author, state | ||
| 30 | case updatedAt = "updated_at" | ||
| 31 | } | ||
| 32 | |||
| 33 | var id: String { "\(repo)#\(number)" } | ||
| 34 | } | ||
| 35 | |||
| 36 | /// One build row with its repo attached. | ||
| 37 | nonisolated struct DashboardBuild: Decodable, Sendable, Hashable, Identifiable { | ||
| 38 | let repo: String | ||
| 39 | let number: Int64 | ||
| 40 | let job: String | ||
| 41 | let status: String | ||
| 42 | let sha: String | ||
| 43 | let ref: String | ||
| 44 | let createdAt: Date | ||
| 45 | let finishedAt: Date? | ||
| 46 | |||
| 47 | enum CodingKeys: String, CodingKey { | ||
| 48 | case repo, number, job, status, sha, ref | ||
| 49 | case createdAt = "created_at" | ||
| 50 | case finishedAt = "finished_at" | ||
| 51 | } | ||
| 52 | |||
| 53 | var id: String { "\(repo)#\(number)" } | ||
| 54 | } | ||
gitbay/Dashboard/DashboardViewModel.swift +8 −145
| @@ -1,164 +1,27 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import Observation | 2 | import Observation |
| 3 | 3 | ||
| 4 | /// "What needs me": open MRs, issues assigned to me, recent builds. | 4 | /// "What needs me": pinned repos, open MRs, assigned issues, recent |
| 5 | /// | 5 | /// builds — one `dashboard` read. This replaced a per-repo fan-out that |
| 6 | /// The API has no cross-repo aggregate yet (a recorded gap), so this | 6 | /// drained the rate bucket at 64 of 66 repos; krz/gitbay#41 added the |
| 7 | /// fans out per repo with bounded concurrency and shows results as they | 7 | /// aggregate. |
| 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 | 8 | @Observable |
| 13 | @MainActor | 9 | @MainActor |
| 14 | final class DashboardViewModel { | 10 | final class DashboardViewModel { |
| 15 | 11 | ||
| 16 | nonisolated struct RepoMR: Sendable, Hashable, Identifiable { | 12 | private(set) var state: LoadState<DashboardData> = .loading |
| 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 | 13 | ||
| 44 | private let client: GitbayClient | 14 | 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 | 15 | ||
| 50 | init(client: GitbayClient, username: String) { | 16 | init(client: GitbayClient) { |
| 51 | self.client = client | 17 | self.client = client |
| 52 | self.username = username | ||
| 53 | } | 18 | } |
| 54 | 19 | ||
| 55 | func load() async { | 20 | func load() async { |
| 56 | scanning = true | ||
| 57 | scanNote = nil | ||
| 58 | defer { scanning = false } | ||
| 59 | |||
| 60 | let repos: [RepoSummary] | ||
| 61 | do { | 21 | do { |
| 62 | repos = try await client.readList(["repo", "list"], of: RepoSummary.self) | 22 | state = .loaded(try await client.read(["dashboard"], as: DashboardData.self)) |
| 63 | .filter { !$0.isArchived } | ||
| 64 | } catch { | 23 | } catch { |
| 65 | scanNote = LoadState<Never>.from(error).failureMessage | 24 | state = .from(error) |
| 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 | } | 25 | } |
| 163 | } | 26 | } |
| 164 | } | 27 | } |
gitbay/Issues/IssueListViewModel.swift +21 −18
| @@ -1,7 +1,7 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import Observation | 2 | import Observation |
| 3 | 3 | ||
| 4 | /// `issue list <repo> --state <s>`. | 4 | /// `issue list <repo> --state <s>`, paginated. |
| 5 | @Observable | 5 | @Observable |
| 6 | @MainActor | 6 | @MainActor |
| 7 | final class IssueListViewModel { | 7 | final class IssueListViewModel { |
| @@ -11,30 +11,33 @@ final class IssueListViewModel { | |||
| 11 | var id: String { rawValue } | 11 | var id: String { rawValue } |
| 12 | } | 12 | } |
| 13 | 13 | ||
| 14 | private(set) var state: LoadState<[Issue]> = .loading | 14 | let list: PagedListModel<Issue> |
| 15 | let repoPath: String | ||
| 15 | var filter: StateFilter = .open { | 16 | var filter: StateFilter = .open { |
| 16 | didSet { if filter != oldValue { Task { await load() } } } | 17 | didSet { |
| 18 | guard filter != oldValue else { return } | ||
| 19 | configureList() | ||
| 20 | Task { await list.reload() } | ||
| 21 | } | ||
| 17 | } | 22 | } |
| 18 | 23 | ||
| 19 | private let client: GitbayClient | ||
| 20 | let repoPath: String | ||
| 21 | |||
| 22 | init(client: GitbayClient, repoPath: String) { | 24 | init(client: GitbayClient, repoPath: String) { |
| 23 | self.client = client | ||
| 24 | self.repoPath = repoPath | 25 | self.repoPath = repoPath |
| 26 | list = PagedListModel( | ||
| 27 | client: client, | ||
| 28 | argv: ["issue", "list", repoPath, "--state", StateFilter.open.rawValue], | ||
| 29 | emptyMessage: "No open issues." | ||
| 30 | ) | ||
| 25 | } | 31 | } |
| 26 | 32 | ||
| 33 | var state: LoadState<[Issue]> { list.state } | ||
| 34 | |||
| 27 | func load() async { | 35 | func load() async { |
| 28 | state = .loading | 36 | await list.reload() |
| 29 | do { | 37 | } |
| 30 | let issues = try await client.readList( | 38 | |
| 31 | ["issue", "list", repoPath, "--state", filter.rawValue], of: Issue.self | 39 | private func configureList() { |
| 32 | ) | 40 | list.argv = ["issue", "list", repoPath, "--state", filter.rawValue] |
| 33 | state = issues.isEmpty | 41 | list.emptyMessage = "No \(filter == .all ? "" : filter.rawValue + " ")issues." |
| 34 | ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")issues.") | ||
| 35 | : .loaded(issues.sorted { $0.number > $1.number }) | ||
| 36 | } catch { | ||
| 37 | state = .from(error) | ||
| 38 | } | ||
| 39 | } | 42 | } |
| 40 | } | 43 | } |
gitbay/MRs/MRListViewModel.swift +22 −18
| @@ -1,7 +1,7 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import Observation | 2 | import Observation |
| 3 | 3 | ||
| 4 | /// `mr list <repo> --state <s>`. | 4 | /// `mr list <repo> --state <s>`, paginated. |
| 5 | @Observable | 5 | @Observable |
| 6 | @MainActor | 6 | @MainActor |
| 7 | final class MRListViewModel { | 7 | final class MRListViewModel { |
| @@ -11,30 +11,34 @@ final class MRListViewModel { | |||
| 11 | var id: String { rawValue } | 11 | var id: String { rawValue } |
| 12 | } | 12 | } |
| 13 | 13 | ||
| 14 | private(set) var state: LoadState<[MergeRequest]> = .loading | 14 | let list: PagedListModel<MergeRequest> |
| 15 | let repoPath: String | ||
| 15 | var filter: StateFilter = .open { | 16 | var filter: StateFilter = .open { |
| 16 | didSet { if filter != oldValue { Task { await load() } } } | 17 | didSet { |
| 18 | guard filter != oldValue else { return } | ||
| 19 | configureList() | ||
| 20 | Task { await list.reload() } | ||
| 21 | } | ||
| 17 | } | 22 | } |
| 18 | 23 | ||
| 19 | private let client: GitbayClient | ||
| 20 | let repoPath: String | ||
| 21 | |||
| 22 | init(client: GitbayClient, repoPath: String) { | 24 | init(client: GitbayClient, repoPath: String) { |
| 23 | self.client = client | ||
| 24 | self.repoPath = repoPath | 25 | self.repoPath = repoPath |
| 26 | list = PagedListModel( | ||
| 27 | client: client, | ||
| 28 | argv: ["mr", "list", repoPath, "--state", StateFilter.open.rawValue], | ||
| 29 | emptyMessage: "No open merge requests." | ||
| 30 | ) | ||
| 25 | } | 31 | } |
| 26 | 32 | ||
| 33 | var state: LoadState<[MergeRequest]> { list.state } | ||
| 34 | |||
| 27 | func load() async { | 35 | func load() async { |
| 28 | state = .loading | 36 | await list.reload() |
| 29 | do { | 37 | } |
| 30 | let mrs = try await client.readList( | 38 | |
| 31 | ["mr", "list", repoPath, "--state", filter.rawValue], of: MergeRequest.self | 39 | private func configureList() { |
| 32 | ) | 40 | list.argv = ["mr", "list", repoPath, "--state", filter.rawValue] |
| 33 | state = mrs.isEmpty | 41 | list.emptyMessage = |
| 34 | ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")merge requests.") | 42 | "No \(filter == .all ? "" : filter.rawValue + " ")merge requests." |
| 35 | : .loaded(mrs.sorted { $0.number > $1.number }) | ||
| 36 | } catch { | ||
| 37 | state = .from(error) | ||
| 38 | } | ||
| 39 | } | 43 | } |
| 40 | } | 44 | } |
gitbay/Networking/GitbayClient.swift +26
| @@ -79,6 +79,32 @@ nonisolated final class GitbayClient: Sendable { | |||
| 79 | return envelope.data ?? [] | 79 | return envelope.data ?? [] |
| 80 | } | 80 | } |
| 81 | 81 | ||
| 82 | /// One page of a paginated list command. With `--limit`/`--cursor` | ||
| 83 | /// present the server moves the array under `items` and returns the | ||
| 84 | /// opaque `next` cursor alongside; `next` is absent on the last page. | ||
| 85 | nonisolated struct Page<Element: Decodable & Sendable>: Decodable, Sendable { | ||
| 86 | let items: [Element] | ||
| 87 | let next: String? | ||
| 88 | } | ||
| 89 | |||
| 90 | /// Run a paginated list command (`repo list`, `issue list`, `mr | ||
| 91 | /// list`, `feed`). Cursors are opaque and kind-checked server-side — | ||
| 92 | /// pass back exactly what `next` carried, never synthesize one. | ||
| 93 | func readPage<Element: Decodable & Sendable>( | ||
| 94 | _ argv: [String], | ||
| 95 | of _: Element.Type, | ||
| 96 | limit: Int, | ||
| 97 | cursor: String? = nil | ||
| 98 | ) async throws -> Page<Element> { | ||
| 99 | var argv = argv + ["--limit", String(limit)] | ||
| 100 | if let cursor { argv.append(contentsOf: ["--cursor", cursor]) } | ||
| 101 | let envelope: Envelope<Page<Element>> = try await readEnvelope(argv) | ||
| 102 | guard let page = envelope.data else { | ||
| 103 | throw GitbayError.decoding(MissingData(argv: argv)) | ||
| 104 | } | ||
| 105 | return page | ||
| 106 | } | ||
| 107 | |||
| 82 | /// Run a read-only command that emits raw text rather than JSON | 108 | /// Run a read-only command that emits raw text rather than JSON |
| 83 | /// (`mr diff`, `build log`). The server wraps those as `output`. | 109 | /// (`mr diff`, `build log`). The server wraps those as `output`. |
| 84 | func readText(_ argv: [String]) async throws -> String { | 110 | func readText(_ argv: [String]) async throws -> String { |
gitbay/Repos/PagedListModel.swift added +68
| @@ -0,0 +1,68 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// Accumulated pages of one list command (`repo list`, `issue list`, | ||
| 5 | /// `mr list`). Cursors are opaque and server-minted; the model only | ||
| 6 | /// hands back what `next` carried. | ||
| 7 | @Observable | ||
| 8 | @MainActor | ||
| 9 | final class PagedListModel<Element: Decodable & Sendable> { | ||
| 10 | |||
| 11 | private(set) var state: LoadState<[Element]> = .loading | ||
| 12 | private(set) var isLoadingMore = false | ||
| 13 | private var next: String? | ||
| 14 | |||
| 15 | var hasMore: Bool { next != nil } | ||
| 16 | |||
| 17 | private let client: GitbayClient | ||
| 18 | private let pageSize: Int | ||
| 19 | /// The list command minus paging flags. Set by the owning view model | ||
| 20 | /// when a filter changes; a change only takes effect via `reload()`. | ||
| 21 | var argv: [String] | ||
| 22 | var emptyMessage: String | ||
| 23 | |||
| 24 | init( | ||
| 25 | client: GitbayClient, | ||
| 26 | argv: [String], | ||
| 27 | emptyMessage: String, | ||
| 28 | pageSize: Int = 50 | ||
| 29 | ) { | ||
| 30 | self.client = client | ||
| 31 | self.argv = argv | ||
| 32 | self.emptyMessage = emptyMessage | ||
| 33 | self.pageSize = pageSize | ||
| 34 | } | ||
| 35 | |||
| 36 | func reload() async { | ||
| 37 | state = .loading | ||
| 38 | next = nil | ||
| 39 | await fetch(cursor: nil, appendingTo: []) | ||
| 40 | } | ||
| 41 | |||
| 42 | /// Fetch the next page. Safe to call repeatedly from row-appear | ||
| 43 | /// triggers; it no-ops while a fetch is in flight or at the end. | ||
| 44 | func loadMore() async { | ||
| 45 | guard let cursor = next, !isLoadingMore, let loaded = state.value else { return } | ||
| 46 | isLoadingMore = true | ||
| 47 | defer { isLoadingMore = false } | ||
| 48 | await fetch(cursor: cursor, appendingTo: loaded) | ||
| 49 | } | ||
| 50 | |||
| 51 | private func fetch(cursor: String?, appendingTo existing: [Element]) async { | ||
| 52 | do { | ||
| 53 | let page = try await client.readPage( | ||
| 54 | argv, of: Element.self, limit: pageSize, cursor: cursor | ||
| 55 | ) | ||
| 56 | next = page.next | ||
| 57 | let all = existing + page.items | ||
| 58 | state = all.isEmpty ? .empty(emptyMessage) : .loaded(all) | ||
| 59 | } catch { | ||
| 60 | // A failed first page is the screen's state; a failed later | ||
| 61 | // page keeps what is on screen and leaves the cursor for a | ||
| 62 | // retry from the same trigger. | ||
| 63 | if existing.isEmpty { | ||
| 64 | state = .from(error) | ||
| 65 | } | ||
| 66 | } | ||
| 67 | } | ||
| 68 | } | ||
gitbay/Repos/RepoListViewModel.swift +14 −14
| @@ -1,22 +1,29 @@ | |||
| 1 | import Foundation | 1 | import Foundation |
| 2 | import Observation | 2 | import Observation |
| 3 | 3 | ||
| 4 | /// The `repo list` screen: everything you own or can reach, filterable. | 4 | /// The `repo list` screen, paginated, filterable client-side. The filter |
| 5 | /// only sees loaded pages; the page size is large enough that one page | ||
| 6 | /// covers most accounts today. | ||
| 5 | @Observable | 7 | @Observable |
| 6 | @MainActor | 8 | @MainActor |
| 7 | final class RepoListViewModel { | 9 | final class RepoListViewModel { |
| 8 | 10 | ||
| 9 | private(set) var state: LoadState<[RepoSummary]> = .loading | 11 | let list: PagedListModel<RepoSummary> |
| 10 | var searchText = "" | 12 | var searchText = "" |
| 11 | 13 | ||
| 12 | private let client: GitbayClient | ||
| 13 | |||
| 14 | init(client: GitbayClient) { | 14 | init(client: GitbayClient) { |
| 15 | self.client = client | 15 | list = PagedListModel( |
| 16 | client: client, | ||
| 17 | argv: ["repo", "list"], | ||
| 18 | emptyMessage: "No repositories yet. Create one over SSH: gitbay repo create <name>", | ||
| 19 | pageSize: 100 | ||
| 20 | ) | ||
| 16 | } | 21 | } |
| 17 | 22 | ||
| 23 | var state: LoadState<[RepoSummary]> { list.state } | ||
| 24 | |||
| 18 | var visibleRepos: [RepoSummary] { | 25 | var visibleRepos: [RepoSummary] { |
| 19 | guard let repos = state.value else { return [] } | 26 | guard let repos = list.state.value else { return [] } |
| 20 | let query = searchText.trimmingCharacters(in: .whitespaces).lowercased() | 27 | let query = searchText.trimmingCharacters(in: .whitespaces).lowercased() |
| 21 | guard !query.isEmpty else { return repos } | 28 | guard !query.isEmpty else { return repos } |
| 22 | return repos.filter { | 29 | return repos.filter { |
| @@ -26,13 +33,6 @@ final class RepoListViewModel { | |||
| 26 | } | 33 | } |
| 27 | 34 | ||
| 28 | func load() async { | 35 | func load() async { |
| 29 | do { | 36 | await list.reload() |
| 30 | let repos = try await client.readList(["repo", "list"], of: RepoSummary.self) | ||
| 31 | state = repos.isEmpty | ||
| 32 | ? .empty("No repositories yet. Create one over SSH: gitbay repo create <name>") | ||
| 33 | : .loaded(repos.sorted { $0.path < $1.path }) | ||
| 34 | } catch { | ||
| 35 | state = .from(error) | ||
| 36 | } | ||
| 37 | } | 37 | } |
| 38 | } | 38 | } |
gitbay/Views/Dashboard/DashboardView.swift +115 −75
| @@ -1,114 +1,154 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | 2 | ||
| 3 | /// What needs me, across every repo I can reach. | 3 | /// What needs me, in one read. |
| 4 | struct DashboardView: View { | 4 | struct DashboardView: View { |
| 5 | 5 | ||
| 6 | @State private var model: DashboardViewModel | 6 | @State private var model: DashboardViewModel |
| 7 | 7 | ||
| 8 | init(client: GitbayClient, username: String) { | 8 | init(client: GitbayClient) { |
| 9 | _model = State(initialValue: DashboardViewModel(client: client, username: username)) | 9 | _model = State(initialValue: DashboardViewModel(client: client)) |
| 10 | } | 10 | } |
| 11 | 11 | ||
| 12 | var body: some View { | 12 | var body: some View { |
| 13 | List { | 13 | List { |
| 14 | if let note = model.scanNote { | 14 | if let data = model.state.value { |
| 15 | Section { | 15 | if !data.pinned.isEmpty { |
| 16 | Label(note, systemImage: "exclamationmark.triangle") | 16 | pinnedSection(data.pinned) |
| 17 | .font(.caption) | ||
| 18 | .foregroundStyle(.orange) | ||
| 19 | } | 17 | } |
| 18 | itemSection( | ||
| 19 | "Needs review", items: data.openMRs, | ||
| 20 | empty: "No open merge requests.", | ||
| 21 | marker: "!" | ||
| 22 | ) { MRRoute.mr(repo: $0.repo, number: $0.number) } | ||
| 23 | itemSection( | ||
| 24 | "Assigned to me", items: data.assignedIssues, | ||
| 25 | empty: "No assigned issues.", | ||
| 26 | marker: "#" | ||
| 27 | ) { IssueRoute.issue(repo: $0.repo, number: $0.number) } | ||
| 28 | buildsSection(data.builds) | ||
| 20 | } | 29 | } |
| 30 | } | ||
| 31 | .overlay { LoadStateOverlay(state: model.state) } | ||
| 32 | .navigationTitle("Dashboard") | ||
| 33 | .toolbar { AccountMenu() } | ||
| 34 | .task { await model.load() } | ||
| 35 | .refreshable { await model.load() } | ||
| 36 | } | ||
| 21 | 37 | ||
| 22 | Section("Needs review") { | 38 | private func pinnedSection(_ pinned: [RepoSummary]) -> some View { |
| 23 | if model.openMRs.isEmpty { | 39 | Section("Pinned") { |
| 24 | emptyRow(model.scanning ? "Scanning…" : "No open merge requests.") | 40 | ForEach(pinned) { repo in |
| 25 | } else { | 41 | NavigationLink(value: RepoRoute.repo(repo.path)) { |
| 26 | ForEach(model.openMRs) { entry in | 42 | VStack(alignment: .leading, spacing: 2) { |
| 27 | NavigationLink(value: MRRoute.mr(repo: entry.repo, number: entry.mr.number)) { | 43 | HStack(spacing: 6) { |
| 28 | VStack(alignment: .leading, spacing: 2) { | 44 | Text(repo.path) |
| 29 | Text(entry.repo) | 45 | .font(.subheadline.weight(.medium)) |
| 30 | .font(.caption) | 46 | .lineLimit(1) |
| 47 | if repo.isPrivate { | ||
| 48 | Image(systemName: "lock.fill") | ||
| 49 | .font(.caption2) | ||
| 31 | .foregroundStyle(.secondary) | 50 | .foregroundStyle(.secondary) |
| 32 | MRRow(mr: entry.mr) | ||
| 33 | } | 51 | } |
| 34 | } | 52 | } |
| 53 | if let description = repo.description, !description.isEmpty { | ||
| 54 | Text(description) | ||
| 55 | .font(.caption) | ||
| 56 | .foregroundStyle(.secondary) | ||
| 57 | .lineLimit(1) | ||
| 58 | } | ||
| 35 | } | 59 | } |
| 36 | } | 60 | } |
| 37 | } | 61 | } |
| 62 | } | ||
| 63 | } | ||
| 38 | 64 | ||
| 39 | Section("Assigned to me") { | 65 | private func itemSection( |
| 40 | if model.assignedIssues.isEmpty { | 66 | _ title: String, |
| 41 | emptyRow(model.scanning ? "Scanning…" : "No assigned issues.") | 67 | items: [DashboardItem], |
| 42 | } else { | 68 | empty: String, |
| 43 | ForEach(model.assignedIssues) { entry in | 69 | marker: String, |
| 44 | NavigationLink(value: IssueRoute.issue(repo: entry.repo, number: entry.issue.number)) { | 70 | route: @escaping (DashboardItem) -> some Hashable |
| 45 | VStack(alignment: .leading, spacing: 2) { | 71 | ) -> some View { |
| 46 | Text(entry.repo) | 72 | Section(title) { |
| 47 | .font(.caption) | 73 | if items.isEmpty { |
| 74 | Text(empty) | ||
| 75 | .font(.subheadline) | ||
| 76 | .foregroundStyle(.secondary) | ||
| 77 | } else { | ||
| 78 | ForEach(items) { item in | ||
| 79 | NavigationLink(value: route(item)) { | ||
| 80 | VStack(alignment: .leading, spacing: 2) { | ||
| 81 | Text(item.repo) | ||
| 82 | .font(.caption) | ||
| 83 | .foregroundStyle(.secondary) | ||
| 84 | HStack(alignment: .firstTextBaseline, spacing: 6) { | ||
| 85 | Text(marker + String(item.number)) | ||
| 86 | .font(.caption.monospaced()) | ||
| 48 | .foregroundStyle(.secondary) | 87 | .foregroundStyle(.secondary) |
| 49 | HStack(spacing: 6) { | 88 | Text(item.title) |
| 50 | Text("#\(entry.issue.number)") | 89 | .font(.subheadline.weight(.medium)) |
| 51 | .font(.caption.monospaced()) | 90 | .lineLimit(2) |
| 52 | .foregroundStyle(.secondary) | ||
| 53 | Text(entry.issue.title) | ||
| 54 | .font(.subheadline.weight(.medium)) | ||
| 55 | .lineLimit(2) | ||
| 56 | } | ||
| 57 | } | 91 | } |
| 92 | HStack(spacing: 6) { | ||
| 93 | Text("by \(item.author)") | ||
| 94 | Text(item.updatedAt, format: .relative(presentation: .named)) | ||
| 95 | .foregroundStyle(.tertiary) | ||
| 96 | } | ||
| 97 | .font(.caption) | ||
| 98 | .foregroundStyle(.secondary) | ||
| 58 | } | 99 | } |
| 59 | } | 100 | } |
| 60 | } | 101 | } |
| 61 | } | 102 | } |
| 103 | } | ||
| 104 | } | ||
| 62 | 105 | ||
| 63 | Section("Recent builds") { | 106 | private func buildsSection(_ builds: [DashboardBuild]) -> some View { |
| 64 | if model.recentBuilds.isEmpty { | 107 | Section("Recent builds") { |
| 65 | emptyRow(model.scanning ? "Scanning…" : "No recent builds where something is open.") | 108 | if builds.isEmpty { |
| 66 | } else { | 109 | Text("No recent builds.") |
| 67 | ForEach(model.recentBuilds) { entry in | 110 | .font(.subheadline) |
| 68 | NavigationLink(value: BuildRoute.log(repo: entry.repo, number: entry.build.number)) { | 111 | .foregroundStyle(.secondary) |
| 69 | HStack(spacing: 8) { | 112 | } else { |
| 70 | Image(systemName: entry.build.status == "success" | 113 | ForEach(builds) { build in |
| 71 | ? "checkmark.circle.fill" | 114 | NavigationLink(value: BuildRoute.log(repo: build.repo, number: build.number)) { |
| 72 | : entry.build.status == "failure" ? "xmark.circle.fill" : "circle.dotted") | 115 | HStack(spacing: 8) { |
| 73 | .foregroundStyle(entry.build.status == "success" | 116 | Image(systemName: buildIcon(build.status)) |
| 74 | ? .green : entry.build.status == "failure" ? .red : .orange) | 117 | .foregroundStyle(buildColor(build.status)) |
| 75 | VStack(alignment: .leading, spacing: 2) { | 118 | VStack(alignment: .leading, spacing: 2) { |
| 76 | Text(entry.repo) | 119 | Text(build.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) | 120 | .font(.caption) |
| 85 | .foregroundStyle(.tertiary) | 121 | .foregroundStyle(.secondary) |
| 122 | Text("#\(build.number) \(build.job)") | ||
| 123 | .font(.subheadline) | ||
| 86 | } | 124 | } |
| 125 | Spacer() | ||
| 126 | Text(build.createdAt, format: .relative(presentation: .named)) | ||
| 127 | .font(.caption) | ||
| 128 | .foregroundStyle(.tertiary) | ||
| 87 | } | 129 | } |
| 88 | } | 130 | } |
| 89 | } | 131 | } |
| 90 | } | 132 | } |
| 133 | } | ||
| 134 | } | ||
| 91 | 135 | ||
| 92 | if model.scanning { | 136 | private func buildIcon(_ status: String) -> String { |
| 93 | Section { | 137 | switch status { |
| 94 | HStack { | 138 | case "success": "checkmark.circle.fill" |
| 95 | ProgressView() | 139 | case "failure", "error": "xmark.circle.fill" |
| 96 | Text("Scanning \(model.scannedRepos)/\(model.totalRepos) repositories…") | 140 | case "running": "circle.dotted" |
| 97 | .font(.caption) | 141 | case "queued", "pending": "clock" |
| 98 | .foregroundStyle(.secondary) | 142 | default: "questionmark.circle" |
| 99 | } | ||
| 100 | } | ||
| 101 | } | ||
| 102 | } | 143 | } |
| 103 | .navigationTitle("Dashboard") | ||
| 104 | .toolbar { AccountMenu() } | ||
| 105 | .task { await model.load() } | ||
| 106 | .refreshable { await model.load() } | ||
| 107 | } | 144 | } |
| 108 | 145 | ||
| 109 | private func emptyRow(_ text: String) -> some View { | 146 | private func buildColor(_ status: String) -> Color { |
| 110 | Text(text) | 147 | switch status { |
| 111 | .font(.subheadline) | 148 | case "success": .green |
| 112 | .foregroundStyle(.secondary) | 149 | case "failure", "error": .red |
| 150 | case "running", "queued", "pending": .orange | ||
| 151 | default: .secondary | ||
| 152 | } | ||
| 113 | } | 153 | } |
| 114 | } | 154 | } |
gitbay/Views/Issues/IssueListView.swift +1
| @@ -24,6 +24,7 @@ struct IssueListView: View { | |||
| 24 | IssueRow(issue: issue) | 24 | IssueRow(issue: issue) |
| 25 | } | 25 | } |
| 26 | } | 26 | } |
| 27 | PageFooter(list: model.list) | ||
| 27 | } | 28 | } |
| 28 | .overlay { LoadStateOverlay(state: model.state) } | 29 | .overlay { LoadStateOverlay(state: model.state) } |
| 29 | .navigationTitle("Issues") | 30 | .navigationTitle("Issues") |
gitbay/Views/MRs/MRListView.swift +1
| @@ -24,6 +24,7 @@ struct MRListView: View { | |||
| 24 | MRRow(mr: mr) | 24 | MRRow(mr: mr) |
| 25 | } | 25 | } |
| 26 | } | 26 | } |
| 27 | PageFooter(list: model.list) | ||
| 27 | } | 28 | } |
| 28 | .overlay { LoadStateOverlay(state: model.state) } | 29 | .overlay { LoadStateOverlay(state: model.state) } |
| 29 | .navigationTitle("Merge Requests") | 30 | .navigationTitle("Merge Requests") |
gitbay/Views/Repos/PageFooter.swift added +21
| @@ -0,0 +1,21 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// The last row of a paginated list: appears, fetches the next page, | ||
| 4 | /// re-arms after each append until the server stops sending a cursor. | ||
| 5 | struct PageFooter<Element: Decodable & Sendable>: View { | ||
| 6 | |||
| 7 | let list: PagedListModel<Element> | ||
| 8 | |||
| 9 | var body: some View { | ||
| 10 | if list.hasMore { | ||
| 11 | HStack { | ||
| 12 | Spacer() | ||
| 13 | ProgressView() | ||
| 14 | Spacer() | ||
| 15 | } | ||
| 16 | .task(id: list.state.value?.count ?? 0) { | ||
| 17 | await list.loadMore() | ||
| 18 | } | ||
| 19 | } | ||
| 20 | } | ||
| 21 | } | ||
gitbay/Views/Repos/RepoListView.swift +3
| @@ -16,6 +16,9 @@ struct RepoListView: View { | |||
| 16 | RepoRow(repo: repo) | 16 | RepoRow(repo: repo) |
| 17 | } | 17 | } |
| 18 | } | 18 | } |
| 19 | if model.searchText.isEmpty { | ||
| 20 | PageFooter(list: model.list) | ||
| 21 | } | ||
| 19 | } | 22 | } |
| 20 | .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) } | 23 | .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) } |
| 21 | .searchable(text: Bindable(model).searchText, prompt: "Filter repositories") | 24 | .searchable(text: Bindable(model).searchText, prompt: "Filter repositories") |
gitbayTests/DashboardViewModelTests.swift +46 −69
| @@ -12,92 +12,69 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |||
| 12 | return (client, box) | 12 | return (client, box) |
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | private let twoRepoList = """ | 15 | private let dashboardJSON = """ |
| 16 | {"protocol_version":1,"data":[\ | 16 | {"protocol_version":1,"data":{\ |
| 17 | {"path":"krz/gitbay","visibility":"public"},\ | 17 | "pinned":[{"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"}],\ |
| 18 | {"path":"krz/dust","visibility":"public","archived":true},\ | 18 | "open_mrs":[{"repo":"krz/gitbay-ios","number":3,"title":"adopt aggregate","author":"cmc",\ |
| 19 | {"path":"krz/hutch","visibility":"public"}\ | 19 | "state":"open","updated_at":"2026-08-27T10:00:00.000Z"}],\ |
| 20 | ],"exit_code":0} | 20 | "assigned_issues":[{"repo":"krz/gitbay","number":11,"title":"iOS app","author":"krz",\ |
| 21 | "state":"open","updated_at":"2026-08-26T10:00:00.000Z"}],\ | ||
| 22 | "builds":[{"repo":"krz/gitbay","number":9,"job":"ci","status":"success",\ | ||
| 23 | "sha":"65ba14e0000000000000","ref":"refs/heads/main",\ | ||
| 24 | "created_at":"2026-08-27T09:00:00.000Z","finished_at":"2026-08-27T09:05:00.000Z"}]},\ | ||
| 25 | "exit_code":0} | ||
| 21 | """ | 26 | """ |
| 22 | 27 | ||
| 23 | private let openMRJSON = """ | 28 | private let emptyDashboardJSON = """ |
| 24 | {"protocol_version":1,"data":[\ | 29 | {"protocol_version":1,"data":{"pinned":[],"open_mrs":[],"assigned_issues":[],"builds":[]},\ |
| 25 | {"number":7,"title":"fix","state":"open","author":"krz","source":"fix",\ | 30 | "exit_code":0} |
| 26 | "target_ref":"main","head_sha":"aa","created_at":"2026-08-22T10:00:00.000Z"}\ | ||
| 27 | ],"exit_code":0} | ||
| 28 | """ | 31 | """ |
| 29 | 32 | ||
| 30 | private let assignedIssueJSON = """ | 33 | @MainActor |
| 31 | {"protocol_version":1,"data":[\ | 34 | struct DashboardViewModelTests { |
| 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 | 35 | ||
| 39 | private let emptyJSON = #"{"protocol_version":1,"exit_code":0}"# | 36 | @Test func oneReadFillsEverySection() async throws { |
| 37 | let (client, stub) = try makeClient() | ||
| 38 | stub.enqueue(.init(status: 200, json: dashboardJSON)) | ||
| 39 | let model = DashboardViewModel(client: client) | ||
| 40 | 40 | ||
| 41 | private let buildJSON = """ | 41 | await model.load() |
| 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 | 42 | ||
| 48 | @MainActor | 43 | let data = try #require(model.state.value) |
| 49 | struct DashboardViewModelTests { | 44 | #expect(data.pinned.map(\.path) == ["krz/gitbay"]) |
| 45 | #expect(data.openMRs.map(\.id) == ["krz/gitbay-ios#3"]) | ||
| 46 | #expect(data.assignedIssues.first?.number == 11) | ||
| 47 | #expect(data.builds.first?.status == "success") | ||
| 48 | // The whole screen cost exactly one request. | ||
| 49 | #expect(stub.seen.count == 1) | ||
| 50 | #expect(stub.seen.first?.url.query() == "argv=dashboard") | ||
| 51 | } | ||
| 50 | 52 | ||
| 51 | @Test func aggregatesAcrossReposSkippingArchivedOnes() async throws { | 53 | @Test func emptyAggregateStillLoads() async throws { |
| 52 | let (client, stub) = try makeClient() | 54 | let (client, stub) = try makeClient() |
| 53 | stub.enqueue(.init(status: 200, json: twoRepoList, match: "argv=repo&argv=list")) | 55 | stub.enqueue(.init(status: 200, json: emptyDashboardJSON)) |
| 54 | // krz/gitbay: one open MR, one assigned + one unassigned issue, a build. | 56 | let model = DashboardViewModel(client: client) |
| 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 | 57 | ||
| 63 | await model.load() | 58 | await model.load() |
| 64 | 59 | ||
| 65 | #expect(model.openMRs.map(\.id) == ["krz/gitbay!7"]) | 60 | let data = try #require(model.state.value) |
| 66 | // Only issues assigned to me, and never from the archived repo. | 61 | #expect(data.pinned.isEmpty) |
| 67 | #expect(model.assignedIssues.map(\.id) == ["krz/gitbay#3"]) | 62 | #expect(data.openMRs.isEmpty) |
| 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 | } | 63 | } |
| 75 | 64 | ||
| 76 | @Test func rateLimitStopsTheScanAndSaysSo() async throws { | 65 | @Test func aFailureIsTheScreensState() async throws { |
| 77 | let (client, stub) = try makeClient() | 66 | let (client, stub) = try makeClient() |
| 78 | stub.enqueue(.init(status: 200, json: twoRepoList, match: "argv=repo&argv=list")) | 67 | stub.enqueue(.init(status: 500, json: |
| 79 | stub.enqueue(.init(status: 200, json: openMRJSON, match: "argv=mr&argv=list&argv=krz/gitbay")) | 68 | #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) |
| 80 | stub.enqueue(.init(status: 200, json: assignedIssueJSON, match: "argv=issue&argv=list&argv=krz/gitbay")) | 69 | stub.enqueue(.init(status: 500, json: |
| 81 | stub.enqueue(.init(status: 200, json: buildJSON, match: "argv=build&argv=list&argv=krz/gitbay")) | 70 | #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) |
| 82 | stub.enqueue(.init( | 71 | let model = DashboardViewModel(client: client) |
| 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 | 72 | ||
| 96 | await model.load() | 73 | await model.load() |
| 97 | 74 | ||
| 98 | // What arrived before the limit is still on screen. | 75 | guard case .failed = model.state else { |
| 99 | #expect(model.openMRs.count == 1) | 76 | Issue.record("expected .failed, got \(model.state)") |
| 100 | let note = try #require(model.scanNote) | 77 | return |
| 101 | #expect(note.contains("Rate limited")) | 78 | } |
| 102 | } | 79 | } |
| 103 | } | 80 | } |
gitbayTests/IssueBuildViewModelTests.swift +7 −7
| @@ -13,12 +13,12 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |||
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | private let issueListJSON = """ | 15 | private let issueListJSON = """ |
| 16 | {"protocol_version":1,"data":[\ | 16 | {"protocol_version":1,"data":{"items":[\ |
| 17 | {"number":11,"title":"iOS app","state":"open","author":"krz",\ | 17 | {"number":11,"title":"iOS app","state":"open","author":"krz",\ |
| 18 | "labels":["app"],"assignees":["cmc"],"created_at":"2026-08-20T10:00:00.000Z"},\ | 18 | "labels":["app"],"assignees":["cmc"],"created_at":"2026-08-20T10:00:00.000Z"},\ |
| 19 | {"number":40,"title":"pagination","state":"open","author":"krz",\ | 19 | {"number":40,"title":"pagination","state":"open","author":"krz",\ |
| 20 | "created_at":"2026-08-21T10:00:00.000Z"}\ | 20 | "created_at":"2026-08-21T10:00:00.000Z"}\ |
| 21 | ],"exit_code":0} | 21 | ]},"exit_code":0} |
| 22 | """ | 22 | """ |
| 23 | 23 | ||
| 24 | private let issueShowJSON = """ | 24 | private let issueShowJSON = """ |
| @@ -42,7 +42,7 @@ private let buildListJSON = """ | |||
| 42 | @MainActor | 42 | @MainActor |
| 43 | struct IssueListViewModelTests { | 43 | struct IssueListViewModelTests { |
| 44 | 44 | ||
| 45 | @Test func listsNewestFirstWithLabelsAndAssignees() async throws { | 45 | @Test func listsInServerOrderWithLabelsAndAssignees() async throws { |
| 46 | let (client, stub) = try makeClient() | 46 | let (client, stub) = try makeClient() |
| 47 | stub.enqueue(.init(status: 200, json: issueListJSON)) | 47 | stub.enqueue(.init(status: 200, json: issueListJSON)) |
| 48 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | 48 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") |
| @@ -50,11 +50,11 @@ struct IssueListViewModelTests { | |||
| 50 | await model.load() | 50 | await model.load() |
| 51 | 51 | ||
| 52 | let issues = try #require(model.state.value) | 52 | let issues = try #require(model.state.value) |
| 53 | #expect(issues.map(\.number) == [40, 11]) | 53 | #expect(issues.map(\.number) == [11, 40]) |
| 54 | #expect(issues[1].labels == ["app"]) | 54 | #expect(issues[0].labels == ["app"]) |
| 55 | #expect(issues[1].assignees == ["cmc"]) | 55 | #expect(issues[0].assignees == ["cmc"]) |
| 56 | #expect(stub.seen.first?.url.query() == | 56 | #expect(stub.seen.first?.url.query() == |
| 57 | "argv=issue&argv=list&argv=krz/gitbay&argv=--state&argv=open") | 57 | "argv=issue&argv=list&argv=krz/gitbay&argv=--state&argv=open&argv=--limit&argv=50") |
| 58 | } | 58 | } |
| 59 | } | 59 | } |
| 60 | 60 | ||
gitbayTests/MRViewModelTests.swift +6 −6
| @@ -13,14 +13,14 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |||
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | private let mrListJSON = """ | 15 | private let mrListJSON = """ |
| 16 | {"protocol_version":1,"data":[\ | 16 | {"protocol_version":1,"data":{"items":[\ |
| 17 | {"number":7,"title":"client: envelope decoding","state":"open","author":"cmc",\ | 17 | {"number":7,"title":"client: envelope decoding","state":"open","author":"cmc",\ |
| 18 | "source":"client-envelope","target_ref":"main","head_sha":"aabbcc",\ | 18 | "source":"client-envelope","target_ref":"main","head_sha":"aabbcc",\ |
| 19 | "created_at":"2026-08-20T10:00:00.000Z"},\ | 19 | "created_at":"2026-08-20T10:00:00.000Z"},\ |
| 20 | {"number":9,"title":"auth: keychain","state":"open","author":"cmc",\ | 20 | {"number":9,"title":"auth: keychain","state":"open","author":"cmc",\ |
| 21 | "source":"krz/fork:auth","target_ref":"main","head_sha":"ddeeff",\ | 21 | "source":"krz/fork:auth","target_ref":"main","head_sha":"ddeeff",\ |
| 22 | "created_at":"2026-08-21T10:00:00.000Z"}\ | 22 | "created_at":"2026-08-21T10:00:00.000Z"}\ |
| 23 | ],"exit_code":0} | 23 | ]},"exit_code":0} |
| 24 | """ | 24 | """ |
| 25 | 25 | ||
| 26 | private let mrShowJSON = """ | 26 | private let mrShowJSON = """ |
| @@ -140,7 +140,7 @@ struct UnifiedDiffParserTests { | |||
| 140 | @MainActor | 140 | @MainActor |
| 141 | struct MRListViewModelTests { | 141 | struct MRListViewModelTests { |
| 142 | 142 | ||
| 143 | @Test func listsNewestFirst() async throws { | 143 | @Test func listsInServerOrderWithPagingFlags() async throws { |
| 144 | let (client, stub) = try makeClient() | 144 | let (client, stub) = try makeClient() |
| 145 | stub.enqueue(.init(status: 200, json: mrListJSON)) | 145 | stub.enqueue(.init(status: 200, json: mrListJSON)) |
| 146 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | 146 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") |
| @@ -148,16 +148,16 @@ struct MRListViewModelTests { | |||
| 148 | await model.load() | 148 | await model.load() |
| 149 | 149 | ||
| 150 | let mrs = try #require(model.state.value) | 150 | let mrs = try #require(model.state.value) |
| 151 | #expect(mrs.map(\.number) == [9, 7]) | 151 | #expect(mrs.map(\.number) == [7, 9]) |
| 152 | let seen = try #require(stub.seen.first) | 152 | let seen = try #require(stub.seen.first) |
| 153 | #expect(seen.url.query() == | 153 | #expect(seen.url.query() == |
| 154 | "argv=mr&argv=list&argv=krz/gitbay&argv=--state&argv=open") | 154 | "argv=mr&argv=list&argv=krz/gitbay&argv=--state&argv=open&argv=--limit&argv=50") |
| 155 | } | 155 | } |
| 156 | 156 | ||
| 157 | @Test func changingTheFilterReloadsWithThatState() async throws { | 157 | @Test func changingTheFilterReloadsWithThatState() async throws { |
| 158 | let (client, stub) = try makeClient() | 158 | let (client, stub) = try makeClient() |
| 159 | stub.enqueue(.init(status: 200, json: mrListJSON)) | 159 | stub.enqueue(.init(status: 200, json: mrListJSON)) |
| 160 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#)) | 160 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) |
| 161 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") | 161 | let model = MRListViewModel(client: client, repoPath: "krz/gitbay") |
| 162 | await model.load() | 162 | await model.load() |
| 163 | 163 | ||
gitbayTests/PagedListTests.swift added +125
| @@ -0,0 +1,125 @@ | |||
| 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 nonisolated struct Row: Decodable, Sendable, Equatable { | ||
| 16 | let number: Int | ||
| 17 | } | ||
| 18 | |||
| 19 | private func page(_ numbers: [Int], next: String? = nil) -> String { | ||
| 20 | let items = numbers.map { #"{"number":\#($0)}"# }.joined(separator: ",") | ||
| 21 | let nextField = next.map { #","next":"\#($0)""# } ?? "" | ||
| 22 | return #"{"protocol_version":1,"data":{"items":[\#(items)]\#(nextField)},"exit_code":0}"# | ||
| 23 | } | ||
| 24 | |||
| 25 | struct ClientPagingTests { | ||
| 26 | |||
| 27 | @Test func readPageSendsLimitAndCursorAndDecodesNext() async throws { | ||
| 28 | let (client, stub) = try makeClient() | ||
| 29 | stub.enqueue(.init(status: 200, json: page([1, 2], next: "abc"))) | ||
| 30 | stub.enqueue(.init(status: 200, json: page([3]))) | ||
| 31 | |||
| 32 | let first = try await client.readPage(["issue", "list", "krz/gitbay"], of: Row.self, limit: 2) | ||
| 33 | let second = try await client.readPage( | ||
| 34 | ["issue", "list", "krz/gitbay"], of: Row.self, limit: 2, cursor: first.next | ||
| 35 | ) | ||
| 36 | |||
| 37 | #expect(first.items == [Row(number: 1), Row(number: 2)]) | ||
| 38 | #expect(first.next == "abc") | ||
| 39 | #expect(second.items == [Row(number: 3)]) | ||
| 40 | #expect(second.next == nil) | ||
| 41 | #expect(stub.seen[0].url.query() == | ||
| 42 | "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2") | ||
| 43 | #expect(stub.seen[1].url.query() == | ||
| 44 | "argv=issue&argv=list&argv=krz/gitbay&argv=--limit&argv=2&argv=--cursor&argv=abc") | ||
| 45 | } | ||
| 46 | } | ||
| 47 | |||
| 48 | @MainActor | ||
| 49 | struct PagedListModelTests { | ||
| 50 | |||
| 51 | private func makeList(pageSize: Int = 2) throws -> (PagedListModel<Row>, StubProtocol.Box) { | ||
| 52 | let (client, stub) = try makeClient() | ||
| 53 | let list = PagedListModel<Row>( | ||
| 54 | client: client, | ||
| 55 | argv: ["issue", "list", "krz/gitbay"], | ||
| 56 | emptyMessage: "nothing", | ||
| 57 | pageSize: pageSize | ||
| 58 | ) | ||
| 59 | return (list, stub) | ||
| 60 | } | ||
| 61 | |||
| 62 | @Test func loadMoreAppendsUntilTheCursorRunsOut() async throws { | ||
| 63 | let (list, stub) = try makeList() | ||
| 64 | stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1"))) | ||
| 65 | stub.enqueue(.init(status: 200, json: page([3, 4], next: "c2"))) | ||
| 66 | stub.enqueue(.init(status: 200, json: page([5]))) | ||
| 67 | |||
| 68 | await list.reload() | ||
| 69 | #expect(list.state.value == [Row(number: 1), Row(number: 2)]) | ||
| 70 | #expect(list.hasMore) | ||
| 71 | |||
| 72 | await list.loadMore() | ||
| 73 | await list.loadMore() | ||
| 74 | #expect(list.state.value?.map(\.number) == [1, 2, 3, 4, 5]) | ||
| 75 | #expect(!list.hasMore) | ||
| 76 | |||
| 77 | // Further calls are no-ops, not requests. | ||
| 78 | await list.loadMore() | ||
| 79 | #expect(stub.seen.count == 3) | ||
| 80 | #expect(stub.seen[1].url.query()?.contains("argv=--cursor&argv=c1") == true) | ||
| 81 | #expect(stub.seen[2].url.query()?.contains("argv=--cursor&argv=c2") == true) | ||
| 82 | } | ||
| 83 | |||
| 84 | @Test func anEmptyFirstPageIsAnEmptyState() async throws { | ||
| 85 | let (list, stub) = try makeList() | ||
| 86 | stub.enqueue(.init(status: 200, json: page([]))) | ||
| 87 | |||
| 88 | await list.reload() | ||
| 89 | |||
| 90 | guard case .empty(let message) = list.state else { | ||
| 91 | Issue.record("expected .empty, got \(list.state)") | ||
| 92 | return | ||
| 93 | } | ||
| 94 | #expect(message == "nothing") | ||
| 95 | } | ||
| 96 | |||
| 97 | @Test func aFailedLaterPageKeepsTheListAndTheCursor() async throws { | ||
| 98 | let (list, stub) = try makeList() | ||
| 99 | stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1"))) | ||
| 100 | stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) | ||
| 101 | stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"boom","exit_code":1}"#)) | ||
| 102 | |||
| 103 | await list.reload() | ||
| 104 | await list.loadMore() | ||
| 105 | |||
| 106 | #expect(list.state.value == [Row(number: 1), Row(number: 2)]) | ||
| 107 | #expect(list.hasMore) // the cursor survives for a retry | ||
| 108 | |||
| 109 | stub.enqueue(.init(status: 200, json: page([3]))) | ||
| 110 | await list.loadMore() | ||
| 111 | #expect(list.state.value?.map(\.number) == [1, 2, 3]) | ||
| 112 | } | ||
| 113 | |||
| 114 | @Test func reloadDropsTheOldCursorAndItems() async throws { | ||
| 115 | let (list, stub) = try makeList() | ||
| 116 | stub.enqueue(.init(status: 200, json: page([1, 2], next: "c1"))) | ||
| 117 | stub.enqueue(.init(status: 200, json: page([9]))) | ||
| 118 | |||
| 119 | await list.reload() | ||
| 120 | await list.reload() | ||
| 121 | |||
| 122 | #expect(list.state.value == [Row(number: 9)]) | ||
| 123 | #expect(!list.hasMore) | ||
| 124 | } | ||
| 125 | } | ||
gitbayTests/RepoViewModelTests.swift +7 −5
| @@ -13,11 +13,11 @@ private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |||
| 13 | } | 13 | } |
| 14 | 14 | ||
| 15 | private let repoListJSON = """ | 15 | private let repoListJSON = """ |
| 16 | {"protocol_version":1,"data":[\ | 16 | {"protocol_version":1,"data":{"items":[\ |
| 17 | {"path":"krz/hutch","visibility":"public","description":"SourceHut iOS client"},\ | ||
| 18 | {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\ | 17 | {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\ |
| 18 | {"path":"krz/hutch","visibility":"public","description":"SourceHut iOS client"},\ | ||
| 19 | {"path":"krz/secrets","visibility":"private","archived":true}\ | 19 | {"path":"krz/secrets","visibility":"private","archived":true}\ |
| 20 | ],"exit_code":0} | 20 | ]},"exit_code":0} |
| 21 | """ | 21 | """ |
| 22 | 22 | ||
| 23 | private let repoShowJSON = """ | 23 | private let repoShowJSON = """ |
| @@ -61,7 +61,7 @@ private let logJSON = """ | |||
| 61 | @MainActor | 61 | @MainActor |
| 62 | struct RepoListViewModelTests { | 62 | struct RepoListViewModelTests { |
| 63 | 63 | ||
| 64 | @Test func loadsAndSortsByPath() async throws { | 64 | @Test func loadsOnePageInServerOrder() async throws { |
| 65 | let (client, stub) = try makeClient() | 65 | let (client, stub) = try makeClient() |
| 66 | stub.enqueue(.init(status: 200, json: repoListJSON)) | 66 | stub.enqueue(.init(status: 200, json: repoListJSON)) |
| 67 | let model = RepoListViewModel(client: client) | 67 | let model = RepoListViewModel(client: client) |
| @@ -71,6 +71,8 @@ struct RepoListViewModelTests { | |||
| 71 | #expect(model.visibleRepos.map(\.path) == ["krz/gitbay", "krz/hutch", "krz/secrets"]) | 71 | #expect(model.visibleRepos.map(\.path) == ["krz/gitbay", "krz/hutch", "krz/secrets"]) |
| 72 | #expect(model.visibleRepos[2].isPrivate) | 72 | #expect(model.visibleRepos[2].isPrivate) |
| 73 | #expect(model.visibleRepos[2].isArchived) | 73 | #expect(model.visibleRepos[2].isArchived) |
| 74 | let seen = try #require(stub.seen.first) | ||
| 75 | #expect(seen.url.query() == "argv=repo&argv=list&argv=--limit&argv=100") | ||
| 74 | } | 76 | } |
| 75 | 77 | ||
| 76 | @Test func filterMatchesPathAndDescription() async throws { | 78 | @Test func filterMatchesPathAndDescription() async throws { |
| @@ -88,7 +90,7 @@ struct RepoListViewModelTests { | |||
| 88 | 90 | ||
| 89 | @Test func anEmptyListIsAnEmptyStateNotAnError() async throws { | 91 | @Test func anEmptyListIsAnEmptyStateNotAnError() async throws { |
| 90 | let (client, stub) = try makeClient() | 92 | let (client, stub) = try makeClient() |
| 91 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#)) | 93 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"items":[]},"exit_code":0}"#)) |
| 92 | let model = RepoListViewModel(client: client) | 94 | let model = RepoListViewModel(client: client) |
| 93 | 95 | ||
| 94 | await model.load() | 96 | await model.load() |