feat: add Forgejo Actions runs API

New model:
  - WorkflowRun — mirrors Forgejo's ActionRun schema (id, title, status,
    event, indexInRepo, commitSha, prettyRef, workflowId, created, updated,
    started, stopped, durationNanos, htmlUrl, triggerUser, repository).

New service:
  - WorkflowService
      - fetchRuns(owner:repo:status:event:ref:workflowId:runNumber:headSha:page:limit:)
        backed by GET /repos/{owner}/{repo}/actions/runs.
      - fetchRun(owner:repo:runId:) backed by
        GET /repos/{owner}/{repo}/actions/runs/{run_id}.

Experimental:
  - fetchRunView(owner:repo:runIndex:jobIndex:logCursors:) backed by
    Forgejo's web-UI route POST
    /{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}/attempt/{N}.
    This is not part of /api/v1 and may change between Forgejo releases;
    it lets clients render jobs, steps, and step logs (via WorkflowRunView,
    WorkflowRunViewJob, WorkflowRunViewStep, WorkflowLogCursor types).
  - ForgejoClient.discoverRedirectLocation(url:) helper that resolves
    Forgejo's RedirectToLatestAttempt without consuming the redirect
    target — used to find the latest attempt number before POSTing.
This commit is contained in:
Voislav Vasiljevski 2026-05-06 12:56:04 +02:00
parent c31ce0d267
commit 6453167525
No known key found for this signature in database
6 changed files with 601 additions and 0 deletions

View file

@ -0,0 +1,96 @@
import Foundation
/// Represents a single Forgejo Actions run.
///
/// Mirrors the Forgejo `ActionRun` schema. Note: Forgejo merges status and
/// conclusion into a single `status` field values are
/// `success`, `failure`, `cancelled`, `skipped`, `running`, `waiting`,
/// `blocked`, `unknown`.
public struct WorkflowRun: Codable, Identifiable, Sendable {
public let id: Int
public let title: String?
public let status: String
public let event: String?
public let triggerEvent: String?
public let workflowId: String?
public let indexInRepo: Int?
public let commitSha: String?
public let prettyRef: String?
public let isRefDeleted: Bool?
public let isForkPullRequest: Bool?
public let needApproval: Bool?
public let approvedBy: Int?
public let scheduleId: Int?
public let created: Date?
public let updated: Date?
public let started: Date?
public let stopped: Date?
/// Elapsed nanoseconds, as Forgejo's `Duration` type.
public let durationNanos: Int?
public let htmlUrl: String?
public let triggerUser: User?
public let repository: Repository?
public init(
id: Int, title: String? = nil, status: String,
event: String? = nil, triggerEvent: String? = nil,
workflowId: String? = nil, indexInRepo: Int? = nil,
commitSha: String? = nil, prettyRef: String? = nil,
isRefDeleted: Bool? = nil, isForkPullRequest: Bool? = nil,
needApproval: Bool? = nil, approvedBy: Int? = nil,
scheduleId: Int? = nil,
created: Date? = nil, updated: Date? = nil,
started: Date? = nil, stopped: Date? = nil,
durationNanos: Int? = nil,
htmlUrl: String? = nil,
triggerUser: User? = nil, repository: Repository? = nil,
) {
self.id = id
self.title = title
self.status = status
self.event = event
self.triggerEvent = triggerEvent
self.workflowId = workflowId
self.indexInRepo = indexInRepo
self.commitSha = commitSha
self.prettyRef = prettyRef
self.isRefDeleted = isRefDeleted
self.isForkPullRequest = isForkPullRequest
self.needApproval = needApproval
self.approvedBy = approvedBy
self.scheduleId = scheduleId
self.created = created
self.updated = updated
self.started = started
self.stopped = stopped
self.durationNanos = durationNanos
self.htmlUrl = htmlUrl
self.triggerUser = triggerUser
self.repository = repository
}
enum CodingKeys: String, CodingKey {
case id
case title
case status
case event
case triggerEvent = "trigger_event"
case workflowId = "workflow_id"
case indexInRepo = "index_in_repo"
case commitSha = "commit_sha"
case prettyRef = "prettyref"
case isRefDeleted = "is_ref_deleted"
case isForkPullRequest = "is_fork_pull_request"
case needApproval = "need_approval"
case approvedBy = "approved_by"
case scheduleId = "ScheduleID"
case created
case updated
case started
case stopped
case durationNanos = "duration"
case htmlUrl = "html_url"
case triggerUser = "trigger_user"
case repository
}
}

View file

@ -0,0 +1,131 @@
import Foundation
/// Response from Forgejo's internal web route
/// `POST /{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}`.
///
/// **Experimental.** This endpoint is the web UI's own JSON contract it is
/// not part of `/api/v1` and may change between Forgejo releases without
/// notice. Use only for surfaces where graceful degradation is acceptable.
public struct WorkflowRunView: Codable, Sendable {
public let state: WorkflowRunViewState
public let logs: WorkflowRunViewLogs
public init(state: WorkflowRunViewState, logs: WorkflowRunViewLogs) {
self.state = state
self.logs = logs
}
}
public struct WorkflowRunViewState: Codable, Sendable {
public let run: WorkflowRunViewRun
public let currentJob: WorkflowRunViewCurrentJob
public init(run: WorkflowRunViewRun, currentJob: WorkflowRunViewCurrentJob) {
self.run = run
self.currentJob = currentJob
}
}
public struct WorkflowRunViewRun: Codable, Sendable {
public let title: String
public let status: String
public let done: Bool
public let jobs: [WorkflowRunViewJob]
public let preExecutionError: String?
public init(
title: String, status: String, done: Bool,
jobs: [WorkflowRunViewJob],
preExecutionError: String? = nil,
) {
self.title = title
self.status = status
self.done = done
self.jobs = jobs
self.preExecutionError = preExecutionError
}
}
public struct WorkflowRunViewJob: Codable, Identifiable, Sendable {
public let id: Int
public let name: String
public let status: String
public let duration: String
public init(id: Int, name: String, status: String, duration: String) {
self.id = id
self.name = name
self.status = status
self.duration = duration
}
}
public struct WorkflowRunViewCurrentJob: Codable, Sendable {
public let title: String
public let steps: [WorkflowRunViewStep]
public init(title: String, steps: [WorkflowRunViewStep]) {
self.title = title
self.steps = steps
}
}
public struct WorkflowRunViewStep: Codable, Sendable {
public let summary: String
public let duration: String
public let status: String
public init(summary: String, duration: String, status: String) {
self.summary = summary
self.duration = duration
self.status = status
}
}
public struct WorkflowRunViewLogs: Codable, Sendable {
public let stepsLog: [WorkflowRunViewStepLog]
public init(stepsLog: [WorkflowRunViewStepLog] = []) {
self.stepsLog = stepsLog
}
}
public struct WorkflowRunViewStepLog: Codable, Sendable {
public let step: Int
public let cursor: Int
public let started: Int
public let lines: [WorkflowRunViewLogLine]
public init(step: Int, cursor: Int, started: Int, lines: [WorkflowRunViewLogLine]) {
self.step = step
self.cursor = cursor
self.started = started
self.lines = lines
}
}
public struct WorkflowRunViewLogLine: Codable, Sendable {
public let index: Int
public let message: String
public let timestamp: Double
public init(index: Int, message: String, timestamp: Double) {
self.index = index
self.message = message
self.timestamp = timestamp
}
}
/// Cursor passed in the `logCursors` array of a `WorkflowRunView` request to
/// indicate which step's logs to fetch and from which line.
public struct WorkflowLogCursor: Codable, Sendable {
public let step: Int
public let cursor: Int
public let expanded: Bool
public init(step: Int, cursor: Int = 0, expanded: Bool = true) {
self.step = step
self.cursor = cursor
self.expanded = expanded
}
}

View file

@ -279,6 +279,45 @@ public final class ForgejoClient: Sendable {
try validateStatus(httpResponse, data: data)
return data
}
/// Issues a GET to `url` with redirects disabled and returns the absolute
/// `Location` header from the response, if any.
///
/// Used to discover server-side redirects (e.g. Forgejo's
/// `RedirectToLatestAttempt`) without consuming the redirect target.
func discoverRedirectLocation(url: URL) async throws -> URL? {
var request = URLRequest(url: url)
request.httpMethod = "GET"
if let serverHost, let requestHost = url.host,
serverHost.lowercased() == requestHost.lowercased()
{
applyAuthHeader(to: &request)
}
let delegate = NoRedirectDelegate()
let session = URLSession(
configuration: .default, delegate: delegate, delegateQueue: nil,
)
defer { session.finishTasksAndInvalidate() }
let (_, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
(300 ... 399).contains(httpResponse.statusCode),
let location = httpResponse.value(forHTTPHeaderField: "Location")
else {
return nil
}
return URL(string: location, relativeTo: url)?.absoluteURL
}
}
private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
func urlSession(
_: URLSession, task _: URLSessionTask,
willPerformHTTPRedirection _: HTTPURLResponse,
newRequest _: URLRequest,
completionHandler: @escaping (URLRequest?) -> Void,
) {
completionHandler(nil)
}
}
extension ForgejoClient {

View file

@ -0,0 +1,126 @@
import Foundation
/// Forgejo Actions API. Forgejo's Actions API is much smaller than Gitea's
/// or GitHub's there is no public endpoint to list workflow definitions,
/// fetch jobs of a run, or stream job logs. Only run listing and run detail
/// are exposed.
public final class WorkflowService: Sendable {
private let client: ForgejoClient
public init(client: ForgejoClient) {
self.client = client
}
private struct WorkflowRunsResponse: Codable {
let totalCount: Int?
let workflowRuns: [WorkflowRun]
enum CodingKeys: String, CodingKey {
case totalCount = "total_count"
case workflowRuns = "workflow_runs"
}
}
/// List action runs for a repository.
///
/// - Parameter status: One of Forgejo's run statuses
/// (`success`, `failure`, `cancelled`, `skipped`, `running`, `waiting`,
/// `blocked`, `unknown`). Use `nil` to omit the filter.
/// - Parameter ref: Branch or tag (e.g. `main`, `refs/heads/main`).
/// - Parameter workflowId: Workflow file name (e.g. `ci.yml`).
public func fetchRuns(
owner: String, repo: String,
status: String? = nil,
event: String? = nil,
ref: String? = nil,
workflowId: String? = nil,
runNumber: Int? = nil,
headSha: String? = nil,
page: Int = 1, limit: Int = 20,
) async throws -> [WorkflowRun] {
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "limit", value: "\(limit)"),
]
if let status {
queryItems.append(URLQueryItem(name: "status", value: status))
}
if let event {
queryItems.append(URLQueryItem(name: "event", value: event))
}
if let ref {
queryItems.append(URLQueryItem(name: "ref", value: ref))
}
if let workflowId {
queryItems.append(URLQueryItem(name: "workflow_id", value: workflowId))
}
if let runNumber {
queryItems.append(URLQueryItem(name: "run_number", value: "\(runNumber)"))
}
if let headSha {
queryItems.append(URLQueryItem(name: "head_sha", value: headSha))
}
let url = try client.makeRepoURL(
owner: owner, repo: repo,
path: "/actions/runs", queryItems: queryItems,
)
let response = try await client.performRequest(
url: url, responseType: WorkflowRunsResponse.self,
)
return response.workflowRuns
}
public func fetchRun(
owner: String, repo: String, runId: Int,
) async throws -> WorkflowRun {
let url = try client.makeRepoURL(
owner: owner, repo: repo,
path: "/actions/runs/\(runId)",
)
return try await client.performRequest(
url: url, responseType: WorkflowRun.self,
)
}
private struct ViewRequestBody: Codable {
let logCursors: [WorkflowLogCursor]
}
/// Fetches the experimental "view" payload Forgejo's web UI uses to render
/// a run's jobs, steps, and (optionally) step logs.
///
/// **Experimental.** Backed by Forgejo's web routes
/// (`POST /{owner}/{repo}/actions/runs/{runIndex}/jobs/{jobIndex}`), not
/// the public `/api/v1` surface. The shape can change between Forgejo
/// releases.
///
/// - Parameter runIndex: the per-repo run number (`index_in_repo` on
/// `WorkflowRun`), **not** the global run id.
/// - Parameter jobIndex: position of the desired job in the run's jobs
/// array, 0-based. Pass `0` to load the first job along with the run's
/// full jobs list.
/// - Parameter logCursors: which steps' logs to include in the response.
/// Pass an empty array to skip log fetching.
public func fetchRunView(
owner: String, repo: String,
runIndex: Int, jobIndex: Int = 0,
logCursors: [WorkflowLogCursor] = [],
) async throws -> WorkflowRunView {
let encOwner = ForgejoClient.encodedPathSegment(owner)
let encRepo = ForgejoClient.encodedPathSegment(repo)
let baseURL = try client.makeURL(
path: "/\(encOwner)/\(encRepo)/actions/runs/\(runIndex)/jobs/\(jobIndex)",
)
// Forgejo's POST handler at this base path uses `attempt=0` which fails
// for any run whose tasks are stored with the (1-based) attempt number.
// The matching GET endpoint 307-redirects to the latest-attempt URL
// resolve that first, then POST to the resolved attempt-suffixed URL.
let postURL = (try? await client.discoverRedirectLocation(url: baseURL)) ?? baseURL
let body = try client.encodeRequestBody(ViewRequestBody(logCursors: logCursors))
return try await client.performRequest(
url: postURL, method: "POST", body: body,
responseType: WorkflowRunView.self,
)
}
}

View file

@ -1153,6 +1153,136 @@ struct ModelDecodingTests {
#expect(token.tokenLastEight == nil)
}
// MARK: - WorkflowRun (Forgejo's ActionRun schema)
@Test func decodesWorkflowRunWithFullFields() throws {
let json = """
{
"id": 100,
"title": "Fix bug in login",
"status": "success",
"event": "push",
"trigger_event": "push",
"workflow_id": "ci.yml",
"index_in_repo": 42,
"commit_sha": "abc123def456",
"prettyref": "main",
"is_ref_deleted": false,
"is_fork_pull_request": false,
"need_approval": false,
"approved_by": 0,
"ScheduleID": 0,
"created": "2024-05-01T10:00:00Z",
"updated": "2024-05-01T10:05:00Z",
"started": "2024-05-01T10:00:30Z",
"stopped": "2024-05-01T10:04:30Z",
"duration": 240000000000,
"html_url": "https://example.com/o/r/actions/runs/100",
"trigger_user": {"id": 1, "login": "alice"}
}
"""
let run = try decoder().decode(WorkflowRun.self, from: Data(json.utf8))
#expect(run.id == 100)
#expect(run.title == "Fix bug in login")
#expect(run.status == "success")
#expect(run.event == "push")
#expect(run.workflowId == "ci.yml")
#expect(run.indexInRepo == 42)
#expect(run.commitSha == "abc123def456")
#expect(run.prettyRef == "main")
#expect(run.htmlUrl == "https://example.com/o/r/actions/runs/100")
#expect(run.triggerUser?.login == "alice")
#expect(run.durationNanos == 240_000_000_000)
}
@Test func decodesWorkflowRunWithMinimalFields() throws {
// Minimum the Forgejo API guarantees for an in-flight run: id and status.
let json = """
{
"id": 101,
"status": "running"
}
"""
let run = try decoder().decode(WorkflowRun.self, from: Data(json.utf8))
#expect(run.status == "running")
#expect(run.indexInRepo == nil)
#expect(run.title == nil)
#expect(run.commitSha == nil)
#expect(run.workflowId == nil)
}
// MARK: - WorkflowRunView (experimental web-route response)
@Test func decodesWorkflowRunView() throws {
let json = """
{
"state": {
"run": {
"title": "ci",
"status": "success",
"done": true,
"preExecutionError": "",
"jobs": [
{"id": 11, "name": "build", "status": "success", "duration": "1m 23s"},
{"id": 12, "name": "test", "status": "failure", "duration": "45s"}
]
},
"currentJob": {
"title": "build",
"steps": [
{"summary": "Set up job", "duration": "5s", "status": "success"},
{"summary": "Run script", "duration": "1m 18s", "status": "success"}
]
}
},
"logs": {
"stepsLog": [
{
"step": 1, "cursor": 200, "started": 1700000000,
"lines": [
{"index": 0, "message": "hello", "timestamp": 1700000000.123},
{"index": 1, "message": "world", "timestamp": 1700000000.456}
]
}
]
}
}
"""
let view = try decoder().decode(WorkflowRunView.self, from: Data(json.utf8))
#expect(view.state.run.title == "ci")
#expect(view.state.run.jobs.count == 2)
#expect(view.state.run.jobs.first?.duration == "1m 23s")
#expect(view.state.currentJob.title == "build")
#expect(view.state.currentJob.steps.count == 2)
#expect(view.logs.stepsLog.first?.lines.count == 2)
#expect(view.logs.stepsLog.first?.lines.first?.message == "hello")
}
@Test func decodesWorkflowRunViewWithEmptyLogs() throws {
let json = """
{
"state": {
"run": {
"title": "ci",
"status": "running",
"done": false,
"jobs": []
},
"currentJob": {
"title": "",
"steps": []
}
},
"logs": {
"stepsLog": []
}
}
"""
let view = try decoder().decode(WorkflowRunView.self, from: Data(json.utf8))
#expect(view.state.run.jobs.isEmpty)
#expect(view.logs.stepsLog.isEmpty)
}
// MARK: - DecodingError description
@Test func decodingErrorAtRootShowsRoot() {

View file

@ -0,0 +1,79 @@
import Foundation
import Testing
@testable import ForgejoKit
struct WorkflowServiceURLTests {
private func makeClient() -> ForgejoClient {
ForgejoClient(serverURL: "https://forgejo.example.com", username: "user", password: "pass")
}
@Test func runsListURLNoFilters() throws {
let client = makeClient()
let queryItems = [
URLQueryItem(name: "page", value: "1"),
URLQueryItem(name: "limit", value: "20"),
]
let url = try client.makeRepoURL(
owner: "owner", repo: "repo",
path: "/actions/runs", queryItems: queryItems,
)
#expect(url.path == "/api/v1/repos/owner/repo/actions/runs")
#expect(url.query?.contains("page=1") == true)
#expect(url.query?.contains("limit=20") == true)
}
@Test func runsListURLAllFilters() throws {
let client = makeClient()
let queryItems = [
URLQueryItem(name: "page", value: "2"),
URLQueryItem(name: "limit", value: "50"),
URLQueryItem(name: "status", value: "running"),
URLQueryItem(name: "event", value: "push"),
URLQueryItem(name: "ref", value: "main"),
URLQueryItem(name: "workflow_id", value: "ci.yml"),
URLQueryItem(name: "run_number", value: "42"),
URLQueryItem(name: "head_sha", value: "abc123"),
]
let url = try client.makeRepoURL(
owner: "owner", repo: "repo",
path: "/actions/runs", queryItems: queryItems,
)
#expect(url.query?.contains("status=running") == true)
#expect(url.query?.contains("event=push") == true)
#expect(url.query?.contains("ref=main") == true)
#expect(url.query?.contains("workflow_id=ci.yml") == true)
#expect(url.query?.contains("run_number=42") == true)
#expect(url.query?.contains("head_sha=abc123") == true)
}
@Test func runDetailURL() throws {
let client = makeClient()
let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "/actions/runs/123")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/actions/runs/123")
}
@Test func ownerAndRepoArePercentEncoded() throws {
let client = makeClient()
let url = try client.makeRepoURL(owner: "my org", repo: "my repo", path: "/actions/runs")
#expect(url.absoluteString.contains("my%20org"))
#expect(url.absoluteString.contains("my%20repo"))
}
// MARK: - Experimental run-view URL (web routes, not /api/v1)
@Test func runViewURLIsUnderRepoWebRoute() throws {
let client = makeClient()
let url = try client.makeURL(path: "/owner/repo/actions/runs/12/jobs/0")
#expect(url.absoluteString == "https://forgejo.example.com/owner/repo/actions/runs/12/jobs/0")
}
@Test func runViewURLEncodesOwnerAndRepo() throws {
let client = makeClient()
let owner = ForgejoClient.encodedPathSegment("my org")
let repo = ForgejoClient.encodedPathSegment("my repo")
let url = try client.makeURL(path: "/\(owner)/\(repo)/actions/runs/3/jobs/1")
#expect(url.absoluteString.contains("my%20org"))
#expect(url.absoluteString.contains("my%20repo"))
}
}