mirror of
https://codeberg.org/secana/ForgejoKit.git
synced 2026-06-16 05:13:53 -07:00
feat: add Forgejo Actions runs API (#1)
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 <voislav@voioo.cz>
Reviewed-on: https://codeberg.org/secana/ForgejoKit/pulls/1
Reviewed-by: secana <secana@noreply.codeberg.org>
This commit is contained in:
parent
c31ce0d267
commit
3967a1ba79
6 changed files with 601 additions and 0 deletions
96
Sources/ForgejoKit/Models/WorkflowRun.swift
Normal file
96
Sources/ForgejoKit/Models/WorkflowRun.swift
Normal 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
|
||||
}
|
||||
}
|
||||
131
Sources/ForgejoKit/Models/WorkflowRunView.swift
Normal file
131
Sources/ForgejoKit/Models/WorkflowRunView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
126
Sources/ForgejoKit/Services/WorkflowService.swift
Normal file
126
Sources/ForgejoKit/Services/WorkflowService.swift
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
79
Tests/ForgejoKitTests/WorkflowServiceURLTests.swift
Normal file
79
Tests/ForgejoKitTests/WorkflowServiceURLTests.swift
Normal 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"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue