Commit 6a01f20b03

6a01f20b03ea6c1eb37d02ac0516b4eab51bb306

parent: 507e2e3ccc

Unsigned

cmc <hello@cleberg.net> · 2026-09-19 02:54 UTC

Org labels and milestones (!103)

Parity rows "org labels: set, list, remove" and "org milestones: create, list, close, reopen" (krz/gitbay fed065d, 749296f). `LabelOwner` routes the existing label and milestone screens through `org label` / `org milestone`; the org screen gains an Issues section linking to both. Org rows are manageable on the org screen and keep the read-only mark on a repository.

Layout: unified · split

gitbay/ContentView.swift +4
@@ -156,6 +156,10 @@ private struct RouteDestinations: ViewModifier {
156 OrgView(client: client, org: name) 156 OrgView(client: client, org: name)
157 case .team(let org, let team): 157 case .team(let org, let team):
158 TeamView(client: client, org: org, team: team) 158 TeamView(client: client, org: org, team: team)
159 case .labels(let org):
160 LabelListView(client: client, owner: .org(org))
161 case .milestones(let org):
162 MilestoneListView(client: client, owner: .org(org))
159 } 163 }
160 } 164 }
161 165
gitbay/Issues/LabelListViewModel.swift +34 −9
@@ -1,9 +1,30 @@
1import Foundation 1import Foundation
2import Observation 2import Observation
3 3
4/// `label list <repo>`, plus the two commands that change the set: 4/// Whose label or milestone set a screen manages. A repository's is
5/// `label set` creates one or recolours it, `label remove` takes it off 5/// `label <verb> <repo>`; an org's is `org label <verb> <org>`, and its
6/// the repository and off every issue that carried it. 6/// rows are seen by every repository under the org (v1.19.0).
7nonisolated enum LabelOwner: Hashable, Sendable {
8 case repo(String)
9 case org(String)
10
11 /// The command prefix for `noun` (`label` or `milestone`) and `verb`.
12 func argv(_ noun: String, _ verb: String) -> [String] {
13 switch self {
14 case .repo(let path): [noun, verb, path]
15 case .org(let name): ["org", noun, verb, name]
16 }
17 }
18
19 var isOrg: Bool {
20 if case .org = self { return true }
21 return false
22 }
23}
24
25/// `label list`, plus the two commands that change the set: `label set`
26/// creates one or recolours it, `label remove` takes it off the owner and
27/// off every issue that carried it.
7@Observable 28@Observable
8@MainActor 29@MainActor
9final class LabelListViewModel { 30final class LabelListViewModel {
@@ -13,18 +34,22 @@ final class LabelListViewModel {
13 private(set) var working = false 34 private(set) var working = false
14 35
15 private let client: GitbayClient 36 private let client: GitbayClient
16 let repoPath: String 37 let owner: LabelOwner
17 38
18 init(client: GitbayClient, repoPath: String) { 39 init(client: GitbayClient, owner: LabelOwner) {
19 self.client = client 40 self.client = client
20 self.repoPath = repoPath 41 self.owner = owner
42 }
43
44 convenience init(client: GitbayClient, repoPath: String) {
45 self.init(client: client, owner: .repo(repoPath))
21 } 46 }
22 47
23 func load() async { 48 func load() async {
24 state = .loading 49 state = .loading
25 do { 50 do {
26 let labels = try await client.readList( 51 let labels = try await client.readList(
27 ["label", "list", repoPath], of: IssueLabel.self 52 owner.argv("label", "list"), of: IssueLabel.self
28 ) 53 )
29 state = labels.isEmpty ? .empty("No labels yet.") : .loaded(labels) 54 state = labels.isEmpty ? .empty("No labels yet.") : .loaded(labels)
30 } catch { 55 } catch {
@@ -36,13 +61,13 @@ final class LabelListViewModel {
36 /// entirely, which the server reads as "make sure it exists" and 61 /// entirely, which the server reads as "make sure it exists" and
37 /// leaves any colour it already had; an empty string clears it. 62 /// leaves any colour it already had; an empty string clears it.
38 func setLabel(_ name: String, color: String?) async { 63 func setLabel(_ name: String, color: String?) async {
39 var argv = ["label", "set", repoPath, name] 64 var argv = owner.argv("label", "set") + [name]
40 if let color { argv.append(contentsOf: ["--color", color]) } 65 if let color { argv.append(contentsOf: ["--color", color]) }
41 await perform(argv) 66 await perform(argv)
42 } 67 }
43 68
44 func remove(_ name: String) async { 69 func remove(_ name: String) async {
45 await perform(["label", "remove", repoPath, name]) 70 await perform(owner.argv("label", "remove") + [name])
46 } 71 }
47 72
48 private func perform(_ argv: [String]) async { 73 private func perform(_ argv: [String]) async {
gitbay/Issues/MilestoneListViewModel.swift +14 −10
@@ -1,8 +1,8 @@
1import Foundation 1import Foundation
2import Observation 2import Observation
3 3
4/// `milestone list <repo> --state <s>` — milestones with their progress, 4/// `milestone list --state <s>` — milestones with their progress, plus
5/// plus `milestone create`, `close` and `reopen`. 5/// `milestone create`, `close` and `reopen`, for a repository or an org.
6@Observable 6@Observable
7@MainActor 7@MainActor
8final class MilestoneListViewModel { 8final class MilestoneListViewModel {
@@ -23,18 +23,22 @@ final class MilestoneListViewModel {
23 } 23 }
24 24
25 private let client: GitbayClient 25 private let client: GitbayClient
26 let repoPath: String 26 let owner: LabelOwner
27 27
28 init(client: GitbayClient, repoPath: String) { 28 init(client: GitbayClient, owner: LabelOwner) {
29 self.client = client 29 self.client = client
30 self.repoPath = repoPath 30 self.owner = owner
31 }
32
33 convenience init(client: GitbayClient, repoPath: String) {
34 self.init(client: client, owner: .repo(repoPath))
31 } 35 }
32 36
33 func load() async { 37 func load() async {
34 state = .loading 38 state = .loading
35 do { 39 do {
36 let milestones = try await client.readList( 40 let milestones = try await client.readList(
37 ["milestone", "list", repoPath, "--state", filter.rawValue], of: Milestone.self 41 owner.argv("milestone", "list") + ["--state", filter.rawValue], of: Milestone.self
38 ) 42 )
39 state = milestones.isEmpty 43 state = milestones.isEmpty
40 ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")milestones.") 44 ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")milestones.")
@@ -44,12 +48,12 @@ final class MilestoneListViewModel {
44 } 48 }
45 } 49 }
46 50
47 /// `milestone create <repo> <title> [--description <d>] [--due YYYY-MM-DD]`. 51 /// `milestone create <title> [--description <d>] [--due YYYY-MM-DD]`.
48 /// Empty description and due are omitted rather than sent blank. 52 /// Empty description and due are omitted rather than sent blank.
49 func create(title: String, description: String, due: String) async -> Bool { 53 func create(title: String, description: String, due: String) async -> Bool {
50 let title = title.trimmingCharacters(in: .whitespacesAndNewlines) 54 let title = title.trimmingCharacters(in: .whitespacesAndNewlines)
51 guard !title.isEmpty else { return false } 55 guard !title.isEmpty else { return false }
52 var argv = ["milestone", "create", repoPath, title] 56 var argv = owner.argv("milestone", "create") + [title]
53 let description = description.trimmingCharacters(in: .whitespacesAndNewlines) 57 let description = description.trimmingCharacters(in: .whitespacesAndNewlines)
54 if !description.isEmpty { argv += ["--description", description] } 58 if !description.isEmpty { argv += ["--description", description] }
55 let due = due.trimmingCharacters(in: .whitespacesAndNewlines) 59 let due = due.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -59,11 +63,11 @@ final class MilestoneListViewModel {
59 } 63 }
60 64
61 func close(_ title: String) async { 65 func close(_ title: String) async {
62 await perform(["milestone", "close", repoPath, title]) 66 await perform(owner.argv("milestone", "close") + [title])
63 } 67 }
64 68
65 func reopen(_ title: String) async { 69 func reopen(_ title: String) async {
66 await perform(["milestone", "reopen", repoPath, title]) 70 await perform(owner.argv("milestone", "reopen") + [title])
67 } 71 }
68 72
69 private func perform(_ argv: [String]) async { 73 private func perform(_ argv: [String]) async {
gitbay/Views/Issues/LabelListView.swift +16 −6
@@ -1,7 +1,7 @@
1import SwiftUI 1import SwiftUI
2 2
3/// A repository's labels: their colour, how many issues carry each, and 3/// A repository's or an org's labels: their colour, how many issues
4/// the controls to create, recolour and remove them. 4/// carry each, and the controls to create, recolour and remove them.
5struct LabelListView: View { 5struct LabelListView: View {
6 6
7 @State private var model: LabelListViewModel 7 @State private var model: LabelListViewModel
@@ -9,7 +9,11 @@ struct LabelListView: View {
9 @State private var pendingRemoval: IssueLabel? 9 @State private var pendingRemoval: IssueLabel?
10 10
11 init(client: GitbayClient, repo: String) { 11 init(client: GitbayClient, repo: String) {
12 _model = State(initialValue: LabelListViewModel(client: client, repoPath: repo)) 12 self.init(client: client, owner: .repo(repo))
13 }
14
15 init(client: GitbayClient, owner: LabelOwner) {
16 _model = State(initialValue: LabelListViewModel(client: client, owner: owner))
13 } 17 }
14 18
15 private var colors: LabelColors { LabelColors(model.state.value ?? []) } 19 private var colors: LabelColors { LabelColors(model.state.value ?? []) }
@@ -39,7 +43,10 @@ struct LabelListView: View {
39 } 43 }
40 44
41 ForEach(model.state.value ?? []) { label in 45 ForEach(model.state.value ?? []) { label in
42 LabelRow(label: label, color: colors.color(label.name), working: model.working) { 46 // A repository sees its org's labels but cannot change
47 // them; the org's own screen can.
48 LabelRow(label: label, color: colors.color(label.name),
49 manageable: !label.org || model.owner.isOrg, working: model.working) {
43 hex in 50 hex in
44 Task { await model.setLabel(label.name, color: hex) } 51 Task { await model.setLabel(label.name, color: hex) }
45 } remove: { 52 } remove: {
@@ -68,7 +75,9 @@ struct LabelListView: View {
68 Button("Cancel", role: .cancel) { pendingRemoval = nil } 75 Button("Cancel", role: .cancel) { pendingRemoval = nil }
69 } message: { 76 } message: {
70 // The command takes the label off every issue, not just the set. 77 // The command takes the label off every issue, not just the set.
71 Text("This takes the label off every issue that carries it.") 78 Text(model.owner.isOrg
79 ? "This takes the label off every issue in every repository under the org."
80 : "This takes the label off every issue that carries it.")
72 } 81 }
73 } 82 }
74} 83}
@@ -76,6 +85,7 @@ struct LabelListView: View {
76private struct LabelRow: View { 85private struct LabelRow: View {
77 let label: IssueLabel 86 let label: IssueLabel
78 let color: Color 87 let color: Color
88 let manageable: Bool
79 let working: Bool 89 let working: Bool
80 let recolour: (String) -> Void 90 let recolour: (String) -> Void
81 let remove: () -> Void 91 let remove: () -> Void
@@ -89,7 +99,7 @@ private struct LabelRow: View {
89 Spacer() 99 Spacer()
90 // An org label is managed with `org label`; the repository's 100 // An org label is managed with `org label`; the repository's
91 // own commands refuse it, so it gets the mark and no menu. 101 // own commands refuse it, so it gets the mark and no menu.
92 if label.org { 102 if !manageable {
93 GBChip("org", .secondary) 103 GBChip("org", .secondary)
94 } else { 104 } else {
95 Menu { 105 Menu {
gitbay/Views/Issues/MilestoneListView.swift +8 −4
@@ -1,13 +1,17 @@
1import SwiftUI 1import SwiftUI
2 2
3/// Milestones and their progress. 3/// A repository's or an org's milestones and their progress.
4struct MilestoneListView: View { 4struct MilestoneListView: View {
5 5
6 @State private var model: MilestoneListViewModel 6 @State private var model: MilestoneListViewModel
7 @State private var creating = false 7 @State private var creating = false
8 8
9 init(client: GitbayClient, repo: String) { 9 init(client: GitbayClient, repo: String) {
10 _model = State(initialValue: MilestoneListViewModel(client: client, repoPath: repo)) 10 self.init(client: client, owner: .repo(repo))
11 }
12
13 init(client: GitbayClient, owner: LabelOwner) {
14 _model = State(initialValue: MilestoneListViewModel(client: client, owner: owner))
11 } 15 }
12 16
13 var body: some View { 17 var body: some View {
@@ -32,8 +36,8 @@ struct MilestoneListView: View {
32 .swipeActions { 36 .swipeActions {
33 // An org milestone is closed and reopened with 37 // An org milestone is closed and reopened with
34 // `org milestone`; the repository's commands 38 // `org milestone`; the repository's commands
35 // refuse it. 39 // refuse it. The org's own screen can.
36 if milestone.org { 40 if milestone.org && !model.owner.isOrg {
37 EmptyView() 41 EmptyView()
38 } else if milestone.state == "open" { 42 } else if milestone.state == "open" {
39 Button("Close") { 43 Button("Close") {
gitbay/Views/Orgs/OrgView.swift +22
@@ -27,6 +27,7 @@ struct OrgView: View {
27 } 27 }
28 membersSection(loaded.members) 28 membersSection(loaded.members)
29 teamsSection(loaded.teams) 29 teamsSection(loaded.teams)
30 issuesSection
30 } 31 }
31 } 32 }
32 .overlay { LoadStateOverlay(state: model.state) } 33 .overlay { LoadStateOverlay(state: model.state) }
@@ -136,6 +137,25 @@ struct OrgView: View {
136 } 137 }
137 } 138 }
138 139
140 /// Labels and milestones every repository under the org sees
141 /// (v1.19.0), managed here with `org label` and `org milestone`.
142 private var issuesSection: some View {
143 Section {
144 NavigationLink(value: OrgRoute.labels(model.orgName)) {
145 Label("Labels", systemImage: "tag")
146 .font(.gbSans(.subheadline))
147 }
148 NavigationLink(value: OrgRoute.milestones(model.orgName)) {
149 Label("Milestones", systemImage: "flag")
150 .font(.gbSans(.subheadline))
151 }
152 } header: {
153 Text("Issues")
154 } footer: {
155 Text("Every repository in the organization sees these; a same-named repository label or milestone folds into the org's.")
156 }
157 }
158
139 private func teamsSection(_ teams: [String]) -> some View { 159 private func teamsSection(_ teams: [String]) -> some View {
140 Section { 160 Section {
141 ForEach(teams, id: \.self) { team in 161 ForEach(teams, id: \.self) { team in
@@ -175,4 +195,6 @@ struct OrgView: View {
175nonisolated enum OrgRoute: Hashable { 195nonisolated enum OrgRoute: Hashable {
176 case org(String) 196 case org(String)
177 case team(org: String, team: String) 197 case team(org: String, team: String)
198 case labels(String)
199 case milestones(String)
178} 200}
gitbayTests/OrgLabelMilestoneTests.swift added +119
@@ -0,0 +1,119 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
16 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
17 return try #require(body["argv"] as? [String])
18}
19
20private let okJSON = #"{"protocol_version":1,"data":{},"exit_code":0}"#
21
22private let orgLabelsJSON = """
23 {"protocol_version":1,"data":[\
24 {"name":"bug","color":"#cf222e","org":true,"issues":18},\
25 {"name":"ci","org":true,"issues":2}],"exit_code":0}
26 """
27
28private let orgMilestonesJSON = """
29 {"protocol_version":1,"data":[\
30 {"title":"v2.0.0","state":"open","org":true,"open":3,"closed":9}],"exit_code":0}
31 """
32
33struct LabelOwnerTests {
34
35 /// A repository's set is `label <verb> <repo>`; an org's is
36 /// `org label <verb> <org>`. Milestones follow the same shape.
37 @Test func eachOwnerSpellsItsOwnCommand() {
38 #expect(LabelOwner.repo("krz/gitbay").argv("label", "set") == ["label", "set", "krz/gitbay"])
39 #expect(LabelOwner.org("krz").argv("label", "set") == ["org", "label", "set", "krz"])
40 #expect(LabelOwner.org("krz").argv("milestone", "list") == ["org", "milestone", "list", "krz"])
41 #expect(LabelOwner.repo("krz/gitbay").isOrg == false)
42 #expect(LabelOwner.org("krz").isOrg)
43 }
44}
45
46@MainActor
47struct OrgLabelListTests {
48
49 @Test func anOrgListsItsLabelsThroughOrgLabel() async throws {
50 let (client, stub) = try makeClient()
51 stub.enqueue(.init(status: 200, json: orgLabelsJSON))
52 let model = LabelListViewModel(client: client, owner: .org("krz"))
53
54 await model.load()
55
56 #expect(stub.seen.first?.url.query() == "argv=org&argv=label&argv=list&argv=krz")
57 #expect(model.state.value?.map(\.name) == ["bug", "ci"])
58 #expect(model.state.value?.allSatisfy(\.org) == true)
59 }
60
61 @Test func setAndRemoveUseTheOrgCommands() async throws {
62 let (client, stub) = try makeClient()
63 stub.enqueue(.init(status: 200, json: orgLabelsJSON))
64 let model = LabelListViewModel(client: client, owner: .org("krz"))
65 await model.load()
66 for _ in 0..<2 {
67 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
68 stub.enqueue(.init(status: 200, json: orgLabelsJSON, match: "argv=list"))
69 }
70
71 await model.setLabel("bug", color: "cf222e")
72 await model.remove("ci")
73
74 let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf)
75 #expect(writes == [
76 ["org", "label", "set", "krz", "bug", "--color", "cf222e"],
77 ["org", "label", "remove", "krz", "ci"],
78 ])
79 }
80}
81
82@MainActor
83struct OrgMilestoneListTests {
84
85 @Test func anOrgListsItsMilestonesWithTheState() async throws {
86 let (client, stub) = try makeClient()
87 stub.enqueue(.init(status: 200, json: orgMilestonesJSON))
88 let model = MilestoneListViewModel(client: client, owner: .org("krz"))
89
90 await model.load()
91
92 #expect(stub.seen.first?.url.query()
93 == "argv=org&argv=milestone&argv=list&argv=krz&argv=--state&argv=open")
94 #expect(model.state.value?.map(\.title) == ["v2.0.0"])
95 }
96
97 @Test func createCloseAndReopenUseTheOrgCommands() async throws {
98 let (client, stub) = try makeClient()
99 stub.enqueue(.init(status: 200, json: orgMilestonesJSON))
100 let model = MilestoneListViewModel(client: client, owner: .org("krz"))
101 await model.load()
102 for _ in 0..<3 {
103 stub.enqueue(.init(status: 200, json: okJSON, match: "cmd"))
104 stub.enqueue(.init(status: 200, json: orgMilestonesJSON, match: "argv=list"))
105 }
106
107 let ok = await model.create(title: "v2.1.0", description: "next", due: "2026-10-01")
108 await model.close("v2.0.0")
109 await model.reopen("v2.0.0")
110
111 #expect(ok)
112 let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf)
113 #expect(writes == [
114 ["org", "milestone", "create", "krz", "v2.1.0", "--description", "next", "--due", "2026-10-01"],
115 ["org", "milestone", "close", "krz", "v2.0.0"],
116 ["org", "milestone", "reopen", "krz", "v2.0.0"],
117 ])
118 }
119}