Commit c88e4da270

c88e4da270a522888e0d7215d47b9ed21f138836

parent: 53ba11d5e1

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 06:03 UTC

Summarize changes: keep what is typed while the lines load

The lines are appended to the notes as they are when the read returns,
not as they were when it started.

Ref #26

Layout: unified · split

gitbay/Releases/ReleaseViewModels.swift +14 −8
@@ -43,9 +43,10 @@ final class ReleaseListViewModel {
43 } 43 }
44 44
45 /// `release notes <owner/name> <tag>`: one line per merge request 45 /// `release notes <owner/name> <tag>`: one line per merge request
46 /// landed since the previous release, appended to `notes` the way the 46 /// landed since the previous release, in the CLI's form. nil when
47 /// web's Summarize changes appends them. The lines are the CLI's. 47 /// there are none or the server refused, with `summaryNote` saying
48 func summarize(tag: String, into notes: String) async -> String { 48 /// which.
49 func summary(tag: String) async -> String? {
49 summaryNote = nil 50 summaryNote = nil
50 working = true 51 working = true
51 defer { working = false } 52 defer { working = false }
@@ -55,17 +56,22 @@ final class ReleaseListViewModel {
55 summaryNote = summary.since.isEmpty 56 summaryNote = summary.since.isEmpty
56 ? "No merge requests landed on \(tag)." 57 ? "No merge requests landed on \(tag)."
57 : "No merge requests landed since \(summary.since)." 58 : "No merge requests landed since \(summary.since)."
58 return notes 59 return nil
59 } 60 }
60 let lines = summary.items.map { "- \($0.title) (!\($0.number)) by @\($0.author)\n" }.joined() 61 return summary.items.map { "- \($0.title) (!\($0.number)) by @\($0.author)\n" }.joined()
61 let typed = notes.trimmingCharacters(in: .whitespacesAndNewlines)
62 return typed.isEmpty ? lines : typed + "\n\n" + lines
63 } catch let error as GitbayError { 62 } catch let error as GitbayError {
64 summaryNote = error.userFacingMessage 63 summaryNote = error.userFacingMessage
65 } catch { 64 } catch {
66 summaryNote = GitbayError.transport(error).userFacingMessage 65 summaryNote = GitbayError.transport(error).userFacingMessage
67 } 66 }
68 return notes 67 return nil
68 }
69
70 /// The notes with `lines` after them, as the web's Summarize changes
71 /// appends them.
72 nonisolated static func appending(_ lines: String, to notes: String) -> String {
73 let typed = notes.trimmingCharacters(in: .whitespacesAndNewlines)
74 return typed.isEmpty ? lines : typed + "\n\n" + lines
69 } 75 }
70 76
71 /// `release create <owner/name> <tag> [--title <t>] [--file -]` — 77 /// `release create <owner/name> <tag> [--title <t>] [--file -]` —
gitbay/Views/Releases/ReleaseListView.swift +5 −2
@@ -111,8 +111,11 @@ private struct ReleaseCreateSheet: View {
111 .autocorrectionDisabled() 111 .autocorrectionDisabled()
112 Button("Summarize changes") { 112 Button("Summarize changes") {
113 Task { 113 Task {
114 notes = await model.summarize( 114 // Appended to the notes as they are when the lines
115 tag: tag.trimmingCharacters(in: .whitespaces), into: notes) 115 // arrive, so typing meanwhile is kept.
116 if let lines = await model.summary(tag: tag.trimmingCharacters(in: .whitespaces)) {
117 notes = ReleaseListViewModel.appending(lines, to: notes)
118 }
116 } 119 }
117 } 120 }
118 .disabled(tag.trimmingCharacters(in: .whitespaces).isEmpty || model.working) 121 .disabled(tag.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
gitbayTests/ReleaseTests.swift +9 −11
@@ -173,7 +173,7 @@ struct FeedReleaseDestinationTests {
173@MainActor 173@MainActor
174struct ReleaseSummaryTests { 174struct ReleaseSummaryTests {
175 175
176 @Test func summarizingAppendsOneLinePerMergeRequestAfterWhatWasTyped() async throws { 176 @Test func theSummaryIsOneLinePerMergeRequestAppendedAfterWhatWasTyped() async throws {
177 let (client, stub) = try makeClient() 177 let (client, stub) = try makeClient()
178 stub.enqueue(.init(status: 200, json: """ 178 stub.enqueue(.init(status: 200, json: """
179 {"protocol_version":1,"data":{"since":"v1.0.0","items":[\ 179 {"protocol_version":1,"data":{"since":"v1.0.0","items":[\
@@ -182,32 +182,30 @@ struct ReleaseSummaryTests {
182 """)) 182 """))
183 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay") 183 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay")
184 184
185 let notes = await model.summarize(tag: "v1.1.0", into: "Second cut.") 185 let lines = try #require(await model.summary(tag: "v1.1.0"))
186 186
187 #expect(notes == "Second cut.\n\n- Add a thing (!12) by @cmc\n- Fix it (!14) by @dee\n") 187 #expect(ReleaseListViewModel.appending(lines, to: "Second cut.\n")
188 == "Second cut.\n\n- Add a thing (!12) by @cmc\n- Fix it (!14) by @dee\n")
189 #expect(ReleaseListViewModel.appending(lines, to: " ") == lines)
188 #expect(stub.seen.first?.url.query() == "argv=release&argv=notes&argv=krz/gitbay&argv=v1.1.0") 190 #expect(stub.seen.first?.url.query() == "argv=release&argv=notes&argv=krz/gitbay&argv=v1.1.0")
189 #expect(model.summaryNote == nil) 191 #expect(model.summaryNote == nil)
190 } 192 }
191 193
192 @Test func anEmptyRangeLeavesTheNotesAndSaysWhy() async throws { 194 @Test func anEmptyRangeGivesNoLinesAndSaysWhy() async throws {
193 let (client, stub) = try makeClient() 195 let (client, stub) = try makeClient()
194 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"since":"v1.0.0","items":[]},"exit_code":0}"#)) 196 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"since":"v1.0.0","items":[]},"exit_code":0}"#))
195 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay") 197 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay")
196 198
197 let notes = await model.summarize(tag: "v1.1.0", into: "typed") 199 #expect(await model.summary(tag: "v1.1.0") == nil)
198
199 #expect(notes == "typed")
200 #expect(model.summaryNote == "No merge requests landed since v1.0.0.") 200 #expect(model.summaryNote == "No merge requests landed since v1.0.0.")
201 } 201 }
202 202
203 @Test func aRefusalLeavesTheNotesAndShowsIt() async throws { 203 @Test func aRefusalGivesNoLinesAndShowsIt() async throws {
204 let (client, stub) = try makeClient() 204 let (client, stub) = try makeClient()
205 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no ref \"v9\" in krz/gitbay","exit_code":3}"#)) 205 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no ref \"v9\" in krz/gitbay","exit_code":3}"#))
206 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay") 206 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay")
207 207
208 let notes = await model.summarize(tag: "v9", into: "") 208 #expect(await model.summary(tag: "v9") == nil)
209
210 #expect(notes == "")
211 #expect(model.summaryNote == #"no ref "v9" in krz/gitbay"#) 209 #expect(model.summaryNote == #"no ref "v9" in krz/gitbay"#)
212 } 210 }
213} 211}