Org mark on repository label and milestone lists !96

merged merged by cmc on 2026-09-12 06:00 UTC · krz/gitbay-ios:org-label-milestone-marks into main

10 files changed, +106 −87

Layout: unified · split

gitbay/Account/AccountViewModel.swift +6 −22
@@ -58,10 +58,6 @@ final class AccountViewModel {
5858 /// Whether activity mail is on (v1.16.0); nil until read, or on a
5959 /// server without the setting. Inbox rows are filed either way.
6060 private(set) var mailOn: Bool?
61 /// Whether the account watches every issue and merge request on the
62 /// repositories it can write to (v1.19.0); nil until read, or on a
63 /// server whose `show` has no `watch`.
64 private(set) var watchOn: Bool?
6561 /// The account's addresses; nil until read, or on a server without
6662 /// `email list`.
6763 private(set) var emails: [EmailAddress]?
@@ -87,35 +83,23 @@ final class AccountViewModel {
8783
8884 // MARK: - Notifications
8985
90 nonisolated private struct NotificationSettings: Decodable, Sendable {
86 nonisolated private struct MailSetting: Decodable, Sendable {
9187 let mail: Bool
92 let watch: Bool?
9388 }
9489
95 func loadNotificationPreferences() async {
96 let settings = try? await client.read(
97 ["notifications", "settings", "show"], as: NotificationSettings.self)
98 mailOn = settings?.mail
99 watchOn = settings?.watch
90 func loadMailPreference() async {
91 mailOn = (try? await client.read(
92 ["notifications", "settings", "show"], as: MailSetting.self))?.mail
10093 }
10194
10295 /// `notifications settings mail on|off`, then read the value back.
10396 func setMail(_ on: Bool) async {
104 await setPreference("mail", on)
105 }
106
107 /// `notifications settings watch on|off`, then read the value back.
108 func setWatch(_ on: Bool) async {
109 await setPreference("watch", on)
110 }
111
112 private func setPreference(_ name: String, _ on: Bool) async {
11397 working = true
11498 actionError = nil
11599 defer { working = false }
116100 do {
117 try await client.run(["notifications", "settings", name, on ? "on" : "off"])
118 await loadNotificationPreferences()
101 try await client.run(["notifications", "settings", "mail", on ? "on" : "off"])
102 await loadMailPreference()
119103 } catch let error as GitbayError {
120104 actionError = error.userFacingMessage
121105 } catch {
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/Account/AccountView.swift +5 −12
@@ -57,7 +57,7 @@ struct AccountView: View {
5757 .navigationTitle("Account")
5858 .navigationBarTitleDisplayMode(.inline)
5959 .task { await model.load() }
60 .task { await model.loadNotificationPreferences() }
60 .task { await model.loadMailPreference() }
6161 .task { await model.loadEmails() }
6262 .confirmationDialog(
6363 "Remove \(removingEmail?.address ?? "")?",
@@ -77,7 +77,7 @@ struct AccountView: View {
7777 }
7878 .refreshable {
7979 await model.load()
80 await model.loadNotificationPreferences()
80 await model.loadMailPreference()
8181 await model.loadEmails()
8282 }
8383 .sheet(isPresented: $addingSSH) {
@@ -142,9 +142,8 @@ struct AccountView: View {
142142
143143 // MARK: - Sections
144144
145 /// `notifications settings mail` and `watch`. The inbox is filed
146 /// either way, and login links still arrive. Watch lands with
147 /// v1.19.0; an older server shows mail alone.
145 /// `notifications settings mail`: the mail half of notifications. The
146 /// inbox is filed either way, and login links still arrive.
148147 @ViewBuilder
149148 private var notificationsSection: some View {
150149 if let mailOn = model.mailOn {
@@ -153,16 +152,10 @@ struct AccountView: View {
153152 await model.setMail($0)
154153 }
155154 .disabled(model.working)
156 if let watchOn = model.watchOn {
157 SettingToggle(title: "Watch writable repositories", serverValue: watchOn) {
158 await model.setWatch($0)
159 }
160 .disabled(model.working)
161 }
162155 } header: {
163156 Text("Notifications")
164157 } footer: {
165 Text("Mail off keeps the inbox and stops the mail; login links are not activity and still arrive. Watching makes you a recipient of every issue and merge request on repositories you can write to.")
158 Text("Off keeps the inbox and stops the mail. Login links are not activity and still arrive.")
166159 }
167160 }
168161 }
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/AccountTests.swift +3 −27
@@ -48,15 +48,13 @@ struct AccountViewModelTests {
4848 /// v1.16.0: `notifications settings show` says whether activity mail
4949 /// is on; `notifications settings mail on|off` flips it. The read is
5050 /// separate from the keys, and an older server leaves it unknown.
51 /// A server before v1.19.0 answers without `watch`, which stays nil.
5251 @Test func mailPreferenceLoadsAndFlips() async throws {
5352 let (model, stub) = try await loadedModel()
5453 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true},"exit_code":0}"#, match: "argv=settings&argv=show"))
5554
56 await model.loadNotificationPreferences()
55 await model.loadMailPreference()
5756
5857 #expect(model.mailOn == true)
59 #expect(model.watchOn == nil)
6058 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
6159 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":false},"exit_code":0}"#, match: "argv=settings&argv=show"))
6260
@@ -68,35 +66,13 @@ struct AccountViewModelTests {
6866 #expect(model.mailOn == false)
6967 }
7068
71 /// v1.19.0: `show` also reports `watch`, and `notifications settings
72 /// watch on|off` flips it.
73 @Test func watchPreferenceLoadsAndFlips() async throws {
74 let (model, stub) = try await loadedModel()
75 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true,"watch":false},"exit_code":0}"#, match: "argv=settings&argv=show"))
76
77 await model.loadNotificationPreferences()
78
79 #expect(model.mailOn == true)
80 #expect(model.watchOn == false)
81 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
82 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true,"watch":true},"exit_code":0}"#, match: "argv=settings&argv=show"))
83
84 await model.setWatch(true)
85
86 let write = try #require(stub.seen.first { $0.method == "POST" })
87 let (argv, _) = try argvOf(write)
88 #expect(argv == ["notifications", "settings", "watch", "on"])
89 #expect(model.watchOn == true)
90 }
91
92 @Test func anOlderServerLeavesTheNotificationPreferencesUnknown() async throws {
69 @Test func anOlderServerLeavesTheMailPreferenceUnknown() async throws {
9370 let (model, stub) = try await loadedModel()
9471 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=settings&argv=show"))
9572
96 await model.loadNotificationPreferences()
73 await model.loadMailPreference()
9774
9875 #expect(model.mailOn == nil)
99 #expect(model.watchOn == nil)
10076 #expect(model.actionError == nil)
10177 }
10278
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))
gitbayUITests/LiveSmokeUITests.swift −17
@@ -860,23 +860,6 @@ extension LiveSmokeUITests {
860860 flipMail()
861861 XCTAssertTrue(waitForValue(mail, mailWasOn ? "1" : "0", timeout: 10), "mail toggle did not restore")
862862
863 // The watch preference (v1.19.0) flips the same way.
864 let watch = app.switches["Watch writable repositories"].firstMatch
865 XCTAssertTrue(scrollTo(watch), "watch toggle not reachable")
866 let innerWatch = watch.switches.firstMatch
867 let flipWatch: () -> Void = {
868 if innerWatch.exists && innerWatch != watch {
869 innerWatch.tap()
870 } else {
871 watch.coordinate(withNormalizedOffset: CGVector(dx: 0.93, dy: 0.5)).tap()
872 }
873 }
874 let watchWasOn = (watch.value as? String) == "1"
875 flipWatch()
876 XCTAssertTrue(waitForValue(watch, watchWasOn ? "0" : "1", timeout: 10), "watch toggle did not persist")
877 flipWatch()
878 XCTAssertTrue(waitForValue(watch, watchWasOn ? "1" : "0", timeout: 10), "watch toggle did not restore")
879
880863 // A bogus verification code is refused, not swallowed.
881864 let code = app.descendants(matching: .any)
882865 .matching(identifier: "email-code").firstMatch