From 3967a1ba79ff08741b0513dcfaca83733afacdd7 Mon Sep 17 00:00:00 2001 From: Voislav Vasiljevski Date: Thu, 7 May 2026 16:42:16 +0200 Subject: [PATCH] feat: add Forgejo Actions runs API (#1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-authored-by: Voislav Vasiljevski Reviewed-on: https://codeberg.org/secana/ForgejoKit/pulls/1 Reviewed-by: secana --- Sources/ForgejoKit/Models/WorkflowRun.swift | 96 +++++++++++++ .../ForgejoKit/Models/WorkflowRunView.swift | 131 ++++++++++++++++++ .../ForgejoKit/Services/ForgejoClient.swift | 39 ++++++ .../ForgejoKit/Services/WorkflowService.swift | 126 +++++++++++++++++ .../ForgejoKitTests/ModelDecodingTests.swift | 130 +++++++++++++++++ .../WorkflowServiceURLTests.swift | 79 +++++++++++ 6 files changed, 601 insertions(+) create mode 100644 Sources/ForgejoKit/Models/WorkflowRun.swift create mode 100644 Sources/ForgejoKit/Models/WorkflowRunView.swift create mode 100644 Sources/ForgejoKit/Services/WorkflowService.swift create mode 100644 Tests/ForgejoKitTests/WorkflowServiceURLTests.swift diff --git a/Sources/ForgejoKit/Models/WorkflowRun.swift b/Sources/ForgejoKit/Models/WorkflowRun.swift new file mode 100644 index 0000000..f65491d --- /dev/null +++ b/Sources/ForgejoKit/Models/WorkflowRun.swift @@ -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 + } +} diff --git a/Sources/ForgejoKit/Models/WorkflowRunView.swift b/Sources/ForgejoKit/Models/WorkflowRunView.swift new file mode 100644 index 0000000..1f787f6 --- /dev/null +++ b/Sources/ForgejoKit/Models/WorkflowRunView.swift @@ -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 + } +} diff --git a/Sources/ForgejoKit/Services/ForgejoClient.swift b/Sources/ForgejoKit/Services/ForgejoClient.swift index eb480e7..70a29c4 100644 --- a/Sources/ForgejoKit/Services/ForgejoClient.swift +++ b/Sources/ForgejoKit/Services/ForgejoClient.swift @@ -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 { diff --git a/Sources/ForgejoKit/Services/WorkflowService.swift b/Sources/ForgejoKit/Services/WorkflowService.swift new file mode 100644 index 0000000..515e5ed --- /dev/null +++ b/Sources/ForgejoKit/Services/WorkflowService.swift @@ -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, + ) + } +} diff --git a/Tests/ForgejoKitTests/ModelDecodingTests.swift b/Tests/ForgejoKitTests/ModelDecodingTests.swift index 7cb0ca1..a06062a 100644 --- a/Tests/ForgejoKitTests/ModelDecodingTests.swift +++ b/Tests/ForgejoKitTests/ModelDecodingTests.swift @@ -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() { diff --git a/Tests/ForgejoKitTests/WorkflowServiceURLTests.swift b/Tests/ForgejoKitTests/WorkflowServiceURLTests.swift new file mode 100644 index 0000000..e77ab20 --- /dev/null +++ b/Tests/ForgejoKitTests/WorkflowServiceURLTests.swift @@ -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")) + } +}