gitbay/Issues/IssueDetailViewModel.swift
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}