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 {
58 /// Whether activity mail is on (v1.16.0); nil until read, or on a 58 /// Whether activity mail is on (v1.16.0); nil until read, or on a
59 /// server without the setting. Inbox rows are filed either way. 59 /// server without the setting. Inbox rows are filed either way.
60 private(set) var mailOn: Bool? 60 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?
65 /// The account's addresses; nil until read, or on a server without 61 /// The account's addresses; nil until read, or on a server without
66 /// `email list`. 62 /// `email list`.
67 private(set) var emails: [EmailAddress]? 63 private(set) var emails: [EmailAddress]?
@@ -87,35 +83,23 @@ final class AccountViewModel {
87 83
88 // MARK: - Notifications 84 // MARK: - Notifications
89 85
90 nonisolated private struct NotificationSettings: Decodable, Sendable { 86 nonisolated private struct MailSetting: Decodable, Sendable {
91 let mail: Bool 87 let mail: Bool
92 let watch: Bool?
93 } 88 }
94 89
95 func loadNotificationPreferences() async { 90 func loadMailPreference() async {
96 let settings = try? await client.read( 91 mailOn = (try? await client.read(
97 ["notifications", "settings", "show"], as: NotificationSettings.self) 92 ["notifications", "settings", "show"], as: MailSetting.self))?.mail
98 mailOn = settings?.mail
99 watchOn = settings?.watch
100 } 93 }
101 94
102 /// `notifications settings mail on|off`, then read the value back. 95 /// `notifications settings mail on|off`, then read the value back.
103 func setMail(_ on: Bool) async { 96 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 {
113 working = true 97 working = true
114 actionError = nil 98 actionError = nil
115 defer { working = false } 99 defer { working = false }
116 do { 100 do {
117 try await client.run(["notifications", "settings", name, on ? "on" : "off"]) 101 try await client.run(["notifications", "settings", "mail", on ? "on" : "off"])
118 await loadNotificationPreferences() 102 await loadMailPreference()
119 } catch let error as GitbayError { 103 } catch let error as GitbayError {
120 actionError = error.userFacingMessage 104 actionError = error.userFacingMessage
121 } catch { 105 } catch {
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/Account/AccountView.swift +5 −12
@@ -57,7 +57,7 @@ struct AccountView: View {
57 .navigationTitle("Account") 57 .navigationTitle("Account")
58 .navigationBarTitleDisplayMode(.inline) 58 .navigationBarTitleDisplayMode(.inline)
59 .task { await model.load() } 59 .task { await model.load() }
60 .task { await model.loadNotificationPreferences() } 60 .task { await model.loadMailPreference() }
61 .task { await model.loadEmails() } 61 .task { await model.loadEmails() }
62 .confirmationDialog( 62 .confirmationDialog(
63 "Remove \(removingEmail?.address ?? "")?", 63 "Remove \(removingEmail?.address ?? "")?",
@@ -77,7 +77,7 @@ struct AccountView: View {
77 } 77 }
78 .refreshable { 78 .refreshable {
79 await model.load() 79 await model.load()
80 await model.loadNotificationPreferences() 80 await model.loadMailPreference()
81 await model.loadEmails() 81 await model.loadEmails()
82 } 82 }
83 .sheet(isPresented: $addingSSH) { 83 .sheet(isPresented: $addingSSH) {
@@ -142,9 +142,8 @@ struct AccountView: View {
142 142
143 // MARK: - Sections 143 // MARK: - Sections
144 144
145 /// `notifications settings mail` and `watch`. The inbox is filed 145 /// `notifications settings mail`: the mail half of notifications. The
146 /// either way, and login links still arrive. Watch lands with 146 /// inbox is filed either way, and login links still arrive.
147 /// v1.19.0; an older server shows mail alone.
148 @ViewBuilder 147 @ViewBuilder
149 private var notificationsSection: some View { 148 private var notificationsSection: some View {
150 if let mailOn = model.mailOn { 149 if let mailOn = model.mailOn {
@@ -153,16 +152,10 @@ struct AccountView: View {
153 await model.setMail($0) 152 await model.setMail($0)
154 } 153 }
155 .disabled(model.working) 154 .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 }
162 } header: { 155 } header: {
163 Text("Notifications") 156 Text("Notifications")
164 } footer: { 157 } 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.")
166 } 159 }
167 } 160 }
168 } 161 }
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/AccountTests.swift +3 −27
@@ -48,15 +48,13 @@ struct AccountViewModelTests {
48 /// v1.16.0: `notifications settings show` says whether activity mail 48 /// v1.16.0: `notifications settings show` says whether activity mail
49 /// is on; `notifications settings mail on|off` flips it. The read is 49 /// is on; `notifications settings mail on|off` flips it. The read is
50 /// separate from the keys, and an older server leaves it unknown. 50 /// separate from the keys, and an older server leaves it unknown.
51 /// A server before v1.19.0 answers without `watch`, which stays nil.
52 @Test func mailPreferenceLoadsAndFlips() async throws { 51 @Test func mailPreferenceLoadsAndFlips() async throws {
53 let (model, stub) = try await loadedModel() 52 let (model, stub) = try await loadedModel()
54 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true},"exit_code":0}"#, match: "argv=settings&argv=show")) 53 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":true},"exit_code":0}"#, match: "argv=settings&argv=show"))
55 54
56 await model.loadNotificationPreferences() 55 await model.loadMailPreference()
57 56
58 #expect(model.mailOn == true) 57 #expect(model.mailOn == true)
59 #expect(model.watchOn == nil)
60 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd")) 58 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
61 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":false},"exit_code":0}"#, match: "argv=settings&argv=show")) 59 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"mail":false},"exit_code":0}"#, match: "argv=settings&argv=show"))
62 60
@@ -68,35 +66,13 @@ struct AccountViewModelTests {
68 #expect(model.mailOn == false) 66 #expect(model.mailOn == false)
69 } 67 }
70 68
71 /// v1.19.0: `show` also reports `watch`, and `notifications settings 69 @Test func anOlderServerLeavesTheMailPreferenceUnknown() async throws {
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 {
93 let (model, stub) = try await loadedModel() 70 let (model, stub) = try await loadedModel()
94 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=settings&argv=show")) 71 stub.enqueue(.init(status: 400, json: #"{"protocol_version":1,"error":"no command matches","exit_code":2}"#, match: "argv=settings&argv=show"))
95 72
96 await model.loadNotificationPreferences() 73 await model.loadMailPreference()
97 74
98 #expect(model.mailOn == nil) 75 #expect(model.mailOn == nil)
99 #expect(model.watchOn == nil)
100 #expect(model.actionError == nil) 76 #expect(model.actionError == nil)
101 } 77 }
102 78
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))
gitbayUITests/LiveSmokeUITests.swift −17
@@ -860,23 +860,6 @@ extension LiveSmokeUITests {
860 flipMail() 860 flipMail()
861 XCTAssertTrue(waitForValue(mail, mailWasOn ? "1" : "0", timeout: 10), "mail toggle did not restore") 861 XCTAssertTrue(waitForValue(mail, mailWasOn ? "1" : "0", timeout: 10), "mail toggle did not restore")
862 862
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
880 // A bogus verification code is refused, not swallowed. 863 // A bogus verification code is refused, not swallowed.
881 let code = app.descendants(matching: .any) 864 let code = app.descendants(matching: .any)
882 .matching(identifier: "email-code").firstMatch 865 .matching(identifier: "email-code").firstMatch