gitbay/Issues/IssueDetailViewModel.swift
123 lines · 4389 bytes
15 symbols in this file
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 /// React to the issue, or to one of its comments. A reaction the
77 /// account already gave is taken back.
78 func react(_ name: String, comment: Int64?) async {
79 let given = comment == nil
80 ? state.value?.reactions
81 : state.value?.comments?.first { $0.commentID == comment }?.reactions
82 await perform(Reaction.argv(noun: "issue", repo: repoPath, number: number, comment: comment,
83 name: name, remove: Reaction.remove(name, given: given)))
84 }
85
86 func close() async {
87 await perform(["issue", "close"] + ref)
88 }
89
90 func reopen() async {
91 await perform(["issue", "reopen"] + ref)
92 }
93
94 func addLabel(_ label: String) async {
95 await perform(["issue", "label"] + ref + ["--add", label])
96 }
97
98 func removeLabel(_ label: String) async {
99 await perform(["issue", "label"] + ref + ["--remove", label])
100 }
101
102 func addAssignee(_ user: String) async {
103 await perform(["issue", "assign"] + ref + ["--add", user])
104 }
105
106 func removeAssignee(_ user: String) async {
107 await perform(["issue", "assign"] + ref + ["--remove", user])
108 }
109
110 private func perform(_ argv: [String], stdin: String? = nil) async {
111 working = true
112 actionError = nil
113 defer { working = false }
114 do {
115 try await client.run(argv, stdin: stdin)
116 await load()
117 } catch let error as GitbayError {
118 actionError = error.userFacingMessage
119 } catch {
120 actionError = GitbayError.transport(error).userFacingMessage
121 }
122 }
123}