Compare commits

..

19 commits
0.3.1 ... main

Author SHA1 Message Date
Stefan Hausotte
7675312f5c release 0.8.1 2026-06-15 18:44:16 +02:00
Stefan Hausotte
1ad2ee2607 fix: report security-key-blocked Basic auth instead of invalid credentials
Forgejo rejects username/password (Basic auth) for accounts with a
security key / passkey (WebAuthn) enrolled, responding 401 with
"Basic authorization is not allowed while having security keys enrolled".
This was misclassified as .invalidCredentials, showing "Invalid username
or password" and sending users in circles. Add a dedicated
.basicAuthBlockedBySecurityKey case that tells the user to use a token.

Also extract AuthenticationError into its own file.
2026-06-15 18:43:55 +02:00
Stefan Hausotte
485c147e7f release 0.8.0 2026-06-15 12:06:48 +02:00
Stefan Hausotte
81c74f6dc3 chore: formatting and function names 2026-06-15 12:06:27 +02:00
Stefan Hausotte
b78c57ffd9 feat: add Attachment model and image upload support
- Add Attachment model with isImage computed property
- Add assets field to Issue, IssueComment, and PullRequest
- Add multipart/form-data upload support to ForgejoClient
- Add uploadIssueAttachment and uploadCommentAttachment to IssueService
2026-06-15 09:48:47 +02:00
Stefan Hausotte
4d1dfc1305 release 0.7.0 2026-06-04 11:29:39 +02:00
Stefan Hausotte
167d61bc06 chore: improved formatting and linting 2026-06-04 11:29:23 +02:00
systemblue
551e1d01ea feat: add .bearer credential for OAuth access tokens (#4) 2026-06-04 11:15:25 +02:00
Stefan Hausotte
7352bc4b1f release 0.6.1 2026-05-17 21:26:30 +02:00
Stefan Hausotte
03829084c8 refactor: small code refactoring 2026-05-17 21:26:19 +02:00
pdurlej
81d8bc5b4a fix: preserve merge error context (#3)
Problem
- Merge failures currently collapse 405 and 409 responses to generic ServiceError cases, even when Forgejo returns a useful response body.

Change
- Keeps the existing notMergeable and mergeConflict fallbacks for empty responses.
- Preserves non-empty Forgejo response bodies as ServiceError.httpError for merge failures.
- Extracts the common JSON message field for clearer LocalizedError descriptions.

Tests
- DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test

Co-authored-by: Piotr Durlej <pdurlej@users.noreply.github.com>
Reviewed-on: https://codeberg.org/secana/ForgejoKit/pulls/3
2026-05-17 21:19:39 +02:00
Stefan Hausotte
2ab5808c34 release 0.6.0 2026-05-12 17:42:44 +02:00
pdurlej
8a5e513c50 feat: classify auth and service errors (#2)
Problem
- Callers currently need to inspect raw status codes to distinguish authentication, permission, not-found, and server failures.

Change
- Adds a small HTTPErrorCategory for 401, 403, 404, and 5xx responses.
- Exposes httpStatusCode and httpErrorCategory on ServiceError and AuthenticationError.
- Keeps the existing AuthenticationError cases intact and centralizes status-to-auth-error mapping.

Tests
- DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer swift test

Co-authored-by: Piotr Durlej <pdurlej@users.noreply.github.com>
Reviewed-on: https://codeberg.org/secana/ForgejoKit/pulls/2
Reviewed-by: secana <secana@noreply.codeberg.org>
2026-05-12 17:41:24 +02:00
Stefan Hausotte
b87e4980a0 release 0.5.0 2026-05-07 18:56:59 +02:00
Stefan Hausotte
21149d4b45 feat: add "hasActions" flag to repo 2026-05-07 18:56:46 +02:00
Stefan Hausotte
3030629b36 release 0.4.0 2026-05-07 16:45:14 +02:00
Voislav Vasiljevski
3967a1ba79 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>
2026-05-07 16:42:16 +02:00
Stefan Hausotte
c31ce0d267 release 0.3.2 2026-03-22 11:49:58 +01:00
Stefan Hausotte
4c786f4f73 fix: pascal case needed for merge message an titel 2026-03-22 11:49:42 +01:00
32 changed files with 1471 additions and 168 deletions

View file

@ -1,2 +1,3 @@
--swiftversion 6.2
--disable redundantMemberwiseInit
--disable swiftTestingTestCaseNames

View file

@ -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

View file

@ -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

View 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"
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

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

View file

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

View file

@ -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

View 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))"
}
}
}
}

View file

@ -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
}
}

View file

@ -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,
)
}
}

View file

@ -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
}
}

View file

@ -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(

View file

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

View file

@ -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)

View 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)
}
}

View 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)
}
}

View file

@ -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)
}

View file

@ -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

View 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)
}
}

View file

@ -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,
)
}

View file

@ -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() {

View file

@ -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")

View file

@ -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 {

View file

@ -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")

View file

@ -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")

View file

@ -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)
}

View 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"))
}
}

View file

@ -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: