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 {
156156 OrgView(client: client, org: name)
157157 case .team(let org, let team):
158158 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))
159163 }
160164 }
161165
gitbay/Issues/LabelListViewModel.swift +34 −9
@@ -1,9 +1,30 @@
11import Foundation
22import Observation
33
4/// `label list <repo>`, plus the two commands that change the set:
5/// `label set` creates one or recolours it, `label remove` takes it off
6/// the repository and off every issue that carried it.
4/// Whose label or milestone set a screen manages. A repository's is
5/// `label <verb> <repo>`; an org's is `org label <verb> <org>`, and its
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.
728@Observable
829@MainActor
930final class LabelListViewModel {
@@ -13,18 +34,22 @@ final class LabelListViewModel {
1334 private(set) var working = false
1435
1536 private let client: GitbayClient
16 let repoPath: String
37 let owner: LabelOwner
1738
18 init(client: GitbayClient, repoPath: String) {
39 init(client: GitbayClient, owner: LabelOwner) {
1940 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))
2146 }
2247
2348 func load() async {
2449 state = .loading
2550 do {
2651 let labels = try await client.readList(
27 ["label", "list", repoPath], of: IssueLabel.self
52 owner.argv("label", "list"), of: IssueLabel.self
2853 )
2954 state = labels.isEmpty ? .empty("No labels yet.") : .loaded(labels)
3055 } catch {
@@ -36,13 +61,13 @@ final class LabelListViewModel {
3661 /// entirely, which the server reads as "make sure it exists" and
3762 /// leaves any colour it already had; an empty string clears it.
3863 func setLabel(_ name: String, color: String?) async {
39 var argv = ["label", "set", repoPath, name]
64 var argv = owner.argv("label", "set") + [name]
4065 if let color { argv.append(contentsOf: ["--color", color]) }
4166 await perform(argv)
4267 }
4368
4469 func remove(_ name: String) async {
45 await perform(["label", "remove", repoPath, name])
70 await perform(owner.argv("label", "remove") + [name])
4671 }
4772
4873 private func perform(_ argv: [String]) async {
gitbay/Issues/MilestoneListViewModel.swift +14 −10
@@ -1,8 +1,8 @@
11import Foundation
22import Observation
33
4/// `milestone list <repo> --state <s>` — milestones with their progress,
5/// plus `milestone create`, `close` and `reopen`.
4/// `milestone list --state <s>` — milestones with their progress, plus
5/// `milestone create`, `close` and `reopen`, for a repository or an org.
66@Observable
77@MainActor
88final class MilestoneListViewModel {
@@ -23,18 +23,22 @@ final class MilestoneListViewModel {
2323 }
2424
2525 private let client: GitbayClient
26 let repoPath: String
26 let owner: LabelOwner
2727
28 init(client: GitbayClient, repoPath: String) {
28 init(client: GitbayClient, owner: LabelOwner) {
2929 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))
3135 }
3236
3337 func load() async {
3438 state = .loading
3539 do {
3640 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
3842 )
3943 state = milestones.isEmpty
4044 ? .empty("No \(filter == .all ? "" : filter.rawValue + " ")milestones.")
@@ -44,12 +48,12 @@ final class MilestoneListViewModel {
4448 }
4549 }
4650
47 /// `milestone create <repo> <title> [--description <d>] [--due YYYY-MM-DD]`.
51 /// `milestone create <title> [--description <d>] [--due YYYY-MM-DD]`.
4852 /// Empty description and due are omitted rather than sent blank.
4953 func create(title: String, description: String, due: String) async -> Bool {
5054 let title = title.trimmingCharacters(in: .whitespacesAndNewlines)
5155 guard !title.isEmpty else { return false }
52 var argv = ["milestone", "create", repoPath, title]
56 var argv = owner.argv("milestone", "create") + [title]
5357 let description = description.trimmingCharacters(in: .whitespacesAndNewlines)
5458 if !description.isEmpty { argv += ["--description", description] }
5559 let due = due.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -59,11 +63,11 @@ final class MilestoneListViewModel {
5963 }
6064
6165 func close(_ title: String) async {
62 await perform(["milestone", "close", repoPath, title])
66 await perform(owner.argv("milestone", "close") + [title])
6367 }
6468
6569 func reopen(_ title: String) async {
66 await perform(["milestone", "reopen", repoPath, title])
70 await perform(owner.argv("milestone", "reopen") + [title])
6771 }
6872
6973 private func perform(_ argv: [String]) async {
gitbay/Views/Issues/LabelListView.swift +16 −6
@@ -1,7 +1,7 @@
11import SwiftUI
22
3/// A repository's labels: their colour, how many issues carry each, and
4/// the controls to create, recolour and remove them.
3/// A repository's or an org's labels: their colour, how many issues
4/// carry each, and the controls to create, recolour and remove them.
55struct LabelListView: View {
66
77 @State private var model: LabelListViewModel
@@ -9,7 +9,11 @@ struct LabelListView: View {
99 @State private var pendingRemoval: IssueLabel?
1010
1111 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))
1317 }
1418
1519 private var colors: LabelColors { LabelColors(model.state.value ?? []) }
@@ -39,7 +43,10 @@ struct LabelListView: View {
3943 }
4044
4145 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) {
4350 hex in
4451 Task { await model.setLabel(label.name, color: hex) }
4552 } remove: {
@@ -68,7 +75,9 @@ struct LabelListView: View {
6875 Button("Cancel", role: .cancel) { pendingRemoval = nil }
6976 } message: {
7077 // 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.")
7281 }
7382 }
7483}
@@ -76,6 +85,7 @@ struct LabelListView: View {
7685private struct LabelRow: View {
7786 let label: IssueLabel
7887 let color: Color
88 let manageable: Bool
7989 let working: Bool
8090 let recolour: (String) -> Void
8191 let remove: () -> Void
@@ -89,7 +99,7 @@ private struct LabelRow: View {
8999 Spacer()
90100 // An org label is managed with `org label`; the repository's
91101 // own commands refuse it, so it gets the mark and no menu.
92 if label.org {
102 if !manageable {
93103 GBChip("org", .secondary)
94104 } else {
95105 Menu {
gitbay/Views/Issues/MilestoneListView.swift +8 −4
@@ -1,13 +1,17 @@
11import SwiftUI
22
3/// Milestones and their progress.
3/// A repository's or an org's milestones and their progress.
44struct MilestoneListView: View {
55
66 @State private var model: MilestoneListViewModel
77 @State private var creating = false
88
99 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))
1115 }
1216
1317 var body: some View {
@@ -32,8 +36,8 @@ struct MilestoneListView: View {
3236 .swipeActions {
3337 // An org milestone is closed and reopened with
3438 // `org milestone`; the repository's commands
35 // refuse it.
36 if milestone.org {
39 // refuse it. The org's own screen can.
40 if milestone.org && !model.owner.isOrg {
3741 EmptyView()
3842 } else if milestone.state == "open" {
3943 Button("Close") {
gitbay/Views/Orgs/OrgView.swift +22
@@ -27,6 +27,7 @@ struct OrgView: View {
2727 }
2828 membersSection(loaded.members)
2929 teamsSection(loaded.teams)
30 issuesSection
3031 }
3132 }
3233 .overlay { LoadStateOverlay(state: model.state) }
@@ -136,6 +137,25 @@ struct OrgView: View {
136137 }
137138 }
138139
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
139159 private func teamsSection(_ teams: [String]) -> some View {
140160 Section {
141161 ForEach(teams, id: \.self) { team in
@@ -175,4 +195,6 @@ struct OrgView: View {
175195nonisolated enum OrgRoute: Hashable {
176196 case org(String)
177197 case team(org: String, team: String)
198 case labels(String)
199 case milestones(String)
178200}
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}