gitbay/Releases/ReleaseViewModels.swift

v1.14.1
gitbay-ios/gitbay/Releases/ReleaseViewModels.swift history · blame · raw

189 lines · 6392 bytes

  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}