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 {
1010 /// `#rrggbb` when set, absent otherwise — `label set` normalises to
1111 /// lowercase with the hash, so any other shape never reached the store.
1212 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
1317 let issues: Int
1418
1519 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 }
1639}
1740
1841/// 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 {
66 let description: String?
77 let due: String?
88 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
914 let open: Int
1015 let closed: Int
1116
1217 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 }
1333}
gitbay/Views/Issues/LabelListView.swift +14 −8
@@ -87,16 +87,22 @@ private struct LabelRow: View {
8787 .font(.gbSans(.caption))
8888 .foregroundStyle(.secondary)
8989 Spacer()
90 Menu {
91 ForEach(LabelPalette.palette, id: \.self) { hex in
92 Button(hex) { recolour(String(hex.dropFirst())) }
90 // An org label is managed with `org label`; the repository's
91 // own commands refuse it, so it gets the mark and no menu.
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")
93103 }
94 Button("Clear colour") { recolour("") }
95 Button("Remove", role: .destructive) { remove() }
96 } label: {
97 Image(systemName: "ellipsis.circle")
104 .disabled(working)
98105 }
99 .disabled(working)
100106 }
101107 }
102108}
gitbay/Views/Issues/MilestoneListView.swift +9 −1
@@ -30,7 +30,12 @@ struct MilestoneListView: View {
3030 ForEach(model.state.value ?? []) { milestone in
3131 MilestoneRow(milestone: milestone)
3232 .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" {
3439 Button("Close") {
3540 Task { await model.close(milestone.title) }
3641 }
@@ -136,6 +141,9 @@ private struct MilestoneRow: View {
136141 if milestone.state != "open" {
137142 GBChip(milestone.state, .gbDone)
138143 }
144 if milestone.org {
145 GBChip("org", .secondary)
146 }
139147 Spacer()
140148 if let due = milestone.due, !due.isEmpty {
141149 Text(due)
gitbayTests/LabelTests.swift +9
@@ -74,6 +74,15 @@ struct IssueLabelDecodingTests {
7474 #expect(labels[1].issues == 11)
7575 }
7676
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
7786 @Test func theIdIsTheName() throws {
7887 let json = Data(#"[{"name":"bug","issues":0}]"#.utf8)
7988 let labels = try JSONDecoder().decode([IssueLabel].self, from: json)
gitbayTests/RefsMilestoneTests.swift +17
@@ -99,6 +99,23 @@ struct MilestoneListViewModelTests {
9999 "argv=milestone&argv=list&argv=krz/gitbay&argv=--state&argv=open")
100100 }
101101
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
102119 @Test func changingTheFilterReloadsWithThatState() async throws {
103120 let (client, stub) = try makeClient()
104121 stub.enqueue(.init(status: 200, json: milestonesJSON))