Commit 725ff32b28
725ff32b287bc72e77f9d9db95f93a9377439065
parent: 170a37d06b
Unsigned
cmc <hello@cleberg.net> · 2026-09-12 06:00 UTC
Org mark on repository label and milestone lists (!96)
`label list` and `milestone list` carry `org: true` on a row the repository sees through its org since v1.19.0 (krz/gitbay#203). Decode it, absent meaning false so older servers and repository rows still parse. An org row shows an `org` chip and loses the recolour/remove menu and the close/reopen swipe, which the repository-scoped commands refuse with "manage it with org …".
This is the minimum the issue asks for. The org's own lists on the org screen and admin management (`org label set|remove`, `org milestone create|close|reopen`) are not in this MR.
Unit suite green on iPhone 17 Pro.
Closes #12
Layout: unified · split
gitbay/Issues/IssueLabel.swift
+23
| @@ -10,9 +10,32 @@ nonisolated struct IssueLabel: Decodable, Sendable, Hashable, Identifiable { |
| 10 | /// `#rrggbb` when set, absent otherwise — `label set` normalises to |
10 | /// `#rrggbb` when set, absent otherwise — `label set` normalises to |
| 11 | /// lowercase with the hash, so any other shape never reached the store. |
11 | /// lowercase with the hash, so any other shape never reached the store. |
| 12 | let color: String? |
12 | let color: String? |
| |
13 | /// True for a label the repository sees through its org (v1.19.0). |
| |
14 | /// The repository cannot recolour or remove it; `org label` does. |
| |
15 | /// Absent on the wire for a repository row and on older servers. |
| |
16 | let org: Bool |
| 13 | let issues: Int |
17 | let issues: Int |
| 14 | |
18 | |
| 15 | var id: String { name } |
19 | var id: String { name } |
| |
20 | |
| |
21 | init(name: String, color: String?, org: Bool = false, issues: Int) { |
| |
22 | self.name = name |
| |
23 | self.color = color |
| |
24 | self.org = org |
| |
25 | self.issues = issues |
| |
26 | } |
| |
27 | |
| |
28 | private enum CodingKeys: String, CodingKey { |
| |
29 | case name, color, org, issues |
| |
30 | } |
| |
31 | |
| |
32 | init(from decoder: Decoder) throws { |
| |
33 | let c = try decoder.container(keyedBy: CodingKeys.self) |
| |
34 | name = try c.decode(String.self, forKey: .name) |
| |
35 | color = try c.decodeIfPresent(String.self, forKey: .color) |
| |
36 | org = try c.decodeIfPresent(Bool.self, forKey: .org) ?? false |
| |
37 | issues = try c.decode(Int.self, forKey: .issues) |
| |
38 | } |
| 16 | } |
39 | } |
| 17 | |
40 | |
| 18 | /// Chip colours for labels, matching what the web paints |
41 | /// Chip colours for labels, matching what the web paints |
gitbay/Issues/Milestone.swift
+20
| @@ -6,8 +6,28 @@ nonisolated struct Milestone: Decodable, Sendable, Hashable, Identifiable { |
| 6 | let description: String? |
6 | let description: String? |
| 7 | let due: String? |
7 | let due: String? |
| 8 | let state: String |
8 | let state: String |
| |
9 | /// True for a milestone the repository sees through its org |
| |
10 | /// (v1.19.0). The repository cannot close or reopen it; `org |
| |
11 | /// milestone` does. Absent on the wire for a repository row and on |
| |
12 | /// older servers. |
| |
13 | let org: Bool |
| 9 | let open: Int |
14 | let open: Int |
| 10 | let closed: Int |
15 | let closed: Int |
| 11 | |
16 | |
| 12 | var id: String { title } |
17 | var id: String { title } |
| |
18 | |
| |
19 | private enum CodingKeys: String, CodingKey { |
| |
20 | case title, description, due, state, org, open, closed |
| |
21 | } |
| |
22 | |
| |
23 | init(from decoder: Decoder) throws { |
| |
24 | let c = try decoder.container(keyedBy: CodingKeys.self) |
| |
25 | title = try c.decode(String.self, forKey: .title) |
| |
26 | description = try c.decodeIfPresent(String.self, forKey: .description) |
| |
27 | due = try c.decodeIfPresent(String.self, forKey: .due) |
| |
28 | state = try c.decode(String.self, forKey: .state) |
| |
29 | org = try c.decodeIfPresent(Bool.self, forKey: .org) ?? false |
| |
30 | open = try c.decode(Int.self, forKey: .open) |
| |
31 | closed = try c.decode(Int.self, forKey: .closed) |
| |
32 | } |
| 13 | } |
33 | } |
gitbay/Views/Issues/LabelListView.swift
+14 −8
| @@ -87,16 +87,22 @@ private struct LabelRow: View { |
| 87 | .font(.gbSans(.caption)) |
87 | .font(.gbSans(.caption)) |
| 88 | .foregroundStyle(.secondary) |
88 | .foregroundStyle(.secondary) |
| 89 | Spacer() |
89 | Spacer() |
| 90 | Menu { |
90 | // An org label is managed with `org label`; the repository's |
| 91 | ForEach(LabelPalette.palette, id: \.self) { hex in |
91 | // own commands refuse it, so it gets the mark and no menu. |
| 92 | Button(hex) { recolour(String(hex.dropFirst())) } |
92 | if label.org { |
| |
93 | GBChip("org", .secondary) |
| |
94 | } else { |
| |
95 | Menu { |
| |
96 | ForEach(LabelPalette.palette, id: \.self) { hex in |
| |
97 | Button(hex) { recolour(String(hex.dropFirst())) } |
| |
98 | } |
| |
99 | Button("Clear colour") { recolour("") } |
| |
100 | Button("Remove", role: .destructive) { remove() } |
| |
101 | } label: { |
| |
102 | Image(systemName: "ellipsis.circle") |
| 93 | } |
103 | } |
| 94 | Button("Clear colour") { recolour("") } |
104 | .disabled(working) |
| 95 | Button("Remove", role: .destructive) { remove() } |
| |
| 96 | } label: { |
| |
| 97 | Image(systemName: "ellipsis.circle") |
| |
| 98 | } |
105 | } |
| 99 | .disabled(working) |
| |
| 100 | } |
106 | } |
| 101 | } |
107 | } |
| 102 | } |
108 | } |
gitbay/Views/Issues/MilestoneListView.swift
+9 −1
| @@ -30,7 +30,12 @@ struct MilestoneListView: View { |
| 30 | ForEach(model.state.value ?? []) { milestone in |
30 | ForEach(model.state.value ?? []) { milestone in |
| 31 | MilestoneRow(milestone: milestone) |
31 | MilestoneRow(milestone: milestone) |
| 32 | .swipeActions { |
32 | .swipeActions { |
| 33 | if milestone.state == "open" { |
33 | // An org milestone is closed and reopened with |
| |
34 | // `org milestone`; the repository's commands |
| |
35 | // refuse it. |
| |
36 | if milestone.org { |
| |
37 | EmptyView() |
| |
38 | } else if milestone.state == "open" { |
| 34 | Button("Close") { |
39 | Button("Close") { |
| 35 | Task { await model.close(milestone.title) } |
40 | Task { await model.close(milestone.title) } |
| 36 | } |
41 | } |
| @@ -136,6 +141,9 @@ private struct MilestoneRow: View { |
| 136 | if milestone.state != "open" { |
141 | if milestone.state != "open" { |
| 137 | GBChip(milestone.state, .gbDone) |
142 | GBChip(milestone.state, .gbDone) |
| 138 | } |
143 | } |
| |
144 | if milestone.org { |
| |
145 | GBChip("org", .secondary) |
| |
146 | } |
| 139 | Spacer() |
147 | Spacer() |
| 140 | if let due = milestone.due, !due.isEmpty { |
148 | if let due = milestone.due, !due.isEmpty { |
| 141 | Text(due) |
149 | Text(due) |
gitbayTests/LabelTests.swift
+9
| @@ -74,6 +74,15 @@ struct IssueLabelDecodingTests { |
| 74 | #expect(labels[1].issues == 11) |
74 | #expect(labels[1].issues == 11) |
| 75 | } |
75 | } |
| 76 | |
76 | |
| |
77 | /// v1.19.0: a row the repository sees through its org carries |
| |
78 | /// `org: true`; repository rows and older servers omit the key. |
| |
79 | @Test func anOrgRowIsMarkedAndARepoRowIsNot() throws { |
| |
80 | let json = Data(#"[{"name":"bug","org":true,"issues":4},{"name":"ci","issues":1}]"#.utf8) |
| |
81 | let labels = try JSONDecoder().decode([IssueLabel].self, from: json) |
| |
82 | #expect(labels[0].org) |
| |
83 | #expect(!labels[1].org) |
| |
84 | } |
| |
85 | |
| 77 | @Test func theIdIsTheName() throws { |
86 | @Test func theIdIsTheName() throws { |
| 78 | let json = Data(#"[{"name":"bug","issues":0}]"#.utf8) |
87 | let json = Data(#"[{"name":"bug","issues":0}]"#.utf8) |
| 79 | let labels = try JSONDecoder().decode([IssueLabel].self, from: json) |
88 | let labels = try JSONDecoder().decode([IssueLabel].self, from: json) |
gitbayTests/RefsMilestoneTests.swift
+17
| @@ -99,6 +99,23 @@ struct MilestoneListViewModelTests { |
| 99 | "argv=milestone&argv=list&argv=krz/gitbay&argv=--state&argv=open") |
99 | "argv=milestone&argv=list&argv=krz/gitbay&argv=--state&argv=open") |
| 100 | } |
100 | } |
| 101 | |
101 | |
| |
102 | /// v1.19.0: a milestone the repository sees through its org carries |
| |
103 | /// `org: true`; repository rows and older servers omit the key. |
| |
104 | @Test func anOrgRowIsMarkedAndARepoRowIsNot() async throws { |
| |
105 | let (client, stub) = try makeClient() |
| |
106 | stub.enqueue(.init(status: 200, json: """ |
| |
107 | {"protocol_version":1,"data":[\ |
| |
108 | {"title":"v2","state":"open","org":true,"open":1,"closed":0},\ |
| |
109 | {"title":"v1.0.0","state":"open","open":3,"closed":9}],"exit_code":0} |
| |
110 | """)) |
| |
111 | let model = MilestoneListViewModel(client: client, repoPath: "krz/gitbay") |
| |
112 | |
| |
113 | await model.load() |
| |
114 | |
| |
115 | let milestones = try #require(model.state.value) |
| |
116 | #expect(milestones.map(\.org) == [true, false]) |
| |
117 | } |
| |
118 | |
| 102 | @Test func changingTheFilterReloadsWithThatState() async throws { |
119 | @Test func changingTheFilterReloadsWithThatState() async throws { |
| 103 | let (client, stub) = try makeClient() |
120 | let (client, stub) = try makeClient() |
| 104 | stub.enqueue(.init(status: 200, json: milestonesJSON)) |
121 | stub.enqueue(.init(status: 200, json: milestonesJSON)) |