krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2: Hutch/Views/Builds/BuildDetailViewModel.swift · raw

  1import Foundation
  2
  3// MARK: - Response types
  4
  5private struct JobDetailResponse: Decodable, Sendable {
  6    let job: JobDetail
  7}
  8
  9private struct CancelResponse: Decodable, Sendable {
 10    let cancel: CancelResult
 11}
 12
 13private struct CancelResult: Decodable, Sendable {
 14    let id: Int
 15}
 16
 17private struct SubmitJobResponse: Decodable, Sendable {
 18    let submit: SubmittedJob
 19}
 20
 21private struct SubmittedJob: Decodable, Sendable {
 22    let id: Int
 23}
 24
 25// MARK: - View Model
 26
 27@Observable
 28@MainActor
 29final class BuildDetailViewModel {
 30
 31    let jobId: Int
 32    private let client: SRHTClient
 33
 34    private(set) var job: JobDetail?
 35    private(set) var isLoading = false
 36    private(set) var taskLogs: [String: String] = [:]
 37    private(set) var loadingTaskLogs: Set<String> = []
 38    private(set) var isCancelling = false
 39    private(set) var isRebuilding = false
 40    private(set) var isSubmittingEditedBuild = false
 41    var error: String?
 42
 43    init(jobId: Int, client: SRHTClient) {
 44        self.jobId = jobId
 45        self.client = client
 46    }
 47
 48    // MARK: - Queries
 49
 50    private static let detailQuery = """
 51    query job($id: Int!) {
 52        job(id: $id) {
 53            id
 54            created
 55            updated
 56            status
 57            note
 58            tags
 59            visibility
 60            image
 61            manifest
 62            tasks { name status log { fullURL } }
 63            log { fullURL }
 64            owner { canonicalName }
 65        }
 66    }
 67    """
 68
 69    private static let cancelMutation = """
 70    mutation cancel($id: Int!) {
 71        cancel(jobId: $id) {
 72            id
 73        }
 74    }
 75    """
 76
 77    private static let submitMutation = """
 78    mutation submit($manifest: String!, $tags: [String!], $note: String, $visibility: Visibility) {
 79        submit(manifest: $manifest, tags: $tags, note: $note, visibility: $visibility) {
 80            id
 81        }
 82    }
 83    """
 84
 85    private static let editableSubmitMutation = """
 86    mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
 87        submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
 88            id
 89        }
 90    }
 91    """
 92
 93    // MARK: - Public API
 94
 95    func loadJob() async {
 96        guard !isLoading else { return }
 97        isLoading = true
 98        error = nil
 99
100        do {
101            let result = try await client.execute(
102                service: .builds,
103                query: Self.detailQuery,
104                variables: ["id": jobId],
105                responseType: JobDetailResponse.self
106            )
107            var loadedJob = result.job
108            loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
109                task.withOrdinal(index)
110            }
111            job = loadedJob
112        } catch {
113            self.error = error.localizedDescription
114        }
115
116        isLoading = false
117    }
118
119    func loadTaskLog(task: BuildTask) async {
120        let cacheKey = task.logCacheKey
121        guard let log = task.log,
122              let logURL = URL(string: log.fullURL),
123              !loadingTaskLogs.contains(cacheKey),
124              taskLogs[cacheKey] == nil else { return }
125        loadingTaskLogs.insert(cacheKey)
126
127        do {
128            taskLogs[cacheKey] = try await client.fetchText(url: logURL)
129        } catch {
130            self.error = error.localizedDescription
131        }
132
133        loadingTaskLogs.remove(cacheKey)
134    }
135
136    func cancelJob() async {
137        guard let job, job.status.isCancellable, !isCancelling else { return }
138        isCancelling = true
139        error = nil
140
141        do {
142            _ = try await client.execute(
143                service: .builds,
144                query: Self.cancelMutation,
145                variables: ["id": jobId],
146                responseType: CancelResponse.self
147            )
148            // Reload job to get updated status.
149            await loadJob()
150        } catch {
151            self.error = error.localizedDescription
152        }
153
154        isCancelling = false
155    }
156
157    func rebuildJob() async -> Int? {
158        guard let job, let manifest = job.manifest, !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !isRebuilding else {
159            return nil
160        }
161
162        isRebuilding = true
163        error = nil
164        defer { isRebuilding = false }
165
166        var variables: [String: any Sendable] = [
167            "manifest": manifest.trimmingCharacters(in: .whitespacesAndNewlines)
168        ]
169        if !job.tags.isEmpty {
170            variables["tags"] = job.tags
171        }
172        if let note = job.note, !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
173            variables["note"] = note.trimmingCharacters(in: .whitespacesAndNewlines)
174        }
175        if let visibility = job.visibility {
176            variables["visibility"] = visibility.rawValue
177        }
178
179        do {
180            let result = try await client.execute(
181                service: .builds,
182                query: Self.submitMutation,
183                variables: variables,
184                responseType: SubmitJobResponse.self
185            )
186            return result.submit.id
187        } catch {
188            self.error = error.localizedDescription
189            return nil
190        }
191    }
192
193    func submitBuild(
194        manifest: String,
195        tags: [String],
196        note: String,
197        secrets: Bool,
198        execute: Bool,
199        visibility: Visibility
200    ) async -> Int? {
201        guard !isSubmittingEditedBuild else { return nil }
202
203        let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
204        guard !trimmedManifest.isEmpty else {
205            error = "Paste a build manifest."
206            return nil
207        }
208
209        isSubmittingEditedBuild = true
210        error = nil
211        defer { isSubmittingEditedBuild = false }
212
213        var variables: [String: any Sendable] = [
214            "manifest": trimmedManifest,
215            "secrets": secrets,
216            "execute": execute,
217            "visibility": visibility.rawValue
218        ]
219        if !tags.isEmpty {
220            variables["tags"] = tags
221        }
222        let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
223        if !trimmedNote.isEmpty {
224            variables["note"] = trimmedNote
225        }
226
227        do {
228            let result = try await client.execute(
229                service: .builds,
230                query: Self.editableSubmitMutation,
231                variables: variables,
232                responseType: SubmitJobResponse.self
233            )
234            return result.submit.id
235        } catch {
236            self.error = "Couldn’t submit the build. \(error.localizedDescription)"
237            return nil
238        }
239    }
240}