krz/hutch

an ios client for sourcehut

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

v3.1.7: 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    private static func cacheKey(for jobId: Int) -> String { "build.detail.\(jobId)" }
 32
 33    let jobId: Int
 34    private let client: SRHTClient
 35
 36    private var autoRefreshTask: Task<Void, Never>?
 37    private(set) var job: JobDetail?
 38    private(set) var isLoading = false
 39    private(set) var buildLogText: String?
 40    private(set) var isLoadingBuildLog = false
 41    private(set) var taskLogs: [String: String] = [:]
 42    private(set) var loadingTaskLogs: Set<String> = []
 43    private(set) var failedTaskLogs: Set<String> = []
 44    private var taskLogRetryCounts: [String: Int] = [:]
 45    private(set) var isCancelling = false
 46    private(set) var isRebuilding = false
 47    private(set) var isSubmittingEditedBuild = false
 48    private(set) var rawJobResponse: String?
 49    var error: String?
 50    /// Transient error shown for action failures (cancel, rebuild, submit).
 51    /// Separate from `error` so auto-refresh doesn't immediately clear it.
 52    private(set) var actionError: String?
 53    private var actionErrorDismissTask: Task<Void, Never>?
 54
 55    init(jobId: Int, client: SRHTClient) {
 56        self.jobId = jobId
 57        self.client = client
 58    }
 59
 60    func dismissActionError() {
 61        actionError = nil
 62        actionErrorDismissTask?.cancel()
 63        actionErrorDismissTask = nil
 64    }
 65
 66    private func setActionError(_ message: String) {
 67        actionError = message
 68        actionErrorDismissTask?.cancel()
 69        actionErrorDismissTask = Task {
 70            try? await Task.sleep(for: .seconds(5))
 71            guard !Task.isCancelled else { return }
 72            actionError = nil
 73        }
 74    }
 75
 76    // MARK: - Queries
 77
 78    private static let detailQuery = """
 79    query job($id: Int!) {
 80        job(id: $id) {
 81            id
 82            created
 83            updated
 84            status
 85            note
 86            tags
 87            visibility
 88            image
 89            manifest
 90            tasks { name status log { fullURL } }
 91            artifacts { id created path size url }
 92            log { fullURL }
 93            owner { canonicalName }
 94        }
 95    }
 96    """
 97
 98    private static let cancelMutation = """
 99    mutation cancel($id: Int!) {
100        cancel(jobId: $id) {
101            id
102        }
103    }
104    """
105
106    private static let submitMutation = """
107    mutation submit($manifest: String!, $tags: [String!], $note: String, $visibility: Visibility) {
108        submit(manifest: $manifest, tags: $tags, note: $note, visibility: $visibility) {
109            id
110        }
111    }
112    """
113
114    private static let editableSubmitMutation = """
115    mutation submit($manifest: String!, $tags: [String!], $note: String, $secrets: Boolean, $execute: Boolean, $visibility: Visibility) {
116        submit(manifest: $manifest, tags: $tags, note: $note, secrets: $secrets, execute: $execute, visibility: $visibility) {
117            id
118        }
119    }
120    """
121
122    // MARK: - Public API
123
124    func loadJob() async {
125        guard !isLoading else { return }
126        isLoading = true
127        error = nil
128        rawJobResponse = nil
129
130        do {
131            let result = try await client.execute(
132                service: .builds,
133                query: Self.detailQuery,
134                variables: ["id": jobId],
135                responseType: JobDetailResponse.self
136            )
137            var loadedJob = result.job
138            loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
139                task.withOrdinal(index)
140            }
141            if job != loadedJob {
142                job = loadedJob
143            }
144
145            if loadedJob.status.isTerminal {
146                stopAutoRefresh()
147            }
148        } catch {
149            self.error = error.userFacingMessage
150        }
151
152        isLoading = false
153    }
154
155    func loadJobWithDebugCapture() async {
156        guard !isLoading else { return }
157        isLoading = true
158        error = nil
159
160        do {
161            let cacheKey = Self.cacheKey(for: jobId)
162            let result = try await client.executeAndCache(
163                service: .builds,
164                query: Self.detailQuery,
165                variables: ["id": jobId],
166                responseType: JobDetailResponse.self,
167                cacheKey: cacheKey
168            )
169            rawJobResponse = client.responseCache.get(forKey: cacheKey)
170                .flatMap { String(data: $0, encoding: .utf8) }
171            var loadedJob = result.job
172            loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
173                task.withOrdinal(index)
174            }
175            if job != loadedJob {
176                job = loadedJob
177            }
178
179            if loadedJob.status.isTerminal {
180                stopAutoRefresh()
181            }
182        } catch {
183            self.error = error.userFacingMessage
184        }
185
186        isLoading = false
187    }
188
189    func loadTaskLog(task: BuildTask) async {
190        let cacheKey = task.logCacheKey
191        let jobIsTerminal = job?.status.isTerminal ?? false
192
193        // Task-specific logs are only fetched after the job reaches a terminal
194        // state. While the build is active, the UI shows the shared live build log.
195        guard let log = task.log,
196              let logURL = URL(string: log.fullURL),
197              !loadingTaskLogs.contains(cacheKey) else { return }
198        guard jobIsTerminal else { return }
199        if jobIsTerminal, taskLogs[cacheKey] != nil { return }
200        failedTaskLogs.remove(cacheKey)
201        loadingTaskLogs.insert(cacheKey)
202
203        do {
204            taskLogs[cacheKey] = try await client.fetchText(url: logURL)
205            failedTaskLogs.remove(cacheKey)
206        } catch {
207            failedTaskLogs.insert(cacheKey)
208            self.error = error.userFacingMessage
209        }
210
211        loadingTaskLogs.remove(cacheKey)
212    }
213
214    func loadBuildLog() async {
215        guard let log = job?.log,
216              let logURL = URL(string: log.fullURL),
217              !isLoadingBuildLog else { return }
218
219        let jobIsTerminal = job?.status.isTerminal ?? false
220        if jobIsTerminal, buildLogText != nil { return }
221
222        isLoadingBuildLog = true
223
224        do {
225            buildLogText = try await client.fetchText(url: logURL)
226        } catch {
227            self.error = error.userFacingMessage
228        }
229
230        isLoadingBuildLog = false
231    }
232
233    func retryTaskLog(task: BuildTask) async {
234        let cacheKey = task.logCacheKey
235        failedTaskLogs.remove(cacheKey)
236        taskLogRetryCounts[cacheKey, default: 0] += 1
237        await loadTaskLog(task: task)
238    }
239
240    func displayedLogText(for task: BuildTask?) -> String? {
241        guard let task else { return nil }
242        guard let job else { return nil }
243
244        if !job.status.isTerminal {
245            return buildLogText
246        }
247
248        return taskLogs[task.logCacheKey] ?? buildLogText
249    }
250
251    func isShowingBuildLogFallback(for task: BuildTask?) -> Bool {
252        guard let task, let job else { return false }
253        if !job.status.isTerminal {
254            return buildLogText != nil
255        }
256
257        return taskLogs[task.logCacheKey] == nil && buildLogText != nil
258    }
259
260    func taskLogTrigger(for task: BuildTask?) -> String? {
261        guard let task, let logURL = task.log?.fullURL else { return nil }
262        let retryCount = taskLogRetryCounts[task.logCacheKey, default: 0]
263        let isTerminal = job?.status.isTerminal ?? false
264        return "\(logURL)#\(retryCount)#\(isTerminal)"
265    }
266
267    func cancelJob() async {
268        guard let job, job.status.isCancellable, !isCancelling else { return }
269        let originalJob = job
270        isCancelling = true
271
272        // Optimistic update: show cancelled status immediately.
273        self.job = JobDetail(
274            id: job.id, created: job.created, updated: job.updated,
275            status: .cancelled, note: job.note, tags: job.tags,
276            visibility: job.visibility, image: job.image,
277            manifest: job.manifest, tasks: job.tasks,
278            artifacts: job.artifacts,
279            log: job.log, owner: job.owner
280        )
281        stopAutoRefresh()
282
283        do {
284            _ = try await client.execute(
285                service: .builds,
286                query: Self.cancelMutation,
287                variables: ["id": jobId],
288                responseType: CancelResponse.self
289            )
290            await reloadJobPreservingDebugState()
291        } catch {
292            // Revert optimistic update on failure.
293            self.job = originalJob
294            if !originalJob.status.isTerminal {
295                startAutoRefresh()
296            }
297            setActionError("Couldn't cancel build. \(error.userFacingMessage)")
298        }
299
300        isCancelling = false
301    }
302
303    func rebuildJob() async -> Int? {
304        guard let job, let manifest = job.manifest, !manifest.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !isRebuilding else {
305            return nil
306        }
307
308        isRebuilding = true
309        dismissActionError()
310        defer { isRebuilding = false }
311
312        var variables: [String: any Sendable] = [
313            "manifest": manifest.trimmingCharacters(in: .whitespacesAndNewlines)
314        ]
315        if !job.tags.isEmpty {
316            variables["tags"] = job.tags
317        }
318        if let note = job.note, !note.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
319            variables["note"] = note.trimmingCharacters(in: .whitespacesAndNewlines)
320        }
321        if let visibility = job.visibility {
322            variables["visibility"] = visibility.rawValue
323        }
324
325        do {
326            let result = try await client.execute(
327                service: .builds,
328                query: Self.submitMutation,
329                variables: variables,
330                responseType: SubmitJobResponse.self
331            )
332            return result.submit.id
333        } catch {
334            setActionError("Couldn't rebuild. \(error.userFacingMessage)")
335            return nil
336        }
337    }
338
339    func submitBuild(
340        manifest: String,
341        tags: [String],
342        note: String,
343        secrets: Bool,
344        execute: Bool,
345        visibility: Visibility
346    ) async -> Int? {
347        guard !isSubmittingEditedBuild else { return nil }
348
349        let trimmedManifest = manifest.trimmingCharacters(in: .whitespacesAndNewlines)
350        guard !trimmedManifest.isEmpty else {
351            setActionError("Paste a build manifest.")
352            return nil
353        }
354
355        isSubmittingEditedBuild = true
356        dismissActionError()
357        defer { isSubmittingEditedBuild = false }
358
359        var variables: [String: any Sendable] = [
360            "manifest": trimmedManifest,
361            "secrets": secrets,
362            "execute": execute,
363            "visibility": visibility.rawValue
364        ]
365        if !tags.isEmpty {
366            variables["tags"] = tags
367        }
368        let trimmedNote = note.trimmingCharacters(in: .whitespacesAndNewlines)
369        if !trimmedNote.isEmpty {
370            variables["note"] = trimmedNote
371        }
372
373        do {
374            let result = try await client.execute(
375                service: .builds,
376                query: Self.editableSubmitMutation,
377                variables: variables,
378                responseType: SubmitJobResponse.self
379            )
380            return result.submit.id
381        } catch {
382            setActionError("Couldn’t submit the build. \(error.userFacingMessage)")
383            return nil
384        }
385    }
386
387    func startAutoRefresh() {
388        guard autoRefreshTask == nil else { return }
389        guard shouldAutoRefresh else { return }
390
391        autoRefreshTask = Task { [weak self] in
392            while !Task.isCancelled {
393                do {
394                    try await Task.sleep(for: Self.autoRefreshInterval)
395                } catch {
396                    break
397                }
398
399                guard let self else { return }
400                await self.performAutoRefreshTick()
401            }
402        }
403    }
404
405    func stopAutoRefresh() {
406        guard let autoRefreshTask else { return }
407
408        autoRefreshTask.cancel()
409        self.autoRefreshTask = nil
410    }
411
412    private func reloadJobPreservingDebugState() async {
413        if rawJobResponse != nil {
414            await loadJobWithDebugCapture()
415        } else {
416            await loadJob()
417        }
418    }
419
420    private var shouldAutoRefresh: Bool {
421        guard let job else { return true }
422        return !job.status.isTerminal
423    }
424
425    private func performAutoRefreshTick() async {
426        guard !Task.isCancelled, shouldAutoRefresh, !isLoading else {
427            if !shouldAutoRefresh {
428                stopAutoRefresh()
429            }
430            return
431        }
432
433        await reloadJobPreservingDebugState()
434        await loadBuildLog()
435    }
436}