Compare commits

...

27 commits
0.1.0 ... 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
Stefan Hausotte
954d5b5064 release 0.3.1 2026-03-22 11:29:07 +01:00
Stefan Hausotte
7da2d2ce0d fix: missing merge titel in PRs 2026-03-22 11:29:01 +01:00
Stefan Hausotte
373b29a21b release 0.3.0 2026-03-22 11:15:21 +01:00
Stefan Hausotte
271839d21b feat: add fetchRepository function 2026-03-22 11:15:10 +01:00
Stefan Hausotte
a73f044623 release 0.2.0 2026-03-11 22:17:50 +01:00
Stefan Hausotte
f576906931 fix: lint errors 2026-03-11 22:17:20 +01:00
Stefan Hausotte
0c2fc12ded feat: add new models and services
New model:
  - Token — id, name, sha1, tokenLastEight

New services:
  - AdminService — createUser()
  - UserService — createToken()

RepositoryService additions:
  - createRepository()
  - editRepository()
  - createLabel()
  - createMilestone()
  - addCollaborator()
  - createFile()

remove MARKS
2026-03-11 21:57:40 +01:00
Stefan Hausotte
42c5ec0ea6 fix: failing release command 2026-02-28 20:33:38 +01:00
37 changed files with 1929 additions and 177 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.1.0"),
.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,25 @@
import Foundation
public struct Token: Codable, Identifiable, Sendable {
public let id: Int
public let name: String
public let sha1: String
public let tokenLastEight: String?
public init(
id: Int, name: String, sha1: String,
tokenLastEight: String? = nil,
) {
self.id = id
self.name = name
self.sha1 = sha1
self.tokenLastEight = tokenLastEight
}
enum CodingKeys: String, CodingKey {
case id
case name
case sha1
case tokenLastEight = "token_last_eight"
}
}

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,57 @@
import Foundation
private struct CreateUserPayload: Codable {
let username: String
let password: String
let email: String
let loginName: String
let fullName: String?
let mustChangePassword: Bool
let sendNotify: Bool
let sourceId: Int
let visibility: String?
enum CodingKeys: String, CodingKey {
case username
case password
case email
case loginName = "login_name"
case fullName = "full_name"
case mustChangePassword = "must_change_password"
case sendNotify = "send_notify"
case sourceId = "source_id"
case visibility
}
}
public final class AdminService: Sendable {
private let client: ForgejoClient
public init(client: ForgejoClient) {
self.client = client
}
public func createUser(
username: String, password: String, email: String,
fullName: String? = nil, mustChangePassword: Bool = false,
visibility: String? = nil,
) async throws -> User {
let url = try client.makeURL(path: "/api/v1/admin/users")
let payload = CreateUserPayload(
username: username,
password: password,
email: email,
loginName: username,
fullName: fullName,
mustChangePassword: mustChangePassword,
sendNotify: false,
sourceId: 0,
visibility: visibility,
)
let jsonData = try client.encodeRequestBody(payload)
return try await client.performRequest(
url: url, method: "POST", body: jsonData,
responseType: User.self,
)
}
}

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,15 +141,21 @@ public final class ForgejoClient: Sendable {
}
private func applyAuthHeader(to request: inout URLRequest) {
switch credential {
case let .basic(base64):
request.setValue("Basic \(base64)", forHTTPHeaderField: "Authorization")
case let .token(token):
request.setValue("token \(token)", forHTTPHeaderField: "Authorization")
}
request.setValue(authorizationHeaderValue, forHTTPHeaderField: "Authorization")
}
// MARK: - API Token creation
/// 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):
"Basic \(base64)"
case let .token(token):
"token \(token)"
case let .bearer(token):
"Bearer \(token)"
}
}
private static let tokenScopes = [
"read:user", "write:user",
@ -186,25 +204,24 @@ 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)
}
}
// MARK: - Internal request infrastructure
func authenticatedRequest(url: URL, method: String = "GET", body: Data? = nil) -> URLRequest {
var request = URLRequest(url: url)
request.httpMethod = method
@ -283,9 +300,46 @@ 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
}
}
// MARK: - URL helpers & normalization
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 {
/// Characters allowed in a single URL path segment (`.urlPathAllowed` minus `/`).
@ -318,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("/") {
@ -336,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
@ -374,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:
@ -383,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:
@ -392,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

@ -34,13 +34,14 @@ public final class PullRequestService: Sendable {
private struct MergePullRequestPayload: Codable {
let method: String
let deleteBranchAfterMerge: Bool
let mergeTitleField: String?
let mergeMessageField: String?
// swiftlint:disable:next nesting
enum CodingKeys: String, CodingKey {
case method = "Do"
case deleteBranchAfterMerge = "delete_branch_after_merge"
case mergeMessageField = "merge_message_field"
case mergeTitleField = "MergeTitleField"
case mergeMessageField = "MergeMessageField"
}
}
@ -56,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
@ -65,8 +65,6 @@ public final class PullRequestService: Sendable {
}
}
// MARK: - Pull Requests
public func fetchPullRequests(
owner: String, repo: String,
state: String = "open",
@ -156,33 +154,40 @@ 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, message: String?,
method: String, title: String? = nil, message: String? = nil,
deleteBranch: Bool,
) async throws {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/pulls/\(index)/merge")
let payload = MergePullRequestPayload(
method: method,
deleteBranchAfterMerge: deleteBranch,
mergeTitleField: title.nilIfEmpty,
mergeMessageField: message.nilIfEmpty,
)
let jsonData = try client.encodeRequestBody(payload)
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
}
}
@ -193,8 +198,6 @@ public final class PullRequestService: Sendable {
return try await client.performRequestRawText(url: url)
}
// MARK: - Comments (delegates to IssueService same endpoint)
public func fetchComments(
owner: String, repo: String, index: Int,
) async throws -> [IssueComment] {
@ -214,8 +217,6 @@ public final class PullRequestService: Sendable {
try await issueService.editComment(owner: owner, repo: repo, commentId: commentId, body: body)
}
// MARK: - Reviews
public func fetchReviews(
owner: String, repo: String, index: Int,
) async throws -> [PullRequestReview] {

View file

@ -1,5 +1,45 @@
import Foundation
private struct CreateRepositoryPayload: Codable {
let name: String
let description: String?
let `private`: Bool
let autoInit: Bool
enum CodingKeys: String, CodingKey {
case name
case description
case `private`
case autoInit = "auto_init"
}
}
private struct CreateMilestonePayload: Codable {
let title: String
let description: String?
let dueOn: String?
enum CodingKeys: String, CodingKey {
case title
case description
case dueOn = "due_on"
}
}
private struct CreateFilePayload: Codable {
let content: String
let message: String
let branch: String?
let newBranch: String?
enum CodingKeys: String, CodingKey {
case content
case message
case branch
case newBranch = "new_branch"
}
}
// swiftlint:disable:next type_body_length
public final class RepositoryService: Sendable {
private let client: ForgejoClient
@ -12,6 +52,34 @@ public final class RepositoryService: Sendable {
let data: [Repository]
}
private struct EditRepositoryPayload: Codable {
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 {
let name: String
let color: String
let description: String?
}
private struct CollaboratorPayload: Codable {
let permission: String
}
private struct CreateFileResponse: Codable {
let content: FileContent
}
private struct UpdateFilePayload: Codable {
let content: String
let sha: String
@ -34,6 +102,11 @@ public final class RepositoryService: Sendable {
let url: String
}
public func fetchRepository(owner: String, repo: String) async throws -> Repository {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "")
return try await client.performRequest(url: url, responseType: Repository.self)
}
public func fetchUserRepositories(
page: Int = 1, limit: Int = 20,
) async throws -> [Repository] {
@ -298,4 +371,111 @@ public final class RepositoryService: Sendable {
)
return response.content
}
public func createRepository(
name: String, description: String? = nil,
isPrivate: Bool = false, autoInit: Bool = false,
) async throws -> Repository {
let url = try client.makeURL(path: "/api/v1/user/repos")
let payload = CreateRepositoryPayload(
name: name,
description: description,
private: isPrivate,
autoInit: autoInit,
)
let jsonData = try client.encodeRequestBody(payload)
return try await client.performRequest(
url: url, method: "POST", body: jsonData,
responseType: Repository.self,
)
}
public func editRepository(
owner: String, repo: String,
archived: Bool? = nil, description: 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(
url: url, method: "PATCH", body: jsonData,
responseType: Repository.self,
)
}
public func createLabel(
owner: String, repo: String,
name: String, color: String, description: String? = nil,
) async throws -> IssueLabel {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/labels")
let payload = CreateLabelPayload(
name: name, color: color, description: description,
)
let jsonData = try client.encodeRequestBody(payload)
return try await client.performRequest(
url: url, method: "POST", body: jsonData,
responseType: IssueLabel.self,
)
}
public func createMilestone(
owner: String, repo: String,
title: String, description: String? = nil,
dueOn: String? = nil,
) async throws -> IssueMilestone {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/milestones")
let payload = CreateMilestonePayload(
title: title, description: description, dueOn: dueOn,
)
let jsonData = try client.encodeRequestBody(payload)
return try await client.performRequest(
url: url, method: "POST", body: jsonData,
responseType: IssueMilestone.self,
)
}
public func addCollaborator(
owner: String, repo: String,
username: String, permission: String,
) async throws {
let encodedUsername = ForgejoClient.encodedPathSegment(username)
let url = try client.makeRepoURL(
owner: owner, repo: repo,
path: "/collaborators/\(encodedUsername)",
)
let payload = CollaboratorPayload(permission: permission)
let jsonData = try client.encodeRequestBody(payload)
_ = try await client.performRequestNoContent(
url: url, method: "PUT", body: jsonData, validateStatus: true,
)
}
public func createFile(
owner: String, repo: String, path: String,
content: String, message: String,
branch: String? = nil, newBranch: String? = nil,
) async throws -> FileContent {
let encodedPath = path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? path
let url = try client.makeRepoURL(
owner: owner, repo: repo,
path: "/contents/\(encodedPath)",
)
let base64Content = Data(content.utf8).base64EncodedString()
let payload = CreateFilePayload(
content: base64Content, message: message,
branch: branch, newBranch: newBranch,
)
let jsonData = try client.encodeRequestBody(payload)
let response = try await client.performRequest(
url: url, method: "POST", body: jsonData,
responseType: CreateFileResponse.self,
)
return response.content
}
}

View file

@ -0,0 +1,27 @@
import Foundation
public final class UserService: Sendable {
private let client: ForgejoClient
public init(client: ForgejoClient) {
self.client = client
}
private struct CreateTokenPayload: Codable {
let name: String
let scopes: [String]
}
public func createToken(
username: String, name: String, scopes: [String],
) async throws -> Token {
let encodedUsername = ForgejoClient.encodedPathSegment(username)
let url = try client.makeURL(path: "/api/v1/users/\(encodedUsername)/tokens")
let payload = CreateTokenPayload(name: name, scopes: scopes)
let jsonData = try client.encodeRequestBody(payload)
return try await client.performRequest(
url: url, method: "POST", body: jsonData,
responseType: Token.self,
)
}
}

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

@ -0,0 +1,35 @@
@testable import ForgejoKit
import Foundation
import Testing
struct AdminServiceTests {
@Test func createUserPayloadEncodesCorrectKeys() throws {
let json = """
{
"username": "testbot",
"password": "testbot1234",
"email": "testbot@test.local",
"login_name": "testbot",
"full_name": "Test Bot",
"must_change_password": false,
"send_notify": false,
"source_id": 0,
"visibility": "public"
}
"""
let data = Data(json.utf8)
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)
#expect(dict["send_notify"] as? Bool == false)
#expect(dict["source_id"] as? Int == 0)
#expect(dict["visibility"] as? String == "public")
}
@Test func createUserURLConstruction() throws {
let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "admin", password: "pass")
let url = try client.makeURL(path: "/api/v1/admin/users")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/admin/users")
}
}

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)
}
@ -1120,6 +1119,169 @@ struct ModelDecodingTests {
#expect(repo.permissions == nil)
}
// MARK: - Token
@Test func decodesToken() throws {
let json = """
{
"id": 1,
"name": "integration-test-token",
"sha1": "abc123def456789",
"token_last_eight": "56789abc"
}
"""
let token = try decoder().decode(Token.self, from: Data(json.utf8))
#expect(token.id == 1)
#expect(token.name == "integration-test-token")
#expect(token.sha1 == "abc123def456789")
#expect(token.tokenLastEight == "56789abc")
}
@Test func decodesTokenWithMinimalFields() throws {
let json = """
{
"id": 2,
"name": "test",
"sha1": "xyz"
}
"""
let token = try decoder().decode(Token.self, from: Data(json.utf8))
#expect(token.id == 2)
#expect(token.name == "test")
#expect(token.sha1 == "xyz")
#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")
@ -60,4 +59,82 @@ struct RepositoryServiceURLTests {
#expect(queryItems.first?.name == "ref")
#expect(queryItems.first?.value == "main")
}
// MARK: - createRepository URL
@Test func createRepositoryURL() throws {
let client = makeClient()
let url = try client.makeURL(path: "/api/v1/user/repos")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/user/repos")
}
// MARK: - editRepository URL
@Test func editRepositoryURL() throws {
let client = makeClient()
let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo")
}
// MARK: - createLabel URL
@Test func createLabelURL() throws {
let client = makeClient()
let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "/labels")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/labels")
}
// MARK: - createMilestone URL
@Test func createMilestoneURL() throws {
let client = makeClient()
let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "/milestones")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/milestones")
}
// MARK: - addCollaborator URL
@Test func addCollaboratorURL() throws {
let client = makeClient()
let encoded = ForgejoClient.encodedPathSegment("testbot")
let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "/collaborators/\(encoded)")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/collaborators/testbot")
}
// MARK: - createFile URL
@Test func createFileURL() throws {
let client = makeClient()
let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "/contents/hello.py")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/contents/hello.py")
}
// MARK: - Payload encoding
@Test func createRepositoryPayloadEncoding() throws {
let payload: [String: Any] = [
"name": "test-repo",
"description": "A test repo",
"private": false,
"auto_init": true,
]
let data = try JSONSerialization.data(withJSONObject: payload)
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)
}
@Test func createFilePayloadEncoding() throws {
let payload: [String: Any] = [
"content": "base64content",
"message": "Add file",
"new_branch": "feature-branch",
]
let data = try JSONSerialization.data(withJSONObject: payload)
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

@ -0,0 +1,29 @@
@testable import ForgejoKit
import Foundation
import Testing
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 #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
#expect(dict["name"] as? String == "integration-test-token")
#expect((dict["scopes"] as? [String])?.count == 2)
}
@Test func createTokenURLConstruction() throws {
let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "admin", password: "pass")
let url = try client.makeURL(path: "/api/v1/users/\(ForgejoClient.encodedPathSegment("testuser"))/tokens")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/users/testuser/tokens")
}
@Test func createTokenURLEncodesSpecialCharacters() throws {
let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "admin", password: "pass")
let encoded = ForgejoClient.encodedPathSegment("user name")
let url = try client.makeURL(path: "/api/v1/users/\(encoded)/tokens")
#expect(url.absoluteString.contains("user%20name"))
}
}

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

@ -20,11 +20,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1772173633,
"narHash": "sha256-MOH58F4AIbCkh6qlQcwMycyk5SWvsqnS/TCfnqDlpj4=",
"lastModified": 1773110118,
"narHash": "sha256-mPAG8phMbCReKSiKAijjjd3v7uVcJOQ75gSjGJjt/Rk=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c0f3d81a7ddbc2b1332be0d8481a672b4f6004d6",
"rev": "e607cb5360ff1234862ac9f8839522becb853bb9",
"type": "github"
},
"original": {

View file

@ -14,11 +14,10 @@
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
devShells.default = pkgs.mkShellNoCC {
packages =
[
pkgs.just
pkgs.swift
pkgs.swiftformat
]
++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isDarwin [

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:
@ -23,7 +23,7 @@ release version:
fi
sed -i.bak 's/from: "[^"]*"/from: "{{version}}"/' README.md && rm -f README.md.bak
git add README.md
git commit -m "release {{version}}"
git diff --cached --quiet || git commit -m "release {{version}}"
git tag "{{version}}"
git push
git push origin "{{version}}"