import Foundation /// A single job within a workflow run, as reported by /// `GET /api/v1/admin/actions/jobs` (Gitea 1.25+). /// /// - Note: `status` values are the *external* strings. `queued` is the one we /// act on: it maps to Gitea's internal `StatusWaiting`, meaning "ready and /// waiting for a matching runner". The string `waiting` means something quite /// different — the job is **blocked** on a dependency — and must never be /// treated as schedulable. public struct WorkflowJob: Codable, Sendable, Equatable, Identifiable { /// Job id. Unique across the instance; the scheduler dedups on this. public let id: Int64 /// The workflow run this job belongs to. public let runID: Int64 /// The job's display name. public let name: String /// One of `queued`, `waiting`, `running`, `success`, `failure`, `cancelled`, /// `skipped`, `blocked`. public let status: String /// The job's `runs-on:` values, as **bare** label names. public let labels: [String] /// The runner that claimed the job, if any. public let runnerID: Int64? /// That runner's name, if any. Lets the reconcile loop tie a Gitea runner /// row back to one of our VMs. public let runnerName: String? /// When the job was created. public let createdAt: Date? /// When a runner picked it up. public let startedAt: Date? /// When it finished. public let completedAt: Date? public init( id: Int64, runID: Int64, name: String, status: String, labels: [String], runnerID: Int64? = nil, runnerName: String? = nil, createdAt: Date? = nil, startedAt: Date? = nil, completedAt: Date? = nil ) { self.id = id self.runID = runID self.name = name self.status = status self.labels = labels self.runnerID = runnerID self.runnerName = runnerName self.createdAt = createdAt self.startedAt = startedAt self.completedAt = completedAt } private enum CodingKeys: String, CodingKey { case id case runID = "run_id" case name case status case labels case runnerID = "runner_id" case runnerName = "runner_name" case createdAt = "created_at" case startedAt = "started_at" case completedAt = "completed_at" } /// Whether this job is waiting for a runner right now. public var isQueued: Bool { status == "queued" } /// ``status`` as a case, with an ``JobStatus/unknown(_:)`` catch-all. public var jobStatus: JobStatus { JobStatus(rawValue: status) } /// Decodes tolerantly: `runner_id` / `runner_name` carry `omitempty` in /// Gitea, and the timestamps are Go `time.Time` values that serialize as /// `0001-01-01T00:00:00Z` when unset (a queued job has no `started_at`). /// Those zero instants are surfaced as `nil` rather than as a year-1 date. public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) self.id = try c.decode(Int64.self, forKey: .id) self.runID = try c.decodeIfPresent(Int64.self, forKey: .runID) ?? 0 self.name = try c.decodeIfPresent(String.self, forKey: .name) ?? "" self.status = try c.decodeIfPresent(String.self, forKey: .status) ?? "" self.labels = try c.decodeIfPresent([String].self, forKey: .labels) ?? [] self.runnerID = try c.decodeIfPresent(Int64.self, forKey: .runnerID) self.runnerName = try c.decodeIfPresent(String.self, forKey: .runnerName) self.createdAt = WorkflowJob.nonZero(try c.decodeIfPresent(Date.self, forKey: .createdAt)) self.startedAt = WorkflowJob.nonZero(try c.decodeIfPresent(Date.self, forKey: .startedAt)) self.completedAt = WorkflowJob.nonZero(try c.decodeIfPresent(Date.self, forKey: .completedAt)) } /// Maps Go's zero `time.Time` (year 1) to `nil`. private static func nonZero(_ date: Date?) -> Date? { guard let date else { return nil } // 0001-01-01T00:00:00Z is ~62.1e9 seconds before the reference date. return date.timeIntervalSinceReferenceDate <= -62_135_596_800 ? nil : date } } /// The *external* status strings Gitea reports for a workflow job. /// /// Gitea maps its internal statuses onto GitHub's vocabulary in /// `convert.ToActionsStatus`: `StatusWaiting → "queued"`, /// `StatusBlocked → "waiting"`, `StatusRunning → "in_progress"`, and every /// terminal status → `"completed"` (the detail moves to a separate `conclusion` /// field). The `unknown` case exists because that mapping is Gitea's to change: /// a closed enum that threw on an unrecognized string would turn a new server /// version into a decode failure and stop the poll loop dead. public enum JobStatus: RawRepresentable, Sendable, Equatable, Hashable { /// Ready and waiting for a matching runner — Gitea's internal `StatusWaiting`. /// This is the only status that is schedulable. case queued /// **Blocked** on a dependency — Gitea's internal `StatusBlocked`. Despite /// the name, this is *not* a job waiting for a runner. case waiting /// Claimed by a runner and executing. case inProgress /// Terminal, in any of success / failure / cancelled / skipped. case completed /// A status string this build does not know about. case unknown(String) public init(rawValue: String) { switch rawValue { case "queued": self = .queued case "waiting": self = .waiting case "in_progress": self = .inProgress case "completed": self = .completed default: self = .unknown(rawValue) } } public var rawValue: String { switch self { case .queued: return "queued" case .waiting: return "waiting" case .inProgress: return "in_progress" case .completed: return "completed" case .unknown(let raw): return raw } } } /// Envelope returned by `GET /api/v1/admin/actions/jobs`. /// /// - Important: The array key has been observed as `jobs`, which is what is /// decoded here; some Gitea builds/OpenAPI revisions have used `workflow_jobs` /// for the equivalent repo-scoped endpoint. ``CodingKeys`` is written out /// explicitly so that adding a fallback is a one-line change, and /// ``jobs`` is optional so an empty response body decodes rather than throwing. public struct WorkflowJobsResponse: Codable, Sendable, Equatable { /// Total matching jobs server-side, ignoring `limit`. public let totalCount: Int? /// The page of jobs. `nil` and `[]` both mean "nothing queued". public let jobs: [WorkflowJob]? public init(totalCount: Int?, jobs: [WorkflowJob]?) { self.totalCount = totalCount self.jobs = jobs } private enum CodingKeys: String, CodingKey { case totalCount = "total_count" case jobs case workflowJobs = "workflow_jobs" case entries } /// The jobs, never `nil`. public var items: [WorkflowJob] { jobs ?? [] } /// Decodes the array under `jobs`, falling back to `workflow_jobs` and /// `entries`. /// /// Gitea 1.25's `ActionWorkflowJobsResponse` tags its slice `json:"jobs"` /// (the Go field is named `Entries`), which is what the fallbacks guard /// against: a future rename of the tag, or a proxy that reserializes from /// the Go field name. public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) self.totalCount = try c.decodeIfPresent(Int.self, forKey: .totalCount) if let jobs = try c.decodeIfPresent([WorkflowJob].self, forKey: .jobs) { self.jobs = jobs } else if let jobs = try c.decodeIfPresent([WorkflowJob].self, forKey: .workflowJobs) { self.jobs = jobs } else { self.jobs = try c.decodeIfPresent([WorkflowJob].self, forKey: .entries) } } public func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) try c.encodeIfPresent(totalCount, forKey: .totalCount) try c.encodeIfPresent(jobs, forKey: .jobs) } } /// A registered Actions runner, from `GET /api/v1/admin/actions/runners`. public struct ActionRunner: Codable, Sendable, Equatable, Identifiable { /// Runner id, used for `DELETE /api/v1/admin/actions/runners/{id}`. public let id: Int64 /// Runner name. Ours always start with the configured `namePrefix`. public let name: String /// Bare label names the runner advertises. public let labels: [String] /// Server-side liveness, e.g. `online` / `offline`. public let status: String? /// Whether the runner is currently executing a task. public let busy: Bool? /// Whether the runner registered with `--ephemeral`, i.e. the server will /// hand it exactly one task and then auto-deregister it (Gitea 1.24+). public let ephemeral: Bool? public init( id: Int64, name: String, labels: [String], status: String? = nil, busy: Bool? = nil, ephemeral: Bool? = nil ) { self.id = id self.name = name self.labels = labels self.status = status self.busy = busy self.ephemeral = ephemeral } private enum CodingKeys: String, CodingKey { case id, name, labels, status, busy, ephemeral } /// A single entry of `ActionRunner.labels` as Gitea actually serializes it: /// an object, not a string. /// /// Verified against `modules/structs/repo_actions.go` at tag `v1.25.0`, /// where `ActionRunner.Labels` is `[]*ActionRunnerLabel` and /// `ActionRunnerLabel` is `{id int64, name string, type string}`. Only /// ``name`` is of any use here. private struct LabelObject: Decodable { let name: String? } /// Decodes `labels` from either shape. /// /// Gitea's job payload gives labels as plain strings, its runner payload /// gives them as objects. Both are accepted so that this one model keeps /// working if a version, a proxy, or a hand-written fixture disagrees. public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) self.id = try c.decode(Int64.self, forKey: .id) self.name = try c.decodeIfPresent(String.self, forKey: .name) ?? "" self.status = try c.decodeIfPresent(String.self, forKey: .status) self.busy = try c.decodeIfPresent(Bool.self, forKey: .busy) self.ephemeral = try c.decodeIfPresent(Bool.self, forKey: .ephemeral) if let strings = try? c.decode([String].self, forKey: .labels) { self.labels = strings } else if let objects = try? c.decode([LabelObject].self, forKey: .labels) { self.labels = objects.compactMap(\.name) } else { // Absent or explicitly null. Gitea does emit `"labels": null` for a // runner registered without any. self.labels = [] } } /// `busy` treated as `false` when the server omits it. public var isBusy: Bool { busy ?? false } /// `ephemeral` treated as `false` when the server omits it. The reconcile /// loop only ever deletes rows it is sure are ephemeral. public var isEphemeral: Bool { ephemeral ?? false } } /// Envelope returned by `GET /api/v1/admin/actions/runners`. public struct RunnersResponse: Codable, Sendable, Equatable { public let totalCount: Int? public let runners: [ActionRunner]? public init(totalCount: Int?, runners: [ActionRunner]?) { self.totalCount = totalCount self.runners = runners } private enum CodingKeys: String, CodingKey { case totalCount = "total_count" case runners case entries } /// The runners, never `nil`. public var items: [ActionRunner] { runners ?? [] } /// Decodes the array under `runners` (Gitea 1.25's tag), falling back to /// `entries` (the Go field name). public init(from decoder: Decoder) throws { let c = try decoder.container(keyedBy: CodingKeys.self) self.totalCount = try c.decodeIfPresent(Int.self, forKey: .totalCount) if let runners = try c.decodeIfPresent([ActionRunner].self, forKey: .runners) { self.runners = runners } else { self.runners = try c.decodeIfPresent([ActionRunner].self, forKey: .entries) } } public func encode(to encoder: Encoder) throws { var c = encoder.container(keyedBy: CodingKeys.self) try c.encodeIfPresent(totalCount, forKey: .totalCount) try c.encodeIfPresent(runners, forKey: .runners) } } /// Response from `POST /api/v1/admin/actions/runners/registration-token`. /// /// - Warning: This is scope-wide and **reusable**. Treat the returned value as /// "the current token for this scope", not as a freshly minted per-VM secret. public struct RegistrationTokenResponse: Codable, Sendable, Equatable { /// The registration token. public let token: String public init(token: String) { self.token = token } }