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 |
| |
174 | struct 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 | } |