import Foundation import Observation /// `release list `. @Observable @MainActor final class ReleaseListViewModel { private(set) var state: LoadState<[Release]> = .loading private(set) var createError: String? private(set) var working = false /// Why Summarize changes added nothing: an empty range, or a refusal. private(set) var summaryNote: String? private let client: GitbayClient let repoPath: String init(client: GitbayClient, repoPath: String) { self.client = client self.repoPath = repoPath } func load() async { do { let releases = try await client.readList(["release", "list", repoPath], of: Release.self) state = releases.isEmpty ? .empty("No releases. Tag a commit, push the tag, then release it.") : .loaded(releases) } catch { state = .from(error) } } nonisolated struct Summary: Decodable, Sendable { nonisolated struct Item: Decodable, Sendable { let number: Int64 let title: String let author: String } /// The tag the range starts after; empty when it covers all of history. let since: String let items: [Item] } /// `release notes `: one line per merge request /// landed since the previous release, in the CLI's form. nil when /// there are none or the server refused, with `summaryNote` saying /// which. func summary(tag: String) async -> String? { summaryNote = nil working = true defer { working = false } do { let summary = try await client.read(["release", "notes", repoPath, tag], as: Summary.self) guard !summary.items.isEmpty else { summaryNote = summary.since.isEmpty ? "No merge requests landed on \(tag)." : "No merge requests landed since \(summary.since)." return nil } return summary.items.map { "- \($0.title) (!\($0.number)) by @\($0.author)\n" }.joined() } catch let error as GitbayError { summaryNote = error.userFacingMessage } catch { summaryNote = GitbayError.transport(error).userFacingMessage } return nil } /// The notes with `lines` after them, as the web's Summarize changes /// appends them. nonisolated static func appending(_ lines: String, to notes: String) -> String { let typed = notes.trimmingCharacters(in: .whitespacesAndNewlines) return typed.isEmpty ? lines : typed + "\n\n" + lines } /// `release create [--title ] [--file -]` — /// the tag must already exist; the server's refusal says so. func create(tag: String, title: String, notes: String) async -> Bool { working = true createError = nil defer { working = false } do { var argv = ["release", "create", repoPath, tag] if !title.isEmpty { argv.append(contentsOf: ["--title", title]) } var stdin: String? if !notes.isEmpty { argv.append(contentsOf: ["--file", "-"]) stdin = notes } try await client.run(argv, stdin: stdin) await load() return true } catch let error as GitbayError { createError = error.userFacingMessage } catch { createError = GitbayError.transport(error).userFacingMessage } return false } } /// One release: `release show`, plus title/notes editing. @Observable @MainActor final class ReleaseDetailViewModel { private(set) var state: LoadState = .loading private(set) var actionError: String? private(set) var working = false private let client: GitbayClient let repoPath: String let tag: String init(client: GitbayClient, repoPath: String, tag: String) { self.client = client self.repoPath = repoPath self.tag = tag } func load() async { do { state = .loaded(try await client.read( ["release", "show", repoPath, tag], as: Release.self)) } catch { state = .from(error) } } func edit(title: String, notes: String) async { working = true actionError = nil defer { working = false } do { try await client.run( ["release", "edit", repoPath, tag, "--title", title, "--file", "-"], stdin: notes ) await load() } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } /// `release asset remove `. func removeAsset(named name: String) async { working = true actionError = nil defer { working = false } do { try await client.run(["release", "asset", "remove", repoPath, tag, name]) await load() } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } /// `release delete --yes` — `--yes` is required by /// the command, not optional; omitting it is a usage error. func delete() async -> Bool { working = true actionError = nil defer { working = false } do { try await client.run(["release", "delete", repoPath, tag, "--yes"]) return true } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } return false } /// Where a browser downloads this asset — the web's own download /// route; the JSON surface has no binary-safe asset read. func downloadURL(for asset: Release.Asset) -> URL { client.instance.baseURL .appending(path: repoPath) .appending(path: "releases/download") .appending(path: tag) .appending(path: asset.name) } }