331 lines
13 KiB
Swift
331 lines
13 KiB
Swift
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
|
|
}
|
|
}
|