mirror of
https://codeberg.org/secana/ForgejoKit.git
synced 2026-06-16 05:13:53 -07:00
Compare commits
19 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7675312f5c | ||
|
|
1ad2ee2607 | ||
|
|
485c147e7f | ||
|
|
81c74f6dc3 | ||
|
|
b78c57ffd9 | ||
|
|
4d1dfc1305 | ||
|
|
167d61bc06 | ||
|
|
551e1d01ea | ||
|
|
7352bc4b1f | ||
|
|
03829084c8 | ||
|
|
81d8bc5b4a | ||
|
|
2ab5808c34 | ||
|
|
8a5e513c50 | ||
|
|
b87e4980a0 | ||
|
|
21149d4b45 | ||
|
|
3030629b36 | ||
|
|
3967a1ba79 | ||
|
|
c31ce0d267 | ||
|
|
4c786f4f73 |
32 changed files with 1471 additions and 168 deletions
|
|
@ -1,2 +1,3 @@
|
|||
--swiftversion 6.2
|
||||
--disable redundantMemberwiseInit
|
||||
--disable swiftTestingTestCaseNames
|
||||
|
|
|
|||
|
|
@ -3,3 +3,13 @@
|
|||
disabled_rules:
|
||||
- trailing_comma
|
||||
- opening_brace
|
||||
|
||||
# Service types nest their request/response payload structs one level deeper.
|
||||
nesting:
|
||||
type_level: 2
|
||||
|
||||
# ForgejoClient intentionally aggregates every authentication flow in one type.
|
||||
file_length:
|
||||
warning: 600
|
||||
type_body_length:
|
||||
warning: 300
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ A native Swift library for the [Forgejo](https://forgejo.org) API. ForgejoKit pr
|
|||
|
||||
## Features
|
||||
|
||||
- Authentication via username/password (with optional OTP) or API tokens
|
||||
- Authentication via username/password (with optional OTP), API tokens, or OAuth2 bearer tokens
|
||||
- Repositories: search, list, create, fork, star, file contents, commits, branches, tags, releases
|
||||
- Issues: create, edit, list, comment, manage labels and milestones
|
||||
- Pull requests: create, edit, merge, review, diff
|
||||
|
|
@ -17,7 +17,7 @@ Add ForgejoKit as a dependency in your `Package.swift`:
|
|||
|
||||
```swift
|
||||
dependencies: [
|
||||
.package(url: "https://codeberg.org/secana/ForgejoKit.git", from: "0.3.1"),
|
||||
.package(url: "https://codeberg.org/secana/ForgejoKit.git", from: "0.8.1"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
|
|
@ -35,6 +35,9 @@ import ForgejoKit
|
|||
// Authenticate with a token
|
||||
let client = ForgejoClient(serverURL: "https://codeberg.org", username: "user", token: "your-token")
|
||||
|
||||
// Or authenticate with an OAuth2 access token (sent as `Authorization: Bearer`)
|
||||
let client = ForgejoClient(serverURL: "https://codeberg.org", username: "user", bearerToken: "oauth2-access-token")
|
||||
|
||||
// Or log in with username/password to create an API token
|
||||
let result = try await ForgejoClient.login(serverURL: "https://codeberg.org", username: "user", password: "pass")
|
||||
let client = result.client
|
||||
|
|
|
|||
41
Sources/ForgejoKit/Models/Attachment.swift
Normal file
41
Sources/ForgejoKit/Models/Attachment.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import Foundation
|
||||
|
||||
public struct Attachment: Codable, Identifiable, Sendable {
|
||||
public let id: Int
|
||||
public let uuid: String
|
||||
public let name: String
|
||||
public let size: Int
|
||||
public let browserDownloadUrl: String
|
||||
public let type: String?
|
||||
public let created: Date
|
||||
|
||||
public init(
|
||||
id: Int, uuid: String, name: String, size: Int,
|
||||
browserDownloadUrl: String,
|
||||
type: String?, created: Date,
|
||||
) {
|
||||
self.id = id
|
||||
self.uuid = uuid
|
||||
self.name = name
|
||||
self.size = size
|
||||
self.browserDownloadUrl = browserDownloadUrl
|
||||
self.type = type
|
||||
self.created = created
|
||||
}
|
||||
|
||||
public var isImage: Bool {
|
||||
if let type, type.hasPrefix("image/") { return true }
|
||||
let ext = (name as NSString).pathExtension.lowercased()
|
||||
return ["png", "jpg", "jpeg", "gif", "webp", "heic", "heif", "svg"].contains(ext)
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case uuid
|
||||
case name
|
||||
case size
|
||||
case browserDownloadUrl = "browser_download_url"
|
||||
case type
|
||||
case created = "created_at"
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ public struct Issue: Codable, Identifiable, Sendable {
|
|||
public let pullRequest: IssuePullRequest?
|
||||
public let comments: Int
|
||||
public let repository: Repository?
|
||||
public let assets: [Attachment]?
|
||||
|
||||
public init(
|
||||
id: Int, number: Int, title: String,
|
||||
|
|
@ -28,6 +29,7 @@ public struct Issue: Codable, Identifiable, Sendable {
|
|||
closedAt: Date? = nil, dueDate: Date? = nil,
|
||||
pullRequest: IssuePullRequest? = nil,
|
||||
comments: Int = 0, repository: Repository? = nil,
|
||||
assets: [Attachment]? = nil,
|
||||
) {
|
||||
self.id = id
|
||||
self.number = number
|
||||
|
|
@ -45,6 +47,7 @@ public struct Issue: Codable, Identifiable, Sendable {
|
|||
self.pullRequest = pullRequest
|
||||
self.comments = comments
|
||||
self.repository = repository
|
||||
self.assets = assets
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
|
|
@ -64,6 +67,7 @@ public struct Issue: Codable, Identifiable, Sendable {
|
|||
case pullRequest = "pull_request"
|
||||
case comments
|
||||
case repository
|
||||
case assets
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,19 @@ public struct IssueComment: Codable, Identifiable, Sendable {
|
|||
public let user: User
|
||||
public let createdAt: Date
|
||||
public let updatedAt: Date
|
||||
public let assets: [Attachment]?
|
||||
|
||||
public init(id: Int, body: String, user: User, createdAt: Date, updatedAt: Date) {
|
||||
public init(
|
||||
id: Int, body: String, user: User,
|
||||
createdAt: Date, updatedAt: Date,
|
||||
assets: [Attachment]? = nil,
|
||||
) {
|
||||
self.id = id
|
||||
self.body = body
|
||||
self.user = user
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.assets = assets
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
|
|
@ -21,5 +27,6 @@ public struct IssueComment: Codable, Identifiable, Sendable {
|
|||
case user
|
||||
case createdAt = "created_at"
|
||||
case updatedAt = "updated_at"
|
||||
case assets
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ public struct PullRequest: Codable, Identifiable, Sendable {
|
|||
public let closedAt: Date?
|
||||
public let mergedAt: Date?
|
||||
public let htmlUrl: String?
|
||||
public let assets: [Attachment]?
|
||||
|
||||
public init(
|
||||
id: Int, number: Int, title: String,
|
||||
|
|
@ -38,6 +39,7 @@ public struct PullRequest: Codable, Identifiable, Sendable {
|
|||
createdAt: Date, updatedAt: Date,
|
||||
closedAt: Date? = nil, mergedAt: Date? = nil,
|
||||
htmlUrl: String? = nil,
|
||||
assets: [Attachment]? = nil,
|
||||
) {
|
||||
self.id = id
|
||||
self.number = number
|
||||
|
|
@ -61,6 +63,7 @@ public struct PullRequest: Codable, Identifiable, Sendable {
|
|||
self.closedAt = closedAt
|
||||
self.mergedAt = mergedAt
|
||||
self.htmlUrl = htmlUrl
|
||||
self.assets = assets
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
|
|
@ -86,6 +89,7 @@ public struct PullRequest: Codable, Identifiable, Sendable {
|
|||
case closedAt = "closed_at"
|
||||
case mergedAt = "merged_at"
|
||||
case htmlUrl = "html_url"
|
||||
case assets
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import Foundation
|
|||
public class URLSessionManager: NSObject, @unchecked Sendable {
|
||||
public let allowSelfSignedCertificates: Bool
|
||||
private let trustedHost: String?
|
||||
// Initialized eagerly in init — safe for concurrent access as it's only written once.
|
||||
// Initialized eagerly in init, safe for concurrent access as it's only written once.
|
||||
private var _session: URLSession!
|
||||
public var session: URLSession {
|
||||
_session
|
||||
|
|
|
|||
73
Sources/ForgejoKit/Services/AuthenticationError.swift
Normal file
73
Sources/ForgejoKit/Services/AuthenticationError.swift
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import Foundation
|
||||
|
||||
public enum AuthenticationError: LocalizedError, Sendable, Equatable {
|
||||
case invalidURL
|
||||
case invalidCredentials
|
||||
case otpRequired
|
||||
case basicAuthBlockedBySecurityKey
|
||||
case serverNotFound
|
||||
case invalidResponse
|
||||
case certificateError
|
||||
case unknownError(statusCode: Int)
|
||||
|
||||
static func from(statusCode: Int, body: String = "") -> AuthenticationError {
|
||||
switch statusCode {
|
||||
case 401 where body.localizedCaseInsensitiveContains("security key"):
|
||||
.basicAuthBlockedBySecurityKey
|
||||
case 401:
|
||||
body.localizedCaseInsensitiveContains("OTP") ? .otpRequired : .invalidCredentials
|
||||
case 404:
|
||||
.serverNotFound
|
||||
default:
|
||||
.unknownError(statusCode: statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
public var httpStatusCode: Int? {
|
||||
switch self {
|
||||
case .invalidCredentials, .otpRequired, .basicAuthBlockedBySecurityKey:
|
||||
401
|
||||
case .serverNotFound:
|
||||
404
|
||||
case let .unknownError(statusCode):
|
||||
statusCode
|
||||
case .invalidURL, .invalidResponse, .certificateError:
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
public var httpErrorCategory: HTTPErrorCategory? {
|
||||
guard let httpStatusCode else {
|
||||
return nil
|
||||
}
|
||||
return HTTPErrorCategory(statusCode: httpStatusCode)
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
"Invalid server URL"
|
||||
case .invalidCredentials:
|
||||
"Invalid username or password"
|
||||
case .otpRequired:
|
||||
"Two-factor authentication code required"
|
||||
case .basicAuthBlockedBySecurityKey:
|
||||
"An enrolled security key blocks Basic auth. Create a personal access token and use 'Login with Token'."
|
||||
case .serverNotFound:
|
||||
"Server not found. Please check the URL."
|
||||
case .invalidResponse:
|
||||
"Invalid response from server"
|
||||
case .certificateError:
|
||||
"SSL certificate error. Try enabling 'Accept Self-Signed Certificates' if your server uses one."
|
||||
case let .unknownError(statusCode):
|
||||
switch HTTPErrorCategory(statusCode: statusCode) {
|
||||
case .permissionDenied:
|
||||
"Permission denied (Status: \(statusCode))"
|
||||
case .server:
|
||||
"Server error occurred (Status: \(statusCode))"
|
||||
case .authentication, .notFound, .other:
|
||||
"Unknown error occurred (Status: \(statusCode))"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,10 @@ public final class ForgejoClient: Sendable {
|
|||
public let serverURL: String
|
||||
public let username: String
|
||||
|
||||
private enum AuthCredential: Sendable {
|
||||
private enum AuthCredential {
|
||||
case basic(base64: String)
|
||||
case token(String)
|
||||
case bearer(String)
|
||||
}
|
||||
|
||||
private let credential: AuthCredential
|
||||
|
|
@ -48,6 +49,21 @@ public final class ForgejoClient: Sendable {
|
|||
)
|
||||
}
|
||||
|
||||
/// Creates a client authenticated with an OAuth2 access token.
|
||||
///
|
||||
/// OAuth2 access tokens are sent with the `Authorization: Bearer` scheme,
|
||||
/// unlike personal access tokens which use `Authorization: token`.
|
||||
public convenience init(
|
||||
serverURL: String, username: String, bearerToken: String,
|
||||
allowSelfSignedCertificates: Bool = false,
|
||||
) {
|
||||
self.init(
|
||||
serverURL: serverURL, username: username,
|
||||
credential: .bearer(bearerToken),
|
||||
allowSelfSignedCertificates: allowSelfSignedCertificates,
|
||||
)
|
||||
}
|
||||
|
||||
public static func login(
|
||||
serverURL: String, username: String, password: String,
|
||||
allowSelfSignedCertificates: Bool = false,
|
||||
|
|
@ -115,13 +131,9 @@ public final class ForgejoClient: Sendable {
|
|||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
|
||||
return try decoder.decode(User.self, from: data)
|
||||
case 401:
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw body.contains("OTP") ? AuthenticationError.otpRequired : .invalidCredentials
|
||||
case 404:
|
||||
throw AuthenticationError.serverNotFound
|
||||
default:
|
||||
throw AuthenticationError.unknownError(statusCode: httpResponse.statusCode)
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
|
||||
}
|
||||
} catch let error as URLError where Self.certificateErrorCodes.contains(error.code) {
|
||||
throw AuthenticationError.certificateError
|
||||
|
|
@ -129,11 +141,19 @@ public final class ForgejoClient: Sendable {
|
|||
}
|
||||
|
||||
private func applyAuthHeader(to request: inout URLRequest) {
|
||||
request.setValue(authorizationHeaderValue, forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
/// The `Authorization` header value for this client's credential.
|
||||
/// Exposed internally so tests can assert the scheme without issuing a request.
|
||||
var authorizationHeaderValue: String {
|
||||
switch credential {
|
||||
case let .basic(base64):
|
||||
request.setValue("Basic \(base64)", forHTTPHeaderField: "Authorization")
|
||||
"Basic \(base64)"
|
||||
case let .token(token):
|
||||
request.setValue("token \(token)", forHTTPHeaderField: "Authorization")
|
||||
"token \(token)"
|
||||
case let .bearer(token):
|
||||
"Bearer \(token)"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,20 +204,21 @@ public final class ForgejoClient: Sendable {
|
|||
else { throw AuthenticationError.invalidResponse }
|
||||
return sha1
|
||||
case 400, 422:
|
||||
// Token name already taken — Forgejo returns 400, some versions use 422
|
||||
// Token name already taken, Forgejo returns 400, some versions use 422
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
guard body.contains("name") else {
|
||||
throw AuthenticationError.unknownError(statusCode: httpResponse.statusCode)
|
||||
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
|
||||
}
|
||||
guard retryCount < 3 else {
|
||||
throw AuthenticationError.unknownError(statusCode: httpResponse.statusCode)
|
||||
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
|
||||
}
|
||||
let retryName = "\(name)-\(Int(Date().timeIntervalSince1970))"
|
||||
return try await postTokenRequest(
|
||||
url: url, name: retryName, otp: otp, retryCount: retryCount + 1,
|
||||
)
|
||||
default:
|
||||
throw AuthenticationError.unknownError(statusCode: httpResponse.statusCode)
|
||||
let body = String(data: data, encoding: .utf8) ?? ""
|
||||
throw AuthenticationError.from(statusCode: httpResponse.statusCode, body: body)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -279,6 +300,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 {
|
||||
|
|
@ -312,6 +372,53 @@ extension ForgejoClient {
|
|||
try JSONEncoder().encode(body)
|
||||
}
|
||||
|
||||
/// Builds a multipart/form-data body with a single file part named "attachment".
|
||||
static func buildMultipartBody(
|
||||
fileData: Data,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
boundary: String,
|
||||
) -> Data {
|
||||
var body = Data()
|
||||
body.append(contentsOf: "--\(boundary)\r\n".utf8)
|
||||
let disposition = "Content-Disposition: form-data; name=\"attachment\"; filename=\"\(fileName)\"\r\n"
|
||||
body.append(contentsOf: disposition.utf8)
|
||||
body.append(contentsOf: "Content-Type: \(mimeType)\r\n\r\n".utf8)
|
||||
body.append(fileData)
|
||||
body.append(contentsOf: "\r\n--\(boundary)--\r\n".utf8)
|
||||
return body
|
||||
}
|
||||
|
||||
func performMultipartRequest<T: Decodable>(
|
||||
url: URL,
|
||||
fileData: Data,
|
||||
fileName: String,
|
||||
mimeType: String,
|
||||
boundary: String = UUID().uuidString,
|
||||
responseType: T.Type,
|
||||
) async throws -> T {
|
||||
let body = Self.buildMultipartBody(
|
||||
fileData: fileData, fileName: fileName, mimeType: mimeType, boundary: boundary,
|
||||
)
|
||||
var request = authenticatedRequest(url: url, method: "POST")
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = body
|
||||
|
||||
let (data, response) = try await urlSessionManager.session.data(for: request)
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw ServiceError.invalidResponse
|
||||
}
|
||||
try validateStatus(httpResponse, data: data)
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
|
||||
do {
|
||||
return try decoder.decode(responseType, from: data)
|
||||
} catch let error as DecodingError {
|
||||
throw ServiceError.decodingFailed(detail: describeDecodingError(error))
|
||||
}
|
||||
}
|
||||
|
||||
public static func normalizeServerURL(_ url: String) -> String {
|
||||
var normalized = url.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
while normalized.hasSuffix("/") {
|
||||
|
|
@ -330,36 +437,30 @@ public struct LoginResult: Sendable {
|
|||
public let token: String
|
||||
}
|
||||
|
||||
public enum AuthenticationError: LocalizedError, Sendable {
|
||||
case invalidURL
|
||||
case invalidCredentials
|
||||
case otpRequired
|
||||
case serverNotFound
|
||||
case invalidResponse
|
||||
case certificateError
|
||||
case unknownError(statusCode: Int)
|
||||
public enum HTTPErrorCategory: Equatable, Sendable {
|
||||
case authentication
|
||||
case permissionDenied
|
||||
case notFound
|
||||
case server
|
||||
case other
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL:
|
||||
"Invalid server URL"
|
||||
case .invalidCredentials:
|
||||
"Invalid username or password"
|
||||
case .otpRequired:
|
||||
"Two-factor authentication code required"
|
||||
case .serverNotFound:
|
||||
"Server not found. Please check the URL."
|
||||
case .invalidResponse:
|
||||
"Invalid response from server"
|
||||
case .certificateError:
|
||||
"SSL certificate error. Try enabling 'Accept Self-Signed Certificates' if your server uses one."
|
||||
case let .unknownError(statusCode):
|
||||
"Unknown error occurred (Status: \(statusCode))"
|
||||
public init(statusCode: Int) {
|
||||
switch statusCode {
|
||||
case 401:
|
||||
self = .authentication
|
||||
case 403:
|
||||
self = .permissionDenied
|
||||
case 404:
|
||||
self = .notFound
|
||||
case 500 ... 599:
|
||||
self = .server
|
||||
default:
|
||||
self = .other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum ServiceError: LocalizedError, Sendable {
|
||||
public enum ServiceError: LocalizedError, Sendable, Equatable {
|
||||
case noActiveInstance
|
||||
case invalidURL
|
||||
case invalidResponse
|
||||
|
|
@ -368,6 +469,20 @@ public enum ServiceError: LocalizedError, Sendable {
|
|||
case mergeConflict
|
||||
case decodingFailed(detail: String)
|
||||
|
||||
public var httpStatusCode: Int? {
|
||||
guard case let .httpError(statusCode, _) = self else {
|
||||
return nil
|
||||
}
|
||||
return statusCode
|
||||
}
|
||||
|
||||
public var httpErrorCategory: HTTPErrorCategory? {
|
||||
guard let httpStatusCode else {
|
||||
return nil
|
||||
}
|
||||
return HTTPErrorCategory(statusCode: httpStatusCode)
|
||||
}
|
||||
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .noActiveInstance:
|
||||
|
|
@ -377,7 +492,11 @@ public enum ServiceError: LocalizedError, Sendable {
|
|||
case .invalidResponse:
|
||||
"Invalid response from server"
|
||||
case let .httpError(statusCode, message):
|
||||
message.isEmpty ? "HTTP error: \(statusCode)" : "HTTP \(statusCode): \(message)"
|
||||
if let message = Self.normalizedHTTPMessage(message) {
|
||||
"HTTP \(statusCode): \(message)"
|
||||
} else {
|
||||
"HTTP error: \(statusCode)"
|
||||
}
|
||||
case .notMergeable:
|
||||
"This pull request cannot be merged"
|
||||
case .mergeConflict:
|
||||
|
|
@ -386,4 +505,26 @@ public enum ServiceError: LocalizedError, Sendable {
|
|||
"Decoding failed: \(detail)"
|
||||
}
|
||||
}
|
||||
|
||||
private struct ForgejoErrorBody: Decodable {
|
||||
let message: String?
|
||||
}
|
||||
|
||||
static func normalizedHTTPMessage(_ message: String) -> String? {
|
||||
let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
guard
|
||||
let data = trimmed.data(using: .utf8),
|
||||
let body = try? JSONDecoder().decode(ForgejoErrorBody.self, from: data)
|
||||
else {
|
||||
return trimmed
|
||||
}
|
||||
guard let jsonMessage = body.message else {
|
||||
return trimmed
|
||||
}
|
||||
let trimmedJSONMessage = jsonMessage.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmedJSONMessage.isEmpty ? nil : trimmedJSONMessage
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -184,4 +184,37 @@ public final class IssueService: Sendable {
|
|||
responseType: IssueComment.self,
|
||||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
public func uploadIssueAttachment(
|
||||
owner: String, repo: String, index: Int,
|
||||
fileData: Data, fileName: String, mimeType: String,
|
||||
) async throws -> Attachment {
|
||||
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/issues/\(index)/assets")
|
||||
return try await client.performMultipartRequest(
|
||||
url: url,
|
||||
fileData: fileData,
|
||||
fileName: fileName,
|
||||
mimeType: mimeType,
|
||||
responseType: Attachment.self,
|
||||
)
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
public func uploadCommentAttachment(
|
||||
owner: String, repo: String, commentId: Int,
|
||||
fileData: Data, fileName: String, mimeType: String,
|
||||
) async throws -> Attachment {
|
||||
let url = try client.makeRepoURL(
|
||||
owner: owner, repo: repo,
|
||||
path: "/issues/comments/\(commentId)/assets",
|
||||
)
|
||||
return try await client.performMultipartRequest(
|
||||
url: url,
|
||||
fileData: fileData,
|
||||
fileName: fileName,
|
||||
mimeType: mimeType,
|
||||
responseType: Attachment.self,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,12 +37,11 @@ public final class PullRequestService: Sendable {
|
|||
let mergeTitleField: String?
|
||||
let mergeMessageField: String?
|
||||
|
||||
// swiftlint:disable:next nesting
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case method = "Do"
|
||||
case deleteBranchAfterMerge = "delete_branch_after_merge"
|
||||
case mergeTitleField = "merge_title_field"
|
||||
case mergeMessageField = "merge_message_field"
|
||||
case mergeTitleField = "MergeTitleField"
|
||||
case mergeMessageField = "MergeMessageField"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +57,6 @@ public final class PullRequestService: Sendable {
|
|||
let oldPosition: Int?
|
||||
let newPosition: Int?
|
||||
|
||||
// swiftlint:disable:next nesting
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case body
|
||||
case path
|
||||
|
|
@ -156,7 +154,6 @@ public final class PullRequestService: Sendable {
|
|||
_ = try await client.performRequestNoContent(url: url, method: "DELETE", body: jsonData, validateStatus: true)
|
||||
}
|
||||
|
||||
// swiftlint:disable:next function_parameter_count
|
||||
public func mergePullRequest(
|
||||
owner: String, repo: String, index: Int,
|
||||
method: String, title: String? = nil, message: String? = nil,
|
||||
|
|
@ -173,17 +170,24 @@ public final class PullRequestService: Sendable {
|
|||
do {
|
||||
_ = try await client.performRequestNoContent(url: url, method: "POST", body: jsonData, validateStatus: true)
|
||||
} catch let error as ServiceError {
|
||||
if case let .httpError(statusCode, _) = error {
|
||||
switch statusCode {
|
||||
case 405:
|
||||
throw ServiceError.notMergeable
|
||||
case 409:
|
||||
throw ServiceError.mergeConflict
|
||||
default:
|
||||
throw error
|
||||
}
|
||||
}
|
||||
throw error
|
||||
throw Self.collapseMergeError(error)
|
||||
}
|
||||
}
|
||||
|
||||
static func collapseMergeError(_ error: ServiceError) -> ServiceError {
|
||||
guard case let .httpError(statusCode, message) = error else {
|
||||
return error
|
||||
}
|
||||
guard message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
return error
|
||||
}
|
||||
switch statusCode {
|
||||
case 405:
|
||||
return .notMergeable
|
||||
case 409:
|
||||
return .mergeConflict
|
||||
default:
|
||||
return error
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import Foundation
|
||||
|
||||
// swiftlint:disable file_length
|
||||
|
||||
private struct CreateRepositoryPayload: Codable {
|
||||
let name: String
|
||||
let description: String?
|
||||
|
|
@ -58,6 +56,14 @@ public final class RepositoryService: Sendable {
|
|||
let archived: Bool?
|
||||
let description: String?
|
||||
let name: String?
|
||||
let hasActions: Bool?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case archived
|
||||
case description
|
||||
case name
|
||||
case hasActions = "has_actions"
|
||||
}
|
||||
}
|
||||
|
||||
private struct CreateLabelPayload: Codable {
|
||||
|
|
@ -387,13 +393,14 @@ public final class RepositoryService: Sendable {
|
|||
public func editRepository(
|
||||
owner: String, repo: String,
|
||||
archived: Bool? = nil, description: String? = nil,
|
||||
name: String? = nil,
|
||||
name: String? = nil, hasActions: Bool? = nil,
|
||||
) async throws -> Repository {
|
||||
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "")
|
||||
let payload = EditRepositoryPayload(
|
||||
archived: archived,
|
||||
description: description,
|
||||
name: name,
|
||||
hasActions: hasActions,
|
||||
)
|
||||
let jsonData = try client.encodeRequestBody(payload)
|
||||
return try await client.performRequest(
|
||||
|
|
|
|||
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 = await (try? 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
18
Tests/.swiftlint.yml
Normal file
18
Tests/.swiftlint.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# Test fixtures embed large, verbatim JSON payloads and exhaustive decoding
|
||||
# cases, so length limits are relaxed here. Quality/safety rules stay enabled.
|
||||
disabled_rules:
|
||||
- trailing_comma
|
||||
- opening_brace
|
||||
|
||||
file_length:
|
||||
warning: 1500
|
||||
error: 2000
|
||||
type_body_length:
|
||||
warning: 1300
|
||||
error: 1500
|
||||
function_body_length:
|
||||
warning: 60
|
||||
error: 100
|
||||
line_length:
|
||||
warning: 600
|
||||
error: 700
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
@testable import ForgejoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ForgejoKit
|
||||
|
||||
struct AdminServiceTests {
|
||||
|
||||
@Test func createUserPayloadEncodesCorrectKeys() throws {
|
||||
let json = """
|
||||
{
|
||||
|
|
@ -19,7 +18,7 @@ struct AdminServiceTests {
|
|||
}
|
||||
"""
|
||||
let data = Data(json.utf8)
|
||||
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
#expect(dict["username"] as? String == "testbot")
|
||||
#expect(dict["login_name"] as? String == "testbot")
|
||||
#expect(dict["must_change_password"] as? Bool == false)
|
||||
|
|
|
|||
249
Tests/ForgejoKitTests/AttachmentTests.swift
Normal file
249
Tests/ForgejoKitTests/AttachmentTests.swift
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
@testable import ForgejoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct AttachmentTests {
|
||||
private func decoder() -> JSONDecoder {
|
||||
let dec = JSONDecoder()
|
||||
dec.dateDecodingStrategy = forgejoDateDecodingStrategy
|
||||
return dec
|
||||
}
|
||||
|
||||
// MARK: - Model decoding
|
||||
|
||||
@Test func decodesAttachment() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "ee01801f-ffdb-4154-856b-77eab52aaad2",
|
||||
"name": "screenshot.png",
|
||||
"size": 222208,
|
||||
"browser_download_url": "https://codeberg.org/attachments/ee01801f-ffdb-4154-856b-77eab52aaad2",
|
||||
"type": "image/png",
|
||||
"created_at": "2026-06-14T17:49:26Z"
|
||||
}
|
||||
"""
|
||||
let attachment = try decoder().decode(Attachment.self, from: Data(json.utf8))
|
||||
#expect(attachment.id == 1)
|
||||
#expect(attachment.uuid == "ee01801f-ffdb-4154-856b-77eab52aaad2")
|
||||
#expect(attachment.name == "screenshot.png")
|
||||
#expect(attachment.size == 222_208)
|
||||
#expect(attachment.browserDownloadUrl == "https://codeberg.org/attachments/ee01801f-ffdb-4154-856b-77eab52aaad2")
|
||||
#expect(attachment.type == "image/png")
|
||||
#expect(attachment.isImage == true)
|
||||
}
|
||||
|
||||
@Test func decodesAttachmentWithoutTypeField() throws {
|
||||
// Forgejo API does not always include a type field, isImage falls back to file extension.
|
||||
let json = """
|
||||
{
|
||||
"id": 3,
|
||||
"uuid": "no-type-uuid",
|
||||
"name": "photo.jpg",
|
||||
"size": 10000,
|
||||
"browser_download_url": "https://codeberg.org/attachments/no-type-uuid",
|
||||
"created_at": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
"""
|
||||
let attachment = try decoder().decode(Attachment.self, from: Data(json.utf8))
|
||||
#expect(attachment.type == nil)
|
||||
#expect(attachment.isImage == true)
|
||||
}
|
||||
|
||||
@Test func decodesNonImageAttachment() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": 2,
|
||||
"uuid": "abc123",
|
||||
"name": "report.pdf",
|
||||
"size": 50000,
|
||||
"browser_download_url": "https://forgejo.example.com/attachments/abc123",
|
||||
"type": "application/pdf",
|
||||
"created_at": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
"""
|
||||
let attachment = try decoder().decode(Attachment.self, from: Data(json.utf8))
|
||||
#expect(attachment.name == "report.pdf")
|
||||
#expect(attachment.type == "application/pdf")
|
||||
#expect(attachment.isImage == false)
|
||||
}
|
||||
|
||||
@Test func decodesIssueWithAssets() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": 1,
|
||||
"number": 42,
|
||||
"title": "Bug report",
|
||||
"state": "open",
|
||||
"user": { "id": 1, "login": "alice" },
|
||||
"labels": [],
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"comments": 0,
|
||||
"assets": [
|
||||
{
|
||||
"id": 10,
|
||||
"uuid": "uuid-1",
|
||||
"name": "bug.png",
|
||||
"size": 1024,
|
||||
"browser_download_url": "https://forgejo.example.com/attachments/uuid-1",
|
||||
"type": "image/png",
|
||||
"created_at": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
let issue = try decoder().decode(Issue.self, from: Data(json.utf8))
|
||||
#expect(issue.assets?.count == 1)
|
||||
#expect(issue.assets?.first?.name == "bug.png")
|
||||
#expect(issue.assets?.first?.isImage == true)
|
||||
}
|
||||
|
||||
@Test func decodesIssueWithNoAssets() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": 1,
|
||||
"number": 1,
|
||||
"title": "No attachments",
|
||||
"state": "open",
|
||||
"user": { "id": 1, "login": "alice" },
|
||||
"labels": [],
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"comments": 0
|
||||
}
|
||||
"""
|
||||
let issue = try decoder().decode(Issue.self, from: Data(json.utf8))
|
||||
#expect(issue.assets == nil)
|
||||
}
|
||||
|
||||
@Test func decodesIssueCommentWithAssets() throws {
|
||||
let json = """
|
||||
{
|
||||
"id": 5,
|
||||
"body": "See attached screenshot",
|
||||
"user": { "id": 2, "login": "bob" },
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"id": 20,
|
||||
"uuid": "uuid-2",
|
||||
"name": "comment-image.jpg",
|
||||
"size": 2048,
|
||||
"browser_download_url": "https://forgejo.example.com/attachments/uuid-2",
|
||||
"type": "image/jpeg",
|
||||
"created_at": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
let comment = try decoder().decode(IssueComment.self, from: Data(json.utf8))
|
||||
#expect(comment.assets?.count == 1)
|
||||
#expect(comment.assets?.first?.name == "comment-image.jpg")
|
||||
}
|
||||
|
||||
// MARK: - isImage
|
||||
|
||||
@Test func isImageForVariousMimeTypes() {
|
||||
let makeAttachment = { (type: String) in
|
||||
Attachment(
|
||||
id: 1, uuid: "u", name: "f.bin", size: 0,
|
||||
browserDownloadUrl: "https://example.com/f",
|
||||
type: type, created: Date(),
|
||||
)
|
||||
}
|
||||
#expect(makeAttachment("image/png").isImage == true)
|
||||
#expect(makeAttachment("image/jpeg").isImage == true)
|
||||
#expect(makeAttachment("image/gif").isImage == true)
|
||||
#expect(makeAttachment("image/webp").isImage == true)
|
||||
#expect(makeAttachment("application/pdf").isImage == false)
|
||||
#expect(makeAttachment("text/plain").isImage == false)
|
||||
#expect(makeAttachment("application/zip").isImage == false)
|
||||
}
|
||||
|
||||
@Test func isImageFallsBackToFileExtensionWhenTypeIsNil() {
|
||||
let makeAttachment = { (name: String) in
|
||||
Attachment(
|
||||
id: 1, uuid: "u", name: name, size: 0,
|
||||
browserDownloadUrl: "https://example.com/f",
|
||||
type: nil, created: Date(),
|
||||
)
|
||||
}
|
||||
#expect(makeAttachment("photo.png").isImage == true)
|
||||
#expect(makeAttachment("photo.jpg").isImage == true)
|
||||
#expect(makeAttachment("photo.jpeg").isImage == true)
|
||||
#expect(makeAttachment("photo.gif").isImage == true)
|
||||
#expect(makeAttachment("photo.webp").isImage == true)
|
||||
#expect(makeAttachment("document.pdf").isImage == false)
|
||||
#expect(makeAttachment("archive.zip").isImage == false)
|
||||
#expect(makeAttachment("readme.txt").isImage == false)
|
||||
}
|
||||
|
||||
@Test func isImageFallsBackToExtensionWhenTypeIsNotAMimeType() {
|
||||
// Forgejo returns "type": "attachment" for all uploads, not an actual MIME type.
|
||||
// isImage must fall through to the extension check in this case.
|
||||
let makeAttachment = { (name: String) in
|
||||
Attachment(
|
||||
id: 1, uuid: "u", name: name, size: 0,
|
||||
browserDownloadUrl: "https://example.com/f",
|
||||
type: "attachment", created: Date(),
|
||||
)
|
||||
}
|
||||
#expect(makeAttachment("photo.png").isImage == true)
|
||||
#expect(makeAttachment("photo.jpg").isImage == true)
|
||||
#expect(makeAttachment("photo.gif").isImage == true)
|
||||
#expect(makeAttachment("document.pdf").isImage == false)
|
||||
#expect(makeAttachment("archive.zip").isImage == false)
|
||||
}
|
||||
|
||||
// MARK: - Upload URL construction
|
||||
|
||||
@Test func uploadIssueAttachmentURL() throws {
|
||||
let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "u", password: "p")
|
||||
let url = try client.makeRepoURL(owner: "alice", repo: "myrepo", path: "/issues/42/assets")
|
||||
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/alice/myrepo/issues/42/assets")
|
||||
}
|
||||
|
||||
@Test func uploadCommentAttachmentURL() throws {
|
||||
let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "u", password: "p")
|
||||
let url = try client.makeRepoURL(owner: "alice", repo: "myrepo", path: "/issues/comments/99/assets")
|
||||
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/alice/myrepo/issues/comments/99/assets")
|
||||
}
|
||||
|
||||
@Test func uploadURLEncodesOwnerAndRepo() throws {
|
||||
let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "u", password: "p")
|
||||
let url = try client.makeRepoURL(owner: "my org", repo: "my repo", path: "/issues/1/assets")
|
||||
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/my%20org/my%20repo/issues/1/assets")
|
||||
}
|
||||
|
||||
// MARK: - Multipart body
|
||||
|
||||
@Test func buildMultipartBodyStructure() {
|
||||
let fileData = Data("hello".utf8)
|
||||
let boundary = "test-boundary-123"
|
||||
let body = ForgejoClient.buildMultipartBody(
|
||||
fileData: fileData,
|
||||
fileName: "test.png",
|
||||
mimeType: "image/png",
|
||||
boundary: boundary,
|
||||
)
|
||||
let bodyString = String(data: body, encoding: .utf8) ?? ""
|
||||
#expect(bodyString.contains("--\(boundary)\r\n"))
|
||||
#expect(bodyString.contains("Content-Disposition: form-data; name=\"attachment\"; filename=\"test.png\""))
|
||||
#expect(bodyString.contains("Content-Type: image/png"))
|
||||
#expect(bodyString.contains("hello"))
|
||||
#expect(bodyString.hasSuffix("--\(boundary)--\r\n"))
|
||||
}
|
||||
|
||||
@Test func buildMultipartBodyContainsFileData() {
|
||||
let fileData = Data([0xFF, 0xD8, 0xFF, 0xE0]) // JPEG header bytes
|
||||
let boundary = "b"
|
||||
let body = ForgejoClient.buildMultipartBody(
|
||||
fileData: fileData, fileName: "img.jpg", mimeType: "image/jpeg", boundary: boundary,
|
||||
)
|
||||
let jpegHeader = Data([0xFF, 0xD8, 0xFF, 0xE0])
|
||||
let range = body.range(of: jpegHeader)
|
||||
#expect(range != nil)
|
||||
}
|
||||
}
|
||||
27
Tests/ForgejoKitTests/BearerCredentialTests.swift
Normal file
27
Tests/ForgejoKitTests/BearerCredentialTests.swift
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
@testable import ForgejoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
struct BearerCredentialTests {
|
||||
@Test func bearerCredentialUsesBearerScheme() {
|
||||
let client = ForgejoClient(
|
||||
serverURL: "https://codeberg.org", username: "octocat", bearerToken: "abc123",
|
||||
)
|
||||
#expect(client.authorizationHeaderValue == "Bearer abc123")
|
||||
}
|
||||
|
||||
@Test func tokenCredentialStillUsesTokenScheme() {
|
||||
let client = ForgejoClient(
|
||||
serverURL: "https://codeberg.org", username: "octocat", token: "abc123",
|
||||
)
|
||||
#expect(client.authorizationHeaderValue == "token abc123")
|
||||
}
|
||||
|
||||
@Test func basicCredentialUsesBasicScheme() {
|
||||
let client = ForgejoClient(
|
||||
serverURL: "https://codeberg.org", username: "octocat", password: "pw",
|
||||
)
|
||||
let expected = "Basic " + Data("octocat:pw".utf8).base64EncodedString()
|
||||
#expect(client.authorizationHeaderValue == expected)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
@testable import ForgejoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ForgejoKit
|
||||
|
||||
struct DateDecodingTests {
|
||||
|
||||
private func decoder() -> JSONDecoder {
|
||||
let d = JSONDecoder()
|
||||
d.dateDecodingStrategy = forgejoDateDecodingStrategy
|
||||
return d
|
||||
let jsonDecoder = JSONDecoder()
|
||||
jsonDecoder.dateDecodingStrategy = forgejoDateDecodingStrategy
|
||||
return jsonDecoder
|
||||
}
|
||||
|
||||
private struct DateWrapper: Codable {
|
||||
|
|
@ -74,9 +73,9 @@ struct DateDecodingTests {
|
|||
let jsonWithout = #"{"date":"2024-03-10T08:00:00Z"}"#
|
||||
let jsonWith = #"{"date":"2024-03-10T08:00:00.000Z"}"#
|
||||
|
||||
let d = decoder()
|
||||
let resultWithout = try d.decode(DateWrapper.self, from: Data(jsonWithout.utf8))
|
||||
let resultWith = try d.decode(DateWrapper.self, from: Data(jsonWith.utf8))
|
||||
let jsonDecoder = decoder()
|
||||
let resultWithout = try jsonDecoder.decode(DateWrapper.self, from: Data(jsonWithout.utf8))
|
||||
let resultWith = try jsonDecoder.decode(DateWrapper.self, from: Data(jsonWith.utf8))
|
||||
|
||||
#expect(resultWithout.date == resultWith.date)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
@testable import ForgejoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ForgejoKit
|
||||
|
||||
struct DiffParserTests {
|
||||
|
||||
// MARK: - Empty and minimal input
|
||||
|
||||
@Test func parseEmptyString() {
|
||||
|
|
@ -305,13 +304,13 @@ struct DiffParserTests {
|
|||
|
||||
#expect(result.files[0].newName == "hello.py")
|
||||
#expect(result.files[0].oldName == "/dev/null")
|
||||
#expect(result.files[0].hunks[0].lines.filter { $0.type == .addition }.count == 2)
|
||||
#expect(result.files[0].hunks[0].lines.count(where: { $0.type == .addition }) == 2)
|
||||
|
||||
#expect(result.files[1].newName == "README.md")
|
||||
#expect(result.files[1].oldName == "README.md")
|
||||
let readmeHunk = result.files[1].hunks[0]
|
||||
#expect(readmeHunk.lines.filter { $0.type == .context }.count == 1)
|
||||
#expect(readmeHunk.lines.filter { $0.type == .addition }.count == 2)
|
||||
#expect(readmeHunk.lines.count(where: { $0.type == .context }) == 1)
|
||||
#expect(readmeHunk.lines.count(where: { $0.type == .addition }) == 2)
|
||||
}
|
||||
|
||||
// MARK: - No newline at end of file sentinel
|
||||
|
|
|
|||
142
Tests/ForgejoKitTests/ErrorMappingTests.swift
Normal file
142
Tests/ForgejoKitTests/ErrorMappingTests.swift
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
@testable import ForgejoKit
|
||||
import Testing
|
||||
|
||||
struct ErrorMappingTests {
|
||||
@Test func classifiesHTTPStatusCodes() {
|
||||
#expect(HTTPErrorCategory(statusCode: 401) == .authentication)
|
||||
#expect(HTTPErrorCategory(statusCode: 403) == .permissionDenied)
|
||||
#expect(HTTPErrorCategory(statusCode: 404) == .notFound)
|
||||
#expect(HTTPErrorCategory(statusCode: 500) == .server)
|
||||
#expect(HTTPErrorCategory(statusCode: 503) == .server)
|
||||
#expect(HTTPErrorCategory(statusCode: 400) == .other)
|
||||
}
|
||||
|
||||
@Test func exposesServiceErrorStatusAndCategory() {
|
||||
let forbidden = ServiceError.httpError(statusCode: 403, message: "missing scope")
|
||||
#expect(forbidden.httpStatusCode == 403)
|
||||
#expect(forbidden.httpErrorCategory == .permissionDenied)
|
||||
|
||||
let serverError = ServiceError.httpError(statusCode: 502)
|
||||
#expect(serverError.httpStatusCode == 502)
|
||||
#expect(serverError.httpErrorCategory == .server)
|
||||
|
||||
#expect(ServiceError.invalidURL.httpStatusCode == nil)
|
||||
#expect(ServiceError.invalidURL.httpErrorCategory == nil)
|
||||
}
|
||||
|
||||
@Test func mapsAuthenticationStatusCodes() {
|
||||
#expect(AuthenticationError.from(statusCode: 401) == .invalidCredentials)
|
||||
#expect(AuthenticationError.from(statusCode: 401, body: "OTP required") == .otpRequired)
|
||||
#expect(AuthenticationError.from(statusCode: 403) == .unknownError(statusCode: 403))
|
||||
#expect(AuthenticationError.from(statusCode: 404) == .serverNotFound)
|
||||
#expect(AuthenticationError.from(statusCode: 500) == .unknownError(statusCode: 500))
|
||||
#expect(AuthenticationError.from(statusCode: 418) == .unknownError(statusCode: 418))
|
||||
}
|
||||
|
||||
@Test func mapsSecurityKeyBlockedBasicAuth() {
|
||||
// Forgejo's exact 401 body when a passkey/security key is enrolled on the account.
|
||||
let body = "Basic authorization is not allowed while having security keys enrolled"
|
||||
#expect(AuthenticationError.from(statusCode: 401, body: body) == .basicAuthBlockedBySecurityKey)
|
||||
#expect(AuthenticationError.basicAuthBlockedBySecurityKey.httpStatusCode == 401)
|
||||
#expect(AuthenticationError.basicAuthBlockedBySecurityKey.httpErrorCategory == .authentication)
|
||||
}
|
||||
|
||||
@Test func exposesAuthenticationStatusAndCategory() {
|
||||
#expect(AuthenticationError.invalidCredentials.httpStatusCode == 401)
|
||||
#expect(AuthenticationError.invalidCredentials.httpErrorCategory == .authentication)
|
||||
#expect(AuthenticationError.serverNotFound.httpStatusCode == 404)
|
||||
#expect(AuthenticationError.serverNotFound.httpErrorCategory == .notFound)
|
||||
#expect(AuthenticationError.unknownError(statusCode: 403).httpStatusCode == 403)
|
||||
#expect(AuthenticationError.unknownError(statusCode: 403).httpErrorCategory == .permissionDenied)
|
||||
#expect(AuthenticationError.unknownError(statusCode: 502).httpErrorCategory == .server)
|
||||
#expect(AuthenticationError.certificateError.httpStatusCode == nil)
|
||||
#expect(AuthenticationError.certificateError.httpErrorCategory == nil)
|
||||
}
|
||||
|
||||
// MARK: - ServiceError descriptions
|
||||
|
||||
@Test func serviceErrorDescriptions() {
|
||||
#expect(ServiceError.noActiveInstance.errorDescription == "No active Forgejo instance")
|
||||
#expect(ServiceError.invalidURL.errorDescription == "Invalid URL")
|
||||
#expect(ServiceError.invalidResponse.errorDescription == "Invalid response from server")
|
||||
#expect(ServiceError.httpError(statusCode: 404).errorDescription == "HTTP error: 404")
|
||||
#expect(ServiceError.httpError(statusCode: 500).errorDescription == "HTTP error: 500")
|
||||
#expect(ServiceError.decodingFailed(detail: "missing key").errorDescription == "Decoding failed: missing key")
|
||||
}
|
||||
|
||||
@Test func httpErrorDescriptionUsesForgejoMessageBody() {
|
||||
let error = ServiceError.httpError(
|
||||
statusCode: 405,
|
||||
message: #"{"message":"This pull request has failing status checks"}"#,
|
||||
)
|
||||
|
||||
#expect(error.errorDescription == "HTTP 405: This pull request has failing status checks")
|
||||
}
|
||||
|
||||
@Test func httpErrorDescriptionFallsBackToRawBody() {
|
||||
let error = ServiceError.httpError(statusCode: 500, message: "database unavailable")
|
||||
|
||||
#expect(error.errorDescription == "HTTP 500: database unavailable")
|
||||
}
|
||||
|
||||
@Test func httpErrorDescriptionIgnoresWhitespaceOnlyMessage() {
|
||||
let error = ServiceError.httpError(statusCode: 503, message: " \n ")
|
||||
|
||||
#expect(error.errorDescription == "HTTP error: 503")
|
||||
}
|
||||
|
||||
@Test func httpErrorDescriptionFallsBackWhenJSONMessageFieldIsEmpty() {
|
||||
let error = ServiceError.httpError(
|
||||
statusCode: 422,
|
||||
message: #"{"message":" "}"#,
|
||||
)
|
||||
|
||||
#expect(error.errorDescription == "HTTP error: 422")
|
||||
}
|
||||
|
||||
@Test func httpErrorDescriptionReturnsRawJSONWhenMessageFieldMissing() {
|
||||
let raw = #"{"errors":["bad request"]}"#
|
||||
let error = ServiceError.httpError(statusCode: 400, message: raw)
|
||||
|
||||
#expect(error.errorDescription == "HTTP 400: \(raw)")
|
||||
}
|
||||
|
||||
// MARK: - Merge error collapsing
|
||||
|
||||
@Test func collapseMergeErrorMaps405WithEmptyBodyToNotMergeable() {
|
||||
let collapsed = PullRequestService.collapseMergeError(
|
||||
.httpError(statusCode: 405, message: ""),
|
||||
)
|
||||
#expect(collapsed == .notMergeable)
|
||||
}
|
||||
|
||||
@Test func collapseMergeErrorMaps409WithEmptyBodyToMergeConflict() {
|
||||
let collapsed = PullRequestService.collapseMergeError(
|
||||
.httpError(statusCode: 409, message: " \n "),
|
||||
)
|
||||
#expect(collapsed == .mergeConflict)
|
||||
}
|
||||
|
||||
@Test func collapseMergeErrorPreserves405WithBody() {
|
||||
let original = ServiceError.httpError(
|
||||
statusCode: 405,
|
||||
message: #"{"message":"failing status checks"}"#,
|
||||
)
|
||||
#expect(PullRequestService.collapseMergeError(original) == original)
|
||||
}
|
||||
|
||||
@Test func collapseMergeErrorPreserves409WithBody() {
|
||||
let original = ServiceError.httpError(statusCode: 409, message: "conflict in README.md")
|
||||
#expect(PullRequestService.collapseMergeError(original) == original)
|
||||
}
|
||||
|
||||
@Test func collapseMergeErrorPassesOtherStatusCodesThrough() {
|
||||
let original = ServiceError.httpError(statusCode: 500, message: "")
|
||||
#expect(PullRequestService.collapseMergeError(original) == original)
|
||||
}
|
||||
|
||||
@Test func collapseMergeErrorPassesNonHTTPErrorsThrough() {
|
||||
#expect(PullRequestService.collapseMergeError(.invalidURL) == .invalidURL)
|
||||
#expect(PullRequestService.collapseMergeError(.noActiveInstance) == .noActiveInstance)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
@testable import ForgejoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ForgejoKit
|
||||
|
||||
struct FileContentTests {
|
||||
|
||||
private func makeFileContent(content: String?, encoding: String?) -> FileContent {
|
||||
FileContent(
|
||||
name: "test.txt",
|
||||
|
|
@ -16,7 +15,7 @@ struct FileContentTests {
|
|||
downloadUrl: nil,
|
||||
type: "file",
|
||||
content: content,
|
||||
encoding: encoding
|
||||
encoding: encoding,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
@testable import ForgejoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ForgejoKit
|
||||
|
||||
struct ModelDecodingTests {
|
||||
|
||||
private func decoder() -> JSONDecoder {
|
||||
let d = JSONDecoder()
|
||||
d.dateDecodingStrategy = forgejoDateDecodingStrategy
|
||||
return d
|
||||
let jsonDecoder = JSONDecoder()
|
||||
jsonDecoder.dateDecodingStrategy = forgejoDateDecodingStrategy
|
||||
return jsonDecoder
|
||||
}
|
||||
|
||||
// MARK: - User
|
||||
|
|
@ -400,14 +399,14 @@ struct ModelDecodingTests {
|
|||
"updated_at": "2024-03-02T09:00:00Z"
|
||||
}
|
||||
"""
|
||||
let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8))
|
||||
#expect(pr.number == 42)
|
||||
#expect(pr.title == "Add feature X")
|
||||
#expect(pr.head.ref == "feature-x")
|
||||
#expect(pr.base.ref == "main")
|
||||
#expect(pr.mergeable == true)
|
||||
#expect(pr.merged == false)
|
||||
#expect(pr.draft == false)
|
||||
let pullRequest = try decoder().decode(PullRequest.self, from: Data(json.utf8))
|
||||
#expect(pullRequest.number == 42)
|
||||
#expect(pullRequest.title == "Add feature X")
|
||||
#expect(pullRequest.head.ref == "feature-x")
|
||||
#expect(pullRequest.base.ref == "main")
|
||||
#expect(pullRequest.mergeable == true)
|
||||
#expect(pullRequest.merged == false)
|
||||
#expect(pullRequest.draft == false)
|
||||
}
|
||||
|
||||
@Test func decodesPullRequestWithFullMetadata() throws {
|
||||
|
|
@ -447,18 +446,18 @@ struct ModelDecodingTests {
|
|||
"updated_at": "2024-03-02T09:00:00Z"
|
||||
}
|
||||
"""
|
||||
let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8))
|
||||
#expect(pr.number == 46)
|
||||
#expect(pr.labels.count == 2)
|
||||
#expect(pr.labels[0].name == "enhancement")
|
||||
#expect(pr.milestone?.id == 3)
|
||||
#expect(pr.milestone?.title == "v1.0")
|
||||
#expect(pr.milestone?.dueOn != nil)
|
||||
#expect(pr.assignees?.count == 2)
|
||||
#expect(pr.assignees?[0].login == "assignee1")
|
||||
#expect(pr.assignees?[0].fullName == "Assignee One")
|
||||
#expect(pr.requestedReviewers?.count == 1)
|
||||
#expect(pr.requestedReviewers?[0].login == "reviewer1")
|
||||
let pullRequest = try decoder().decode(PullRequest.self, from: Data(json.utf8))
|
||||
#expect(pullRequest.number == 46)
|
||||
#expect(pullRequest.labels.count == 2)
|
||||
#expect(pullRequest.labels[0].name == "enhancement")
|
||||
#expect(pullRequest.milestone?.id == 3)
|
||||
#expect(pullRequest.milestone?.title == "v1.0")
|
||||
#expect(pullRequest.milestone?.dueOn != nil)
|
||||
#expect(pullRequest.assignees?.count == 2)
|
||||
#expect(pullRequest.assignees?[0].login == "assignee1")
|
||||
#expect(pullRequest.assignees?[0].fullName == "Assignee One")
|
||||
#expect(pullRequest.requestedReviewers?.count == 1)
|
||||
#expect(pullRequest.requestedReviewers?[0].login == "reviewer1")
|
||||
}
|
||||
|
||||
@Test func decodesPullRequestWithRequestedReviewers() throws {
|
||||
|
|
@ -484,11 +483,11 @@ struct ModelDecodingTests {
|
|||
]
|
||||
}
|
||||
"""
|
||||
let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8))
|
||||
#expect(pr.number == 44)
|
||||
#expect(pr.requestedReviewers?.count == 2)
|
||||
#expect(pr.requestedReviewers?[0].login == "reviewer1")
|
||||
#expect(pr.requestedReviewers?[1].fullName == "Reviewer Two")
|
||||
let pullRequest = try decoder().decode(PullRequest.self, from: Data(json.utf8))
|
||||
#expect(pullRequest.number == 44)
|
||||
#expect(pullRequest.requestedReviewers?.count == 2)
|
||||
#expect(pullRequest.requestedReviewers?[0].login == "reviewer1")
|
||||
#expect(pullRequest.requestedReviewers?[1].fullName == "Reviewer Two")
|
||||
}
|
||||
|
||||
@Test func decodesPullRequestWithoutRequestedReviewers() throws {
|
||||
|
|
@ -507,9 +506,9 @@ struct ModelDecodingTests {
|
|||
"updated_at": "2024-03-02T09:00:00Z"
|
||||
}
|
||||
"""
|
||||
let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8))
|
||||
#expect(pr.number == 45)
|
||||
#expect(pr.requestedReviewers == nil)
|
||||
let pullRequest = try decoder().decode(PullRequest.self, from: Data(json.utf8))
|
||||
#expect(pullRequest.number == 45)
|
||||
#expect(pullRequest.requestedReviewers == nil)
|
||||
}
|
||||
|
||||
@Test func decodesMergedPullRequest() throws {
|
||||
|
|
@ -531,10 +530,10 @@ struct ModelDecodingTests {
|
|||
"updated_at": "2024-04-01T12:00:00Z"
|
||||
}
|
||||
"""
|
||||
let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8))
|
||||
#expect(pr.merged == true)
|
||||
#expect(pr.mergedBy?.login == "maintainer")
|
||||
#expect(pr.mergedAt != nil)
|
||||
let pullRequest = try decoder().decode(PullRequest.self, from: Data(json.utf8))
|
||||
#expect(pullRequest.merged == true)
|
||||
#expect(pullRequest.mergedBy?.login == "maintainer")
|
||||
#expect(pullRequest.mergedAt != nil)
|
||||
}
|
||||
|
||||
// MARK: - PullRequestReview
|
||||
|
|
@ -607,10 +606,10 @@ struct ModelDecodingTests {
|
|||
body: "Fix this",
|
||||
path: "file.swift",
|
||||
oldPosition: 5,
|
||||
newPosition: 7
|
||||
newPosition: 7,
|
||||
)
|
||||
let data = try JSONEncoder().encode(comment)
|
||||
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
#expect(dict["body"] as? String == "Fix this")
|
||||
#expect(dict["path"] as? String == "file.swift")
|
||||
#expect(dict["old_position"] as? Int == 5)
|
||||
|
|
@ -622,10 +621,10 @@ struct ModelDecodingTests {
|
|||
body: "Note",
|
||||
path: "readme.md",
|
||||
oldPosition: nil,
|
||||
newPosition: nil
|
||||
newPosition: nil,
|
||||
)
|
||||
let data = try JSONEncoder().encode(comment)
|
||||
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
#expect(dict["old_position"] == nil)
|
||||
#expect(dict["new_position"] == nil)
|
||||
}
|
||||
|
|
@ -1153,6 +1152,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() {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
@testable import ForgejoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ForgejoKit
|
||||
|
||||
struct NormalizeServerURLTests {
|
||||
|
||||
@Test func stripsTrailingSlash() {
|
||||
let result = ForgejoClient.normalizeServerURL("https://forgejo.example.com/")
|
||||
#expect(result == "https://forgejo.example.com")
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
@testable import ForgejoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ForgejoKit
|
||||
|
||||
struct NotificationTests {
|
||||
|
||||
private func decoder() -> JSONDecoder {
|
||||
let d = JSONDecoder()
|
||||
d.dateDecodingStrategy = forgejoDateDecodingStrategy
|
||||
return d
|
||||
let jsonDecoder = JSONDecoder()
|
||||
jsonDecoder.dateDecodingStrategy = forgejoDateDecodingStrategy
|
||||
return jsonDecoder
|
||||
}
|
||||
|
||||
// MARK: - Subject number extraction
|
||||
|
|
@ -64,17 +63,6 @@ struct NotificationTests {
|
|||
#expect(notificationSubjectNumber(from: url) == 15)
|
||||
}
|
||||
|
||||
// MARK: - ServiceError descriptions
|
||||
|
||||
@Test func serviceErrorDescriptions() {
|
||||
#expect(ServiceError.noActiveInstance.errorDescription == "No active Forgejo instance")
|
||||
#expect(ServiceError.invalidURL.errorDescription == "Invalid URL")
|
||||
#expect(ServiceError.invalidResponse.errorDescription == "Invalid response from server")
|
||||
#expect(ServiceError.httpError(statusCode: 404).errorDescription == "HTTP error: 404")
|
||||
#expect(ServiceError.httpError(statusCode: 500).errorDescription == "HTTP error: 500")
|
||||
#expect(ServiceError.decodingFailed(detail: "missing key").errorDescription == "Decoding failed: missing key")
|
||||
}
|
||||
|
||||
// MARK: - Full API response decoding
|
||||
|
||||
@Test func decodesNotificationArray() throws {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
@testable import ForgejoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ForgejoKit
|
||||
|
||||
struct RepositoryServiceURLTests {
|
||||
|
||||
/// Creates a client with dummy credentials for URL construction tests.
|
||||
private func makeClient() -> ForgejoClient {
|
||||
ForgejoClient(serverURL: "https://forgejo.example.com", username: "user", password: "pass")
|
||||
|
|
@ -120,7 +119,7 @@ struct RepositoryServiceURLTests {
|
|||
"auto_init": true,
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
#expect(dict["name"] as? String == "test-repo")
|
||||
#expect(dict["auto_init"] as? Bool == true)
|
||||
#expect(dict["private"] as? Bool == false)
|
||||
|
|
@ -133,7 +132,7 @@ struct RepositoryServiceURLTests {
|
|||
"new_branch": "feature-branch",
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
#expect(dict["content"] as? String == "base64content")
|
||||
#expect(dict["message"] as? String == "Add file")
|
||||
#expect(dict["new_branch"] as? String == "feature-branch")
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
@testable import ForgejoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ForgejoKit
|
||||
|
||||
struct URLSessionManagerTests {
|
||||
|
||||
// MARK: - trustedHost scoping
|
||||
|
||||
@Test func selfSignedWithoutTrustedHostAcceptsAny() {
|
||||
|
|
@ -62,49 +61,48 @@ struct URLSessionManagerTests {
|
|||
}
|
||||
|
||||
struct ForgejoClientHostMatchTests {
|
||||
|
||||
// MARK: - Auth header host matching
|
||||
|
||||
@Test func authenticatedRequestIncludesAuthForMatchingHost() {
|
||||
@Test func authenticatedRequestIncludesAuthForMatchingHost() throws {
|
||||
let client = ForgejoClient(
|
||||
serverURL: "https://forgejo.example.com",
|
||||
username: "user",
|
||||
password: "pass"
|
||||
password: "pass",
|
||||
)
|
||||
let url = URL(string: "https://forgejo.example.com/api/v1/user")!
|
||||
let url = try #require(URL(string: "https://forgejo.example.com/api/v1/user"))
|
||||
let request = client.authenticatedRequest(url: url)
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") != nil)
|
||||
}
|
||||
|
||||
@Test func authenticatedRequestOmitsAuthForDifferentHost() {
|
||||
@Test func authenticatedRequestOmitsAuthForDifferentHost() throws {
|
||||
let client = ForgejoClient(
|
||||
serverURL: "https://forgejo.example.com",
|
||||
username: "user",
|
||||
password: "pass"
|
||||
password: "pass",
|
||||
)
|
||||
let url = URL(string: "https://evil.example.com/api/v1/user")!
|
||||
let url = try #require(URL(string: "https://evil.example.com/api/v1/user"))
|
||||
let request = client.authenticatedRequest(url: url)
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
|
||||
}
|
||||
|
||||
@Test func authenticatedRequestMatchesCaseInsensitiveHost() {
|
||||
@Test func authenticatedRequestMatchesCaseInsensitiveHost() throws {
|
||||
let client = ForgejoClient(
|
||||
serverURL: "https://Forgejo.Example.COM",
|
||||
username: "user",
|
||||
password: "pass"
|
||||
password: "pass",
|
||||
)
|
||||
let url = URL(string: "https://forgejo.example.com/api/v1/repos")!
|
||||
let url = try #require(URL(string: "https://forgejo.example.com/api/v1/repos"))
|
||||
let request = client.authenticatedRequest(url: url)
|
||||
#expect(request.value(forHTTPHeaderField: "Authorization") != nil)
|
||||
}
|
||||
|
||||
@Test func authenticatedRequestSetsMethodAndBody() {
|
||||
@Test func authenticatedRequestSetsMethodAndBody() throws {
|
||||
let client = ForgejoClient(
|
||||
serverURL: "https://forgejo.example.com",
|
||||
username: "user",
|
||||
password: "pass"
|
||||
password: "pass",
|
||||
)
|
||||
let url = URL(string: "https://forgejo.example.com/api/v1/repos")!
|
||||
let url = try #require(URL(string: "https://forgejo.example.com/api/v1/repos"))
|
||||
let body = Data("{\"name\":\"test\"}".utf8)
|
||||
let request = client.authenticatedRequest(url: url, method: "POST", body: body)
|
||||
#expect(request.httpMethod == "POST")
|
||||
|
|
@ -116,7 +114,7 @@ struct ForgejoClientHostMatchTests {
|
|||
let client = ForgejoClient(
|
||||
serverURL: "https://forgejo.example.com",
|
||||
username: "user",
|
||||
password: "pass"
|
||||
password: "pass",
|
||||
)
|
||||
let url = try client.makeURL(path: "/api/v1/user")
|
||||
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/user")
|
||||
|
|
@ -126,11 +124,11 @@ struct ForgejoClientHostMatchTests {
|
|||
let client = ForgejoClient(
|
||||
serverURL: "https://forgejo.example.com",
|
||||
username: "user",
|
||||
password: "pass"
|
||||
password: "pass",
|
||||
)
|
||||
let url = try client.makeURL(path: "/api/v1/repos/search", queryItems: [
|
||||
URLQueryItem(name: "q", value: "test"),
|
||||
URLQueryItem(name: "page", value: "2")
|
||||
URLQueryItem(name: "page", value: "2"),
|
||||
])
|
||||
#expect(url.absoluteString.contains("q=test"))
|
||||
#expect(url.absoluteString.contains("page=2"))
|
||||
|
|
@ -142,7 +140,7 @@ struct ForgejoClientHostMatchTests {
|
|||
serverURL: "https://my-forgejo.local:3000",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
allowSelfSignedCertificates: true
|
||||
allowSelfSignedCertificates: true,
|
||||
)
|
||||
// The session should be created with self-signed support
|
||||
#expect(client.serverURL == "https://my-forgejo.local:3000")
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
@testable import ForgejoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import ForgejoKit
|
||||
|
||||
struct UserServiceTests {
|
||||
|
||||
@Test func createTokenPayloadEncodesCorrectKeys() throws {
|
||||
let payload: [String: Any] = [
|
||||
"name": "integration-test-token",
|
||||
"scopes": ["read:user", "write:user"],
|
||||
]
|
||||
let data = try JSONSerialization.data(withJSONObject: payload)
|
||||
let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any]
|
||||
let dict = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
|
||||
#expect(dict["name"] as? String == "integration-test-token")
|
||||
#expect((dict["scopes"] as? [String])?.count == 2)
|
||||
}
|
||||
|
|
|
|||
78
Tests/ForgejoKitTests/WorkflowServiceURLTests.swift
Normal file
78
Tests/ForgejoKitTests/WorkflowServiceURLTests.swift
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
@testable import ForgejoKit
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
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"))
|
||||
}
|
||||
}
|
||||
4
justfile
4
justfile
|
|
@ -8,10 +8,10 @@ test:
|
|||
swift test 2>&1 | xcbeautify
|
||||
|
||||
lint:
|
||||
swiftlint lint Sources
|
||||
swiftlint lint Sources Tests
|
||||
|
||||
format:
|
||||
swiftformat Sources
|
||||
swiftformat Sources Tests
|
||||
|
||||
# Tag and push a new release: just release 1.0.0
|
||||
release version:
|
||||
|
|
|
|||
Loading…
Reference in a new issue