Commit 1a3fa0c298
Verified · cmc
Layout: unified · split
gitbay/Issues/IssueDetailViewModel.swift +8
| @@ -12,6 +12,8 @@ final class IssueDetailViewModel { | ||
| 12 | 12 | private(set) var working = false |
| 13 | 13 | /// Open milestones for the picker; fetched on first use. |
| 14 | 14 | private(set) var availableMilestones: [Milestone]? |
| 15 | private(set) var labels: [IssueLabel] = [] | |
| 16 | private(set) var colors = LabelColors() | |
| 15 | 17 | |
| 16 | 18 | private let client: GitbayClient |
| 17 | 19 | let repoPath: String |
| @@ -31,6 +33,12 @@ final class IssueDetailViewModel { | ||
| 31 | 33 | } catch { |
| 32 | 34 | state = .from(error) |
| 33 | 35 | } |
| 36 | labels = await labelList() | |
| 37 | colors = LabelColors(labels) | |
| 38 | } | |
| 39 | ||
| 40 | private func labelList() async -> [IssueLabel] { | |
| 41 | (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [] | |
| 34 | 42 | } |
| 35 | 43 | |
| 36 | 44 | /// Both fields are always sent: the title as given, the body over |
gitbay/Issues/IssueListViewModel.swift +14
| @@ -13,6 +13,7 @@ final class IssueListViewModel { | ||
| 13 | 13 | |
| 14 | 14 | let list: PagedListModel<Issue> |
| 15 | 15 | let repoPath: String |
| 16 | private let client: GitbayClient | |
| 16 | 17 | var filter: StateFilter = .open { |
| 17 | 18 | didSet { |
| 18 | 19 | guard filter != oldValue else { return } |
| @@ -21,7 +22,14 @@ final class IssueListViewModel { | ||
| 21 | 22 | } |
| 22 | 23 | } |
| 23 | 24 | |
| 25 | /// This repository's labels, loaded beside the issues: the names for | |
| 26 | /// a picker, the colours for the chips. A failure here is not fatal — | |
| 27 | /// every chip then derives its colour from its name. | |
| 28 | private(set) var labels: [IssueLabel] = [] | |
| 29 | private(set) var colors = LabelColors() | |
| 30 | ||
| 24 | 31 | init(client: GitbayClient, repoPath: String) { |
| 32 | self.client = client | |
| 25 | 33 | self.repoPath = repoPath |
| 26 | 34 | list = PagedListModel( |
| 27 | 35 | client: client, |
| @@ -34,6 +42,12 @@ final class IssueListViewModel { | ||
| 34 | 42 | |
| 35 | 43 | func load() async { |
| 36 | 44 | await list.reload() |
| 45 | labels = await labelList() | |
| 46 | colors = LabelColors(labels) | |
| 47 | } | |
| 48 | ||
| 49 | private func labelList() async -> [IssueLabel] { | |
| 50 | (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [] | |
| 37 | 51 | } |
| 38 | 52 | |
| 39 | 53 | private func configureList() { |
gitbay/Views/Issues/IssueListView.swift +3 −2
| @@ -26,7 +26,7 @@ struct IssueListView: View { | ||
| 26 | 26 | |
| 27 | 27 | ForEach(model.state.value ?? []) { issue in |
| 28 | 28 | NavigationLink(value: IssueRoute.issue(repo: model.repoPath, number: issue.number)) { |
| 29 | IssueRow(issue: issue) | |
| 29 | IssueRow(issue: issue, colors: model.colors) | |
| 30 | 30 | } |
| 31 | 31 | } |
| 32 | 32 | PageFooter(list: model.list) |
| @@ -70,6 +70,7 @@ struct IssueListView: View { | ||
| 70 | 70 | |
| 71 | 71 | private struct IssueRow: View { |
| 72 | 72 | let issue: Issue |
| 73 | let colors: LabelColors | |
| 73 | 74 | |
| 74 | 75 | var body: some View { |
| 75 | 76 | VStack(alignment: .leading, spacing: 4) { |
| @@ -86,7 +87,7 @@ private struct IssueRow: View { | ||
| 86 | 87 | .font(.gbSans(.caption2)) |
| 87 | 88 | .foregroundStyle(issue.isOpen ? Color.gbOK : Color.gbBad) |
| 88 | 89 | ForEach(issue.labels ?? [], id: \.self) { label in |
| 89 | GBChip(label, .secondary) | |
| 90 | GBChip(label, colors.color(label)) | |
| 90 | 91 | } |
| 91 | 92 | Spacer() |
| 92 | 93 | if let assignees = issue.assignees, !assignees.isEmpty { |
gitbay/Views/Issues/IssueView.swift +8 −4
| @@ -88,7 +88,7 @@ struct IssueView: View { | ||
| 88 | 88 | @ViewBuilder |
| 89 | 89 | private func triageSection(_ issue: IssueDetail) -> some View { |
| 90 | 90 | Section("Labels") { |
| 91 | labelFlow(issue.labels ?? [], remove: { label in | |
| 91 | labelFlow(issue.labels ?? [], color: { model.colors.color($0) }, remove: { label in | |
| 92 | 92 | Task { await model.removeLabel(label) } |
| 93 | 93 | }) |
| 94 | 94 | HStack { |
| @@ -150,7 +150,11 @@ struct IssueView: View { | ||
| 150 | 150 | } |
| 151 | 151 | |
| 152 | 152 | @ViewBuilder |
| 153 | private func labelFlow(_ items: [String], remove: @escaping (String) -> Void) -> some View { | |
| 153 | private func labelFlow( | |
| 154 | _ items: [String], | |
| 155 | color: @escaping (String) -> Color = { _ in .secondary }, | |
| 156 | remove: @escaping (String) -> Void | |
| 157 | ) -> some View { | |
| 154 | 158 | if !items.isEmpty { |
| 155 | 159 | ScrollView(.horizontal, showsIndicators: false) { |
| 156 | 160 | HStack(spacing: 6) { |
| @@ -168,8 +172,8 @@ struct IssueView: View { | ||
| 168 | 172 | .font(.gbSans(.caption)) |
| 169 | 173 | .padding(.horizontal, 8) |
| 170 | 174 | .padding(.vertical, 3) |
| 171 | .background(Color.secondary.opacity(0.07), in: gbChipShape) | |
| 172 | .overlay(gbChipShape.stroke(Color.secondary.opacity(0.35), lineWidth: 1)) | |
| 175 | .background(color(item).opacity(0.07), in: gbChipShape) | |
| 176 | .overlay(gbChipShape.stroke(color(item).opacity(0.35), lineWidth: 1)) | |
| 173 | 177 | } |
| 174 | 178 | } |
| 175 | 179 | } |
gitbayTests/IssueBuildViewModelTests.swift +1 −1
| @@ -84,7 +84,7 @@ struct IssueDetailViewModelTests { | ||
| 84 | 84 | |
| 85 | 85 | await model.comment("triaged from the phone") |
| 86 | 86 | |
| 87 | let write = stub.seen[1] | |
| 87 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 88 | 88 | #expect(write.method == "POST") |
| 89 | 89 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) |
| 90 | 90 | #expect(body["argv"] as? [String] == ["issue", "comment", "krz/gitbay", "11", "--file", "-"]) |
gitbayTests/LabelTests.swift +85
| @@ -98,3 +98,88 @@ struct LabelColorsTests { | ||
| 98 | 98 | #expect(LabelColors().hex("docs") == "#9a6700") |
| 99 | 99 | } |
| 100 | 100 | } |
| 101 | ||
| 102 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 103 | let box = StubProtocol.box() | |
| 104 | let client = GitbayClient( | |
| 105 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 106 | token: "test-token", | |
| 107 | session: box.session() | |
| 108 | ) | |
| 109 | return (client, box) | |
| 110 | } | |
| 111 | ||
| 112 | private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] { | |
| 113 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | |
| 114 | return try #require(body["argv"] as? [String]) | |
| 115 | } | |
| 116 | ||
| 117 | private let issuePageJSON = """ | |
| 118 | {"protocol_version":1,"data":{"items":[\ | |
| 119 | {"number":7,"title":"a bug","state":"open","author":"cmc",\ | |
| 120 | "labels":["bug","ci"],"created_at":"2026-09-01T00:00:00Z"}]},"exit_code":0} | |
| 121 | """ | |
| 122 | private let labelListJSON = """ | |
| 123 | {"protocol_version":1,"data":[\ | |
| 124 | {"name":"bug","color":"#cf222e","issues":18},\ | |
| 125 | {"name":"ci","issues":11}],"exit_code":0} | |
| 126 | """ | |
| 127 | ||
| 128 | @MainActor | |
| 129 | struct IssueListColorsTests { | |
| 130 | ||
| 131 | @Test func loadsLabelsAlongsideTheIssues() async throws { | |
| 132 | let (client, stub) = try makeClient() | |
| 133 | stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue")) | |
| 134 | stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label")) | |
| 135 | ||
| 136 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | |
| 137 | await model.load() | |
| 138 | ||
| 139 | #expect(model.state.value?.count == 1) | |
| 140 | // The names are what MR 2's filter picker will offer. | |
| 141 | #expect(model.labels.map(\.name) == ["bug", "ci"]) | |
| 142 | #expect(model.colors.hex("bug") == "#cf222e") | |
| 143 | #expect(model.colors.hex("ci") == "#1a7f37") | |
| 144 | #expect(stub.seen.contains { | |
| 145 | $0.url.query() == "argv=label&argv=list&argv=krz/gitbay" | |
| 146 | }) | |
| 147 | } | |
| 148 | ||
| 149 | /// Decoration must not take the screen down with it. | |
| 150 | @Test func issuesStillLoadWhenTheLabelReadFails() async throws { | |
| 151 | let (client, stub) = try makeClient() | |
| 152 | stub.enqueue(.init(status: 200, json: issuePageJSON, match: "argv=issue")) | |
| 153 | stub.enqueue(.init(status: 403, json: """ | |
| 154 | {"protocol_version":1,"error":"denied","exit_code":4} | |
| 155 | """, match: "argv=label")) | |
| 156 | ||
| 157 | let model = IssueListViewModel(client: client, repoPath: "krz/gitbay") | |
| 158 | await model.load() | |
| 159 | ||
| 160 | #expect(model.state.value?.count == 1) | |
| 161 | #expect(model.labels.isEmpty) | |
| 162 | // Derived from the name, since nothing was stored. | |
| 163 | #expect(model.colors.hex("bug") == "#b93a86") | |
| 164 | } | |
| 165 | } | |
| 166 | ||
| 167 | @MainActor | |
| 168 | struct IssueDetailColorsTests { | |
| 169 | ||
| 170 | @Test func loadsLabelsAlongsideTheIssue() async throws { | |
| 171 | let (client, stub) = try makeClient() | |
| 172 | stub.enqueue(.init(status: 200, json: """ | |
| 173 | {"protocol_version":1,"data":{"number":7,"title":"a bug","state":"open",\ | |
| 174 | "author":"cmc","labels":["bug"],"created_at":"2026-09-01T00:00:00Z"},\ | |
| 175 | "exit_code":0} | |
| 176 | """, match: "argv=show")) | |
| 177 | stub.enqueue(.init(status: 200, json: labelListJSON, match: "argv=label")) | |
| 178 | ||
| 179 | let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 7) | |
| 180 | await model.load() | |
| 181 | ||
| 182 | #expect(model.state.value?.number == 7) | |
| 183 | #expect(model.colors.hex("bug") == "#cf222e") | |
| 184 | } | |
| 185 | } | |