gitbay/Issues/IssueDetailViewModel.swift

v1.1.0
gitbay-ios/gitbay/Issues/IssueDetailViewModel.swift history · blame · raw

113 lines · 3863 bytes

  1import Foundation
  2import Observation
  3
  4/// One issue and everything triage needs: comment, close, reopen, labels,
  5/// assignees. Writes reload; refusals surface verbatim.
  6@Observable
  7@MainActor
  8final class IssueDetailViewModel {
  9
 10    private(set) var state: LoadState<IssueDetail> = .loading
 11    private(set) var actionError: String?
 12    private(set) var working = false
 13    /// Open milestones for the picker; fetched on first use.
 14    private(set) var availableMilestones: [Milestone]?
 15    private(set) var colors = LabelColors()
 16    /// Labels rarely change mid-session, and every write reloads via
 17    /// `perform(argv:)`: fetch them once per view model, not on every reload.
 18    private var labelsLoaded = false
 19
 20    private let client: GitbayClient
 21    let repoPath: String
 22    let number: Int64
 23
 24    init(client: GitbayClient, repoPath: String, number: Int64) {
 25        self.client = client
 26        self.repoPath = repoPath
 27        self.number = number
 28    }
 29
 30    private var ref: [String] { [repoPath, String(number)] }
 31
 32    func load() async {
 33        do {
 34            state = .loaded(try await client.read(["issue", "show"] + ref, as: IssueDetail.self))
 35        } catch {
 36            state = .from(error)
 37            return
 38        }
 39        guard !labelsLoaded else { return }
 40        labelsLoaded = true
 41        colors = LabelColors(await labelList())
 42    }
 43
 44    private func labelList() async -> [IssueLabel] {
 45        (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? []
 46    }
 47
 48    /// Both fields are always sent: the title as given, the body over
 49    /// stdin — an empty body clears it, matching the CLI. `format` is
 50    /// appended only when given; omitting it lets the server default.
 51    func edit(title: String, body: String, format: String? = nil) async {
 52        var argv = ["issue", "edit"] + ref + ["--title", title, "--file", "-"]
 53        if let format { argv.append(contentsOf: ["--format", format]) }
 54        await perform(argv, stdin: body)
 55    }
 56
 57    /// nil clears the milestone; the command spells that "none".
 58    func setMilestone(_ title: String?) async {
 59        await perform(["issue", "milestone"] + ref + [title ?? "none"])
 60    }
 61
 62    func loadMilestones() async {
 63        guard availableMilestones == nil else { return }
 64        availableMilestones = (try? await client.readList(
 65            ["milestone", "list", repoPath, "--state", "open"], of: Milestone.self
 66        )) ?? []
 67    }
 68
 69    func comment(_ text: String, format: String? = nil) async {
 70        // stdin is only read when argv carries --file -.
 71        var argv = ["issue", "comment"] + ref + ["--file", "-"]
 72        if let format { argv.append(contentsOf: ["--format", format]) }
 73        await perform(argv, stdin: text)
 74    }
 75
 76    func close() async {
 77        await perform(["issue", "close"] + ref)
 78    }
 79
 80    func reopen() async {
 81        await perform(["issue", "reopen"] + ref)
 82    }
 83
 84    func addLabel(_ label: String) async {
 85        await perform(["issue", "label"] + ref + ["--add", label])
 86    }
 87
 88    func removeLabel(_ label: String) async {
 89        await perform(["issue", "label"] + ref + ["--remove", label])
 90    }
 91
 92    func addAssignee(_ user: String) async {
 93        await perform(["issue", "assign"] + ref + ["--add", user])
 94    }
 95
 96    func removeAssignee(_ user: String) async {
 97        await perform(["issue", "assign"] + ref + ["--remove", user])
 98    }
 99
100    private func perform(_ argv: [String], stdin: String? = nil) async {
101        working = true
102        actionError = nil
103        defer { working = false }
104        do {
105            try await client.run(argv, stdin: stdin)
106            await load()
107        } catch let error as GitbayError {
108            actionError = error.userFacingMessage
109        } catch {
110            actionError = GitbayError.transport(error).userFacingMessage
111        }
112    }
113}