Commit 53ba11d5e1

53ba11d5e18b26ca34f447f3d076c9edab264ca4

parent: 778d1e8fe1

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 05:33 UTC

Summarize changes when drafting a release

The new-release sheet's Summarize changes reads release notes for the
tag and appends a line per merge request since the previous release,
as the web's button does. An empty range or a refusal says why under
the notes and leaves them as typed. Needs gitbay v1.44.0.

Closes #26

Layout: unified · split

gitbay/Releases/ReleaseViewModels.swift +39
@@ -9,6 +9,8 @@ final class ReleaseListViewModel {
9 private(set) var state: LoadState<[Release]> = .loading 9 private(set) var state: LoadState<[Release]> = .loading
10 private(set) var createError: String? 10 private(set) var createError: String?
11 private(set) var working = false 11 private(set) var working = false
12 /// Why Summarize changes added nothing: an empty range, or a refusal.
13 private(set) var summaryNote: String?
12 14
13 private let client: GitbayClient 15 private let client: GitbayClient
14 let repoPath: String 16 let repoPath: String
@@ -29,6 +31,43 @@ final class ReleaseListViewModel {
29 } 31 }
30 } 32 }
31 33
34 nonisolated struct Summary: Decodable, Sendable {
35 nonisolated struct Item: Decodable, Sendable {
36 let number: Int64
37 let title: String
38 let author: String
39 }
40 /// The tag the range starts after; empty when it covers all of history.
41 let since: String
42 let items: [Item]
43 }
44
45 /// `release notes <owner/name> <tag>`: one line per merge request
46 /// landed since the previous release, appended to `notes` the way the
47 /// web's Summarize changes appends them. The lines are the CLI's.
48 func summarize(tag: String, into notes: String) async -> String {
49 summaryNote = nil
50 working = true
51 defer { working = false }
52 do {
53 let summary = try await client.read(["release", "notes", repoPath, tag], as: Summary.self)
54 guard !summary.items.isEmpty else {
55 summaryNote = summary.since.isEmpty
56 ? "No merge requests landed on \(tag)."
57 : "No merge requests landed since \(summary.since)."
58 return notes
59 }
60 let lines = 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 {
64 summaryNote = error.userFacingMessage
65 } catch {
66 summaryNote = GitbayError.transport(error).userFacingMessage
67 }
68 return notes
69 }
70
32 /// `release create <owner/name> <tag> [--title <t>] [--file -]` — 71 /// `release create <owner/name> <tag> [--title <t>] [--file -]` —
33 /// the tag must already exist; the server's refusal says so. 72 /// the tag must already exist; the server's refusal says so.
34 func create(tag: String, title: String, notes: String) async -> Bool { 73 func create(tag: String, title: String, notes: String) async -> Bool {
gitbay/Views/Releases/ReleaseListView.swift +17 −1
@@ -105,13 +105,29 @@ private struct ReleaseCreateSheet: View {
105 TextField("Title (optional)", text: $title) 105 TextField("Title (optional)", text: $title)
106 .autocorrectionDisabled() 106 .autocorrectionDisabled()
107 } 107 }
108 Section("Notes") { 108 Section {
109 TextEditor(text: $notes) 109 TextEditor(text: $notes)
110 .frame(minHeight: 140) 110 .frame(minHeight: 140)
111 .autocorrectionDisabled() 111 .autocorrectionDisabled()
112 Button("Summarize changes") {
113 Task {
114 notes = await model.summarize(
115 tag: tag.trimmingCharacters(in: .whitespaces), into: notes)
116 }
117 }
118 .disabled(tag.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
119 .accessibilityIdentifier("release-summarize")
112 // release create sends no --format, and release notes 120 // release create sends no --format, and release notes
113 // render as markdown. 121 // render as markdown.
114 MarkupPreview(text: notes, format: "md", identifier: "release-notes-preview") 122 MarkupPreview(text: notes, format: "md", identifier: "release-notes-preview")
123 } header: {
124 Text("Notes")
125 } footer: {
126 if let note = model.summaryNote {
127 Text(note)
128 } else {
129 Text("Summarize changes adds a line for each merge request since the previous release.")
130 }
115 } 131 }
116 if let error = model.createError { 132 if let error = model.createError {
117 Section { 133 Section {
gitbayTests/ReleaseTests.swift +42
@@ -169,3 +169,45 @@ struct FeedReleaseDestinationTests {
169 #expect(event.destination == .release(repo: "krz/gitbay", tag: "v1.0.0")) 169 #expect(event.destination == .release(repo: "krz/gitbay", tag: "v1.0.0"))
170 } 170 }
171} 171}
172
173@MainActor
174struct ReleaseSummaryTests {
175
176 @Test func summarizingAppendsOneLinePerMergeRequestAfterWhatWasTyped() async throws {
177 let (client, stub) = try makeClient()
178 stub.enqueue(.init(status: 200, json: """
179 {"protocol_version":1,"data":{"since":"v1.0.0","items":[\
180 {"number":12,"title":"Add a thing","author":"cmc","merged_at":"2026-08-20T10:00:00.000Z"},\
181 {"number":14,"title":"Fix it","author":"dee","merged_at":"2026-08-21T10:00:00.000Z"}]},"exit_code":0}
182 """))
183 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay")
184
185 let notes = await model.summarize(tag: "v1.1.0", into: "Second cut.")
186
187 #expect(notes == "Second cut.\n\n- Add a thing (!12) by @cmc\n- Fix it (!14) by @dee\n")
188 #expect(stub.seen.first?.url.query() == "argv=release&argv=notes&argv=krz/gitbay&argv=v1.1.0")
189 #expect(model.summaryNote == nil)
190 }
191
192 @Test func anEmptyRangeLeavesTheNotesAndSaysWhy() async throws {
193 let (client, stub) = try makeClient()
194 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")
196
197 let notes = await model.summarize(tag: "v1.1.0", into: "typed")
198
199 #expect(notes == "typed")
200 #expect(model.summaryNote == "No merge requests landed since v1.0.0.")
201 }
202
203 @Test func aRefusalLeavesTheNotesAndShowsIt() async throws {
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}"#))
206 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay")
207
208 let notes = await model.summarize(tag: "v9", into: "")
209
210 #expect(notes == "")
211 #expect(model.summaryNote == #"no ref "v9" in krz/gitbay"#)
212 }
213}