krz/hutch

an ios client for sourcehut

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

v2.10.2: 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    private static let autoRefreshInterval: Duration = .seconds(5)
 31
 32    let jobId: Int
 33    private let client: SRHTClient
 34
 35    private var autoRefreshTask: Task<Void, Never>?
 36    private(set) var job: JobDetail?
 37    private(set) var isLoading = false
 38    private(set) var buildLogText: String?
 39    private(set) var isLoadingBuildLog = false
 40    private(set) var taskLogs: [String: String] = [:]
 41    private(set) var loadingTaskLogs: Set<String> = []
 42    private(set) var failedTaskLogs: Set<String> = []
 43    private var taskLogRetryCounts: [String: Int] = [:]
 44    private(set) var isCancelling = false
 45    private(set) var isRebuilding = false
 46    private(set) var isSubmittingEditedBuild = false
 47    var error: String?
 48
 49    init(jobId: Int, client: SRHTClient) {
 50        self.jobId = jobId
 51        self.client = client
 52    }
 53
 54    // MARK: - Queries
 55
 56    private static let detailQuery = """
 57    query job($id: Int!) {
 58        job(id: $id) {
 59            id
 60            created
 61            updated
 62            status
 63            note
 64            tags
 65            visibility
 66            image
 67            manifest
 68            tasks { name status log { fullURL } }
 69            log { fullURL }
 70            owner { canonicalName }
 71        }
 72    }
 73    """
 74
 75    private static let cancelMutation = """
 76    mutation cancel($id: Int!) {
 77        cancel(jobId: $id) {
 78            id
 79        }
 80    }
 81    """
 82
 83    private static let submitMutation = """
 84    mutation submit($manifest: String!, $tags: [String!], $note: String, $visibility: Visibility) {
 85        submit(manifest: $manifest, tags: $tags, note: $note, visibility: $visibility) {
 86            id
 87        }
 88    }
 89    """
 90
 91    private static let editableSubmitMutation = """
 92    mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
 93        submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
 94            id
 95        }
 96    }
 97    """
 98
 99    // MARK: - Public API
100
101    func loadJob() async {
102        guard !isLoading else { return }
103        isLoading = true
104        error = nil
105
106        do {
107            let result = try await client.execute(
108                service: .builds,
109                query: Self.detailQuery,
110                variables: ["id": jobId],
111                responseType: JobDetailResponse.self
112            )
113            var loadedJob = result.job
114            loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
115                task.withOrdinal(index)
116            }
117            if job != loadedJob {
118                job = loadedJob
119            }
120
121            if loadedJob.status.isTerminal {
122                stopAutoRefresh()
123            }
124        } catch {
125            self.error = error.userFacingMessage
126        }
127
128        isLoading = false
129    }
130
131    func loadTaskLog(task: BuildTask) async {
132        let cacheKey = task.logCacheKey
133        let jobIsTerminal = job?.status.isTerminal ?? false
134
135        // Task-specific logs are only fetched after the job reaches a terminal
136        // state. While the build is active, the UI shows the shared live build log.
137        guard let log = task.log,
138              let logURL = URL(string: log.fullURL),
139              !loadingTaskLogs.contains(cacheKey) else { return }
140        guard jobIsTerminal else { return }
141        if jobIsTerminal, taskLogs[cacheKey] != nil { return }
142        failedTaskLogs.remove(cacheKey)
143        loadingTaskLogs.insert(cacheKey)
144
145        do {
146            taskLogs[cacheKey] = try await client.fetchText(url: logURL)
147            failedTaskLogs.remove(cacheKey)
148        } catch {
149            failedTaskLogs.insert(cacheKey)
150            self.error = error.userFacingMessage
151        }
152
153        loadingTaskLogs.remove(cacheKey)
154    }
155
156    func loadBuildLog() async {
157        guard let log = job?.log,
158              let logURL = URL(string: log.fullURL),
159              !isLoadingBuildLog else { return }
160
161        let jobIsTerminal = job?.status.isTerminal ?? false
162        if jobIsTerminal, buildLogText != nil { return }
163
164        isLoadingBuildLog = true
165
166        do {
167            buildLogText = try await client.fetchText(url: logURL)
168        } catch {
169            self.error = error.userFacingMessage
170        }
171
172        isLoadingBuildLog = false
173    }
174
175    func retryTaskLog(task: BuildTask) async {
176        let cacheKey = task.logCacheKey
177        failedTaskLogs.remove(cacheKey)
178        taskLogRetryCounts[cacheKey, default: 0] += 1
179        await loadTaskLog(task: task)
180    }
181
182    func displayedLogText(for task: BuildTask?) -> String? {
183        guard let task else { return nil }
184        guard let job else { return nil }
185
186        if !job.status.isTerminal {
187            return buildLogText
188        }
189
190        return taskLogs[task.logCacheKey] ?? buildLogText
191    }
192
193    func isShowingBuildLogFallback(for task: BuildTask?) -> Bool {
194        guard let task, let job else { return false }
195        if !job.status.isTerminal {
196            return buildLogText != nil
197        }
198
199        return taskLogs[task.logCacheKey] == nil && buildLogText != nil
200    }
201
202    func taskLogTrigger(for task: BuildTask?) -> String? {
203        guard let task, let logURL = task.log?.fullURL else { return nil }
204        let retryCount = taskLogRetryCounts[task.logCacheKey, default: 0]
205        let isTerminal = job?.status.isTerminal ?? false
206        return "\(logURL)#\(retryCount)#\(isTerminal)"
207    }
208
209    func cancelJob() async {
210        guard let job, job.status.isCancellable, !isCancelling else { return }
211        isCancelling = true
212        error = nil
213
214        do {
215            _ = try await client.execute(
216                service: .builds,
217                query: Self.cancelMutation,
218                variables: ["id": jobId],
219                responseType: CancelResponse.self
220            )
221            // Reload job to get updated status.
222            await loadJob()
223        } catch {
224            self.error = error.userFacingMessage
225        }
226
227        isCancelling = false
228    }
229
230    func rebuildJob() async -> Int? {
231        guard let job, let manifest = job.manifest, !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !isRebuilding else {
232            return nil
233        }
234
235        isRebuilding = true
236        error = nil
237        defer { isRebuilding = false }
238
239        var variables: [String: any Sendable] = [
240            "manifest": manifest.trimmingCharacters(in: .whitespacesAndNewlines)
241        ]
242        if !job.tags.isEmpty {
243            variables["tags"] = job.tags
244        }
245        if let note = job.note, !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
246            variables["note"] = note.trimmingCharacters(in: .whitespacesAndNewlines)
247        }
248        if let visibility = job.visibility {
249            variables["visibility"] = visibility.rawValue
250        }
251
252        do {
253            let result = try await client.execute(
254                service: .builds,
255                query: Self.submitMutation,
256                variables: variables,
257                responseType: SubmitJobResponse.self
258            )
259            return result.submit.id
260        } catch {
261            self.error = error.userFacingMessage
262            return nil
263        }
264    }
265
266    func submitBuild(
267        manifest: String,
268        tags: [String],
269        note: String,
270        secrets: Bool,
271        execute: Bool,
272        visibility: Visibility
273    ) async -> Int? {
274        guard !isSubmittingEditedBuild else { return nil }
275
276        let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
277        guard !trimmedManifest.isEmpty else {
278            error = "Paste a build manifest."
279            return nil
280        }
281
282        isSubmittingEditedBuild = true
283        error = nil
284        defer { isSubmittingEditedBuild = false }
285
286        var variables: [String: any Sendable] = [
287            "manifest": trimmedManifest,
288            "secrets": secrets,
289            "execute": execute,
290            "visibility": visibility.rawValue
291        ]
292        if !tags.isEmpty {
293            variables["tags"] = tags
294        }
295        let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
296        if !trimmedNote.isEmpty {
297            variables["note"] = trimmedNote
298        }
299
300        do {
301            let result = try await client.execute(
302                service: .builds,
303                query: Self.editableSubmitMutation,
304                variables: variables,
305                responseType: SubmitJobResponse.self
306            )
307            return result.submit.id
308        } catch {
309            self.error = "Couldn’t submit the build. \(error.userFacingMessage)"
310            return nil
311        }
312    }
313
314    func startAutoRefresh() {
315        guard autoRefreshTask == nil else { return }
316        guard shouldAutoRefresh else { return }
317
318        print("Build auto-refresh started")
319
320        autoRefreshTask = Task { [weak self] in
321            while !Task.isCancelled {
322                do {
323                    try await Task.sleep(for: Self.autoRefreshInterval)
324                } catch {
325                    break
326                }
327
328                guard let self else { return }
329                await self.performAutoRefreshTick()
330            }
331        }
332    }
333
334    func stopAutoRefresh() {
335        guard let autoRefreshTask else { return }
336
337        autoRefreshTask.cancel()
338        self.autoRefreshTask = nil
339        print("Build auto-refresh stopped")
340    }
341
342    private var shouldAutoRefresh: Bool {
343        guard let job else { return true }
344        return !job.status.isTerminal
345    }
346
347    private func performAutoRefreshTick() async {
348        guard !Task.isCancelled, shouldAutoRefresh, !isLoading else {
349            if !shouldAutoRefresh {
350                stopAutoRefresh()
351            }
352            return
353        }
354
355        print("Build refresh tick")
356        await loadJob()
357        await loadBuildLog()
358    }
359}