Commit da1c8a11b6

da1c8a11b69183812dc67ef198176223fd2e9254

parent: b93b686b84

Unsigned

cmc <hello@cleberg.net> · 2026-09-07 17:17 UTC

Create, close and reopen milestones (!69)

A plus button on the milestone list opens a sheet for milestone create; swipe actions dispatch close and reopen. Unit tests cover argv shapes, the reload, and a refusal. No live-suite step: there is no milestone delete, so a created fixture could not be cleaned up.

Layout: unified · split

gitbay/Issues/MilestoneListViewModel.swift +40 −1
@@ -1,7 +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 <repo> --state <s>` — milestones with their progress,
5/// plus `milestone create`, `close` and `reopen`.
5@Observable 6@Observable
6@MainActor 7@MainActor
7final class MilestoneListViewModel { 8final class MilestoneListViewModel {
@@ -12,6 +13,8 @@ final class MilestoneListViewModel {
12 } 13 }
13 14
14 private(set) var state: LoadState<[Milestone]> = .loading 15 private(set) var state: LoadState<[Milestone]> = .loading
16 private(set) var actionError: String?
17 private(set) var working = false
15 var filter: StateFilter = .open { 18 var filter: StateFilter = .open {
16 didSet { 19 didSet {
17 guard filter != oldValue else { return } 20 guard filter != oldValue else { return }
@@ -40,4 +43,40 @@ final class MilestoneListViewModel {
40 state = .from(error) 43 state = .from(error)
41 } 44 }
42 } 45 }
46
47 /// `milestone create <repo> <title> [--description <d>] [--due YYYY-MM-DD]`.
48 /// Empty description and due are omitted rather than sent blank.
49 func create(title: String, description: String, due: String) async -> Bool {
50 let title = title.trimmingCharacters(in: .whitespacesAndNewlines)
51 guard !title.isEmpty else { return false }
52 var argv = ["milestone", "create", repoPath, title]
53 let description = description.trimmingCharacters(in: .whitespacesAndNewlines)
54 if !description.isEmpty { argv += ["--description", description] }
55 let due = due.trimmingCharacters(in: .whitespacesAndNewlines)
56 if !due.isEmpty { argv += ["--due", due] }
57 await perform(argv)
58 return actionError == nil
59 }
60
61 func close(_ title: String) async {
62 await perform(["milestone", "close", repoPath, title])
63 }
64
65 func reopen(_ title: String) async {
66 await perform(["milestone", "reopen", repoPath, title])
67 }
68
69 private func perform(_ argv: [String]) async {
70 working = true
71 actionError = nil
72 defer { working = false }
73 do {
74 try await client.run(argv)
75 await load()
76 } catch let error as GitbayError {
77 actionError = error.userFacingMessage
78 } catch {
79 actionError = GitbayError.transport(error).userFacingMessage
80 }
81 }
43} 82}
gitbay/Views/Issues/MilestoneListView.swift +88
@@ -4,6 +4,7 @@ import SwiftUI
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 8
8 init(client: GitbayClient, repo: String) { 9 init(client: GitbayClient, repo: String) {
9 _model = State(initialValue: MilestoneListViewModel(client: client, repoPath: repo)) 10 _model = State(initialValue: MilestoneListViewModel(client: client, repoPath: repo))
@@ -20,18 +21,105 @@ struct MilestoneListView: View {
20 .listRowBackground(Color.clear) 21 .listRowBackground(Color.clear)
21 .listRowInsets(EdgeInsets()) 22 .listRowInsets(EdgeInsets())
22 23
24 if let error = model.actionError {
25 Section {
26 GBNotice(error, .gbWarn)
27 }
28 }
29
23 ForEach(model.state.value ?? []) { milestone in 30 ForEach(model.state.value ?? []) { milestone in
24 MilestoneRow(milestone: milestone) 31 MilestoneRow(milestone: milestone)
32 .swipeActions {
33 if milestone.state == "open" {
34 Button("Close") {
35 Task { await model.close(milestone.title) }
36 }
37 } else {
38 Button("Reopen") {
39 Task { await model.reopen(milestone.title) }
40 }
41 }
42 }
25 } 43 }
26 } 44 }
27 .overlay { LoadStateOverlay(state: model.state) } 45 .overlay { LoadStateOverlay(state: model.state) }
28 .navigationTitle("Milestones") 46 .navigationTitle("Milestones")
29 .navigationBarTitleDisplayMode(.inline) 47 .navigationBarTitleDisplayMode(.inline)
48 .toolbar {
49 ToolbarItem(placement: .topBarTrailing) {
50 Button {
51 creating = true
52 } label: {
53 Image(systemName: "plus")
54 }
55 .disabled(model.working)
56 .accessibilityIdentifier("milestone-create-button")
57 }
58 }
59 .sheet(isPresented: $creating) {
60 MilestoneCreateSheet(model: model) { creating = false }
61 }
30 .task { await model.load() } 62 .task { await model.load() }
31 .refreshable { await model.load() } 63 .refreshable { await model.load() }
32 } 64 }
33} 65}
34 66
67/// `milestone create` — title, optional description, optional due date
68/// typed the way the command takes it.
69private struct MilestoneCreateSheet: View {
70 let model: MilestoneListViewModel
71 let onCreated: () -> Void
72
73 @State private var title = ""
74 @State private var description = ""
75 @State private var due = ""
76
77 var body: some View {
78 NavigationStack {
79 Form {
80 TextField("Title", text: $title)
81 .autocorrectionDisabled()
82 .accessibilityIdentifier("milestone-title")
83 TextField("Description", text: $description, axis: .vertical)
84 .lineLimit(1...4)
85 .autocorrectionDisabled()
86 TextField("Due (YYYY-MM-DD)", text: $due)
87 .keyboardType(.numbersAndPunctuation)
88 .autocorrectionDisabled()
89 if let error = model.actionError {
90 Section {
91 GBNotice(error, .gbWarn)
92 }
93 }
94 }
95 .navigationTitle("New Milestone")
96 .navigationBarTitleDisplayMode(.inline)
97 .toolbar {
98 ToolbarItem(placement: .cancellationAction) {
99 Button("Cancel") { onCreated() }
100 .disabled(model.working)
101 }
102 ToolbarItem(placement: .confirmationAction) {
103 if model.working {
104 ProgressView()
105 } else {
106 Button("Create") {
107 Task {
108 if await model.create(title: title, description: description, due: due) {
109 onCreated()
110 }
111 }
112 }
113 .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
114 .accessibilityIdentifier("milestone-submit")
115 }
116 }
117 }
118 }
119 .interactiveDismissDisabled(model.working)
120 }
121}
122
35private struct MilestoneRow: View { 123private struct MilestoneRow: View {
36 let milestone: Milestone 124 let milestone: Milestone
37 125
gitbayTests/RefsMilestoneTests.swift +60
@@ -123,6 +123,66 @@ struct MilestoneListViewModelTests {
123 let second = try #require(stub.seen.dropFirst().first) 123 let second = try #require(stub.seen.dropFirst().first)
124 #expect(second.url.query()?.contains("argv=closed") == true) 124 #expect(second.url.query()?.contains("argv=closed") == true)
125 } 125 }
126
127 private func loaded() async throws -> (MilestoneListViewModel, StubProtocol.Box) {
128 let (client, stub) = try makeClient()
129 stub.enqueue(.init(status: 200, json: milestonesJSON, match: "argv=list"))
130 let model = MilestoneListViewModel(client: client, repoPath: "krz/gitbay")
131 await model.load()
132 return (model, stub)
133 }
134
135 @Test func createSendsTitleDescriptionAndDueThenReloads() async throws {
136 let (model, stub) = try await loaded()
137 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"milestone":"v1.1.0"},"exit_code":0}"#, match: "cmd"))
138 stub.enqueue(.init(status: 200, json: milestonesJSON, match: "argv=list"))
139
140 let ok = await model.create(title: "v1.1.0", description: "next", due: "2026-10-01")
141
142 #expect(ok)
143 let write = try #require(stub.seen.first { $0.method == "POST" })
144 #expect(try argvOf(write) == ["milestone", "create", "krz/gitbay", "v1.1.0",
145 "--description", "next", "--due", "2026-10-01"])
146 #expect(stub.seen.filter { $0.method == "GET" }.count == 2)
147 }
148
149 @Test func createOmitsEmptyDescriptionAndDue() async throws {
150 let (model, stub) = try await loaded()
151 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"milestone":"v1.1.0"},"exit_code":0}"#, match: "cmd"))
152 stub.enqueue(.init(status: 200, json: milestonesJSON, match: "argv=list"))
153
154 _ = await model.create(title: " v1.1.0 ", description: "", due: "")
155
156 let write = try #require(stub.seen.first { $0.method == "POST" })
157 #expect(try argvOf(write) == ["milestone", "create", "krz/gitbay", "v1.1.0"])
158 }
159
160 @Test func closeAndReopenUseTheirCommands() async throws {
161 let (model, stub) = try await loaded()
162 for _ in 0..<2 {
163 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{},"exit_code":0}"#, match: "cmd"))
164 stub.enqueue(.init(status: 200, json: milestonesJSON, match: "argv=list"))
165 }
166
167 await model.close("v1.0.0")
168 await model.reopen("v0.5.0")
169
170 let writes = try stub.seen.filter { $0.method == "POST" }.map(argvOf)
171 #expect(writes == [
172 ["milestone", "close", "krz/gitbay", "v1.0.0"],
173 ["milestone", "reopen", "krz/gitbay", "v0.5.0"],
174 ])
175 }
176
177 @Test func aRefusedCreateSurfacesAndReturnsFalse() async throws {
178 let (model, stub) = try await loaded()
179 stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"write access required","exit_code":4}"#, match: "cmd"))
180
181 let ok = await model.create(title: "v9", description: "", due: "")
182
183 #expect(!ok)
184 #expect(model.actionError == "write access required")
185 }
126} 186}
127 187
128@MainActor 188@MainActor