import Foundation import Observation /// One issue and everything triage needs: comment, close, reopen, labels, /// assignees. Writes reload; refusals surface verbatim. @Observable @MainActor final class IssueDetailViewModel { private(set) var state: LoadState = .loading private(set) var actionError: String? private(set) var working = false /// Open milestones for the picker; fetched on first use. private(set) var availableMilestones: [Milestone]? private(set) var colors = LabelColors() /// Labels rarely change mid-session, and every write reloads via /// `perform(argv:)`: fetch them once per view model, not on every reload. private var labelsLoaded = false private let client: GitbayClient let repoPath: String let number: Int64 init(client: GitbayClient, repoPath: String, number: Int64) { self.client = client self.repoPath = repoPath self.number = number } private var ref: [String] { [repoPath, String(number)] } func load() async { do { state = .loaded(try await client.read(["issue", "show"] + ref, as: IssueDetail.self)) } catch { state = .from(error) return } guard !labelsLoaded else { return } labelsLoaded = true colors = LabelColors(await labelList()) } private func labelList() async -> [IssueLabel] { (try? await client.readList(["label", "list", repoPath], of: IssueLabel.self)) ?? [] } /// Both fields are always sent: the title as given, the body over /// stdin — an empty body clears it, matching the CLI. `format` is /// appended only when given; omitting it lets the server default. func edit(title: String, body: String, format: String? = nil) async { var argv = ["issue", "edit"] + ref + ["--title", title, "--file", "-"] if let format { argv.append(contentsOf: ["--format", format]) } await perform(argv, stdin: body) } /// nil clears the milestone; the command spells that "none". func setMilestone(_ title: String?) async { await perform(["issue", "milestone"] + ref + [title ?? "none"]) } func loadMilestones() async { guard availableMilestones == nil else { return } availableMilestones = (try? await client.readList( ["milestone", "list", repoPath, "--state", "open"], of: Milestone.self )) ?? [] } func comment(_ text: String, format: String? = nil) async { // stdin is only read when argv carries --file -. var argv = ["issue", "comment"] + ref + ["--file", "-"] if let format { argv.append(contentsOf: ["--format", format]) } await perform(argv, stdin: text) } /// React to the issue, or to one of its comments. A reaction the /// account already gave is taken back. func react(_ name: String, comment: Int64?) async { let given = comment == nil ? state.value?.reactions : state.value?.comments?.first { $0.commentID == comment }?.reactions await perform(Reaction.argv(noun: "issue", repo: repoPath, number: number, comment: comment, name: name, remove: Reaction.remove(name, given: given))) } func close() async { await perform(["issue", "close"] + ref) } func reopen() async { await perform(["issue", "reopen"] + ref) } func addLabel(_ label: String) async { await perform(["issue", "label"] + ref + ["--add", label]) } func removeLabel(_ label: String) async { await perform(["issue", "label"] + ref + ["--remove", label]) } func addAssignee(_ user: String) async { await perform(["issue", "assign"] + ref + ["--add", user]) } func removeAssignee(_ user: String) async { await perform(["issue", "assign"] + ref + ["--remove", user]) } private func perform(_ argv: [String], stdin: String? = nil) async { working = true actionError = nil defer { working = false } do { try await client.run(argv, stdin: stdin) await load() } catch let error as GitbayError { actionError = error.userFacingMessage } catch { actionError = GitbayError.transport(error).userFacingMessage } } }