gitbay/Releases/ReleaseViewModels.swift
189 lines · 6392 bytes
11 symbols in this file
1import Foundation
2import Observation
3
4/// `release list <owner/name>`.
5@Observable
6@MainActor
7final class ReleaseListViewModel {
8
9 private(set) var state: LoadState<[Release]> = .loading
10 private(set) var createError: String?
11 private(set) var working = false
12 /// Why Summarize changes added nothing: an empty range, or a refusal.
13 private(set) var summaryNote: String?
14
15 private let client: GitbayClient
16 let repoPath: String
17
18 init(client: GitbayClient, repoPath: String) {
19 self.client = client
20 self.repoPath = repoPath
21 }
22
23 func load() async {
24 do {
25 let releases = try await client.readList(["release", "list", repoPath], of: Release.self)
26 state = releases.isEmpty
27 ? .empty("No releases. Tag a commit, push the tag, then release it.")
28 : .loaded(releases)
29 } catch {
30 state = .from(error)
31 }
32 }
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
77 /// `release create <owner/name> <tag> [--title <t>] [--file -]` —
78 /// the tag must already exist; the server's refusal says so.
79 func create(tag: String, title: String, notes: String) async -> Bool {
80 working = true
81 createError = nil
82 defer { working = false }
83 do {
84 var argv = ["release", "create", repoPath, tag]
85 if !title.isEmpty { argv.append(contentsOf: ["--title", title]) }
86 var stdin: String?
87 if !notes.isEmpty {
88 argv.append(contentsOf: ["--file", "-"])
89 stdin = notes
90 }
91 try await client.run(argv, stdin: stdin)
92 await load()
93 return true
94 } catch let error as GitbayError {
95 createError = error.userFacingMessage
96 } catch {
97 createError = GitbayError.transport(error).userFacingMessage
98 }
99 return false
100 }
101}
102
103/// One release: `release show`, plus title/notes editing.
104@Observable
105@MainActor
106final class ReleaseDetailViewModel {
107
108 private(set) var state: LoadState<Release> = .loading
109 private(set) var actionError: String?
110 private(set) var working = false
111
112 private let client: GitbayClient
113 let repoPath: String
114 let tag: String
115
116 init(client: GitbayClient, repoPath: String, tag: String) {
117 self.client = client
118 self.repoPath = repoPath
119 self.tag = tag
120 }
121
122 func load() async {
123 do {
124 state = .loaded(try await client.read(
125 ["release", "show", repoPath, tag], as: Release.self))
126 } catch {
127 state = .from(error)
128 }
129 }
130
131 func edit(title: String, notes: String) async {
132 working = true
133 actionError = nil
134 defer { working = false }
135 do {
136 try await client.run(
137 ["release", "edit", repoPath, tag, "--title", title, "--file", "-"],
138 stdin: notes
139 )
140 await load()
141 } catch let error as GitbayError {
142 actionError = error.userFacingMessage
143 } catch {
144 actionError = GitbayError.transport(error).userFacingMessage
145 }
146 }
147
148 /// `release asset remove <owner/name> <tag> <filename>`.
149 func removeAsset(named name: String) async {
150 working = true
151 actionError = nil
152 defer { working = false }
153 do {
154 try await client.run(["release", "asset", "remove", repoPath, tag, name])
155 await load()
156 } catch let error as GitbayError {
157 actionError = error.userFacingMessage
158 } catch {
159 actionError = GitbayError.transport(error).userFacingMessage
160 }
161 }
162
163 /// `release delete <owner/name> <tag> --yes` — `--yes` is required by
164 /// the command, not optional; omitting it is a usage error.
165 func delete() async -> Bool {
166 working = true
167 actionError = nil
168 defer { working = false }
169 do {
170 try await client.run(["release", "delete", repoPath, tag, "--yes"])
171 return true
172 } catch let error as GitbayError {
173 actionError = error.userFacingMessage
174 } catch {
175 actionError = GitbayError.transport(error).userFacingMessage
176 }
177 return false
178 }
179
180 /// Where a browser downloads this asset — the web's own download
181 /// route; the JSON surface has no binary-safe asset read.
182 func downloadURL(for asset: Release.Asset) -> URL {
183 client.instance.baseURL
184 .appending(path: repoPath)
185 .appending(path: "releases/download")
186 .appending(path: tag)
187 .appending(path: asset.name)
188 }
189}