Summarize changes when drafting a release !160

merged merged by cmc on 2026-10-14 06:11 UTC · krz/gitbay-ios:release-summary into main

3 files changed, +105 −1

Layout: unified · split

gitbay/Releases/ReleaseViewModels.swift +45
@@ -9,6 +9,8 @@ final class ReleaseListViewModel {
99 private(set) var state: LoadState<[Release]> = .loading
1010 private(set) var createError: String?
1111 private(set) var working = false
12 /// Why Summarize changes added nothing: an empty range, or a refusal.
13 private(set) var summaryNote: String?
1214
1315 private let client: GitbayClient
1416 let repoPath: String
@@ -29,6 +31,49 @@ final class ReleaseListViewModel {
2931 }
3032 }
3133
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, in the CLI's form. nil when
47 /// there are none or the server refused, with `summaryNote` saying
48 /// which.
49 func summary(tag: String) async -> String? {
50 summaryNote = nil
51 working = true
52 defer { working = false }
53 do {
54 let summary = try await client.read(["release", "notes", repoPath, tag], as: Summary.self)
55 guard !summary.items.isEmpty else {
56 summaryNote = summary.since.isEmpty
57 ? "No merge requests landed on \(tag)."
58 : "No merge requests landed since \(summary.since)."
59 return nil
60 }
61 return summary.items.map { "- \($0.title) (!\($0.number)) by @\($0.author)\n" }.joined()
62 } catch let error as GitbayError {
63 summaryNote = error.userFacingMessage
64 } catch {
65 summaryNote = GitbayError.transport(error).userFacingMessage
66 }
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
75 }
76
3277 /// `release create <owner/name> <tag> [--title <t>] [--file -]` —
3378 /// the tag must already exist; the server's refusal says so.
3479 func create(tag: String, title: String, notes: String) async -> Bool {
gitbay/Views/Releases/ReleaseListView.swift +20 −1
@@ -105,13 +105,32 @@ private struct ReleaseCreateSheet: View {
105105 TextField("Title (optional)", text: $title)
106106 .autocorrectionDisabled()
107107 }
108 Section("Notes") {
108 Section {
109109 TextEditor(text: $notes)
110110 .frame(minHeight: 140)
111111 .autocorrectionDisabled()
112 Button("Summarize changes") {
113 Task {
114 // Appended to the notes as they are when the lines
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 }
119 }
120 }
121 .disabled(tag.trimmingCharacters(in: .whitespaces).isEmpty || model.working)
122 .accessibilityIdentifier("release-summarize")
112123 // release create sends no --format, and release notes
113124 // render as markdown.
114125 MarkupPreview(text: notes, format: "md", identifier: "release-notes-preview")
126 } header: {
127 Text("Notes")
128 } footer: {
129 if let note = model.summaryNote {
130 Text(note)
131 } else {
132 Text("Summarize changes adds a line for each merge request since the previous release.")
133 }
115134 }
116135 if let error = model.createError {
117136 Section {
gitbayTests/ReleaseTests.swift +40
@@ -169,3 +169,43 @@ struct FeedReleaseDestinationTests {
169169 #expect(event.destination == .release(repo: "krz/gitbay", tag: "v1.0.0"))
170170 }
171171}
172
173@MainActor
174struct ReleaseSummaryTests {
175
176 @Test func theSummaryIsOneLinePerMergeRequestAppendedAfterWhatWasTyped() 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 lines = try #require(await model.summary(tag: "v1.1.0"))
186
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)
190 #expect(stub.seen.first?.url.query() == "argv=release&argv=notes&argv=krz/gitbay&argv=v1.1.0")
191 #expect(model.summaryNote == nil)
192 }
193
194 @Test func anEmptyRangeGivesNoLinesAndSaysWhy() async throws {
195 let (client, stub) = try makeClient()
196 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"since":"v1.0.0","items":[]},"exit_code":0}"#))
197 let model = ReleaseListViewModel(client: client, repoPath: "krz/gitbay")
198
199 #expect(await model.summary(tag: "v1.1.0") == nil)
200 #expect(model.summaryNote == "No merge requests landed since v1.0.0.")
201 }
202
203 @Test func aRefusalGivesNoLinesAndShowsIt() 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 #expect(await model.summary(tag: "v9") == nil)
209 #expect(model.summaryNote == #"no ref "v9" in krz/gitbay"#)
210 }
211}