Summarize changes when drafting a release !160
3 files changed, +105 −1
Layout: unified · split
gitbay/Releases/ReleaseViewModels.swift +45
| @@ -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,49 @@ 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, 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 | |||
| 32 | /// `release create <owner/name> <tag> [--title <t>] [--file -]` — | 77 | /// `release create <owner/name> <tag> [--title <t>] [--file -]` — |
| 33 | /// the tag must already exist; the server's refusal says so. | 78 | /// the tag must already exist; the server's refusal says so. |
| 34 | func create(tag: String, title: String, notes: String) async -> Bool { | 79 | 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 { | |||
| 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 | // 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") | ||
| 112 | // release create sends no --format, and release notes | 123 | // release create sends no --format, and release notes |
| 113 | // render as markdown. | 124 | // render as markdown. |
| 114 | MarkupPreview(text: notes, format: "md", identifier: "release-notes-preview") | 125 | 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 | } | ||
| 115 | } | 134 | } |
| 116 | if let error = model.createError { | 135 | if let error = model.createError { |
| 117 | Section { | 136 | Section { |
gitbayTests/ReleaseTests.swift +40
| @@ -169,3 +169,43 @@ 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 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 | } | ||