feat: initial commit

Intitial commit for ForgejoKit, a native Swift library to interact with
the Frogejo API
This commit is contained in:
Stefan Hausotte 2026-02-28 20:10:51 +01:00
commit 897a8ebedd
37 changed files with 5040 additions and 0 deletions

1
.envrc Normal file
View file

@ -0,0 +1 @@
use flake

68
.gitignore vendored Normal file
View file

@ -0,0 +1,68 @@
# ---> Swift
# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
## User settings
xcuserdata/
## Obj-C/Swift specific
*.hmap
## App packaging
*.ipa
*.dSYM.zip
*.dSYM
## Playgrounds
timeline.xctimeline
playground.xcworkspace
# Swift Package Manager
#
# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies.
# Packages/
# Package.pins
# Package.resolved
# *.xcodeproj
#
# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata
# hence it is not needed unless you have added a package configuration file to your project
# .swiftpm
.build/
# CocoaPods
#
# We recommend against adding the Pods directory to your .gitignore. However
# you should judge for yourself, the pros and cons are mentioned at:
# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control
#
# Pods/
#
# Add this line if you want to avoid checking in source code from the Xcode workspace
# *.xcworkspace
# Carthage
#
# Add this line if you want to avoid checking in source code from Carthage dependencies.
# Carthage/Checkouts
Carthage/Build/
# fastlane
#
# It is recommended to not store the screenshots in the git repo.
# Instead, use fastlane to re-generate the screenshots whenever they are needed.
# For more information about the recommended setup visit:
# https://docs.fastlane.tools/best-practices/source-control/#source-control
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots/**/*.png
fastlane/test_output
# Integration test snapshot cache
integration/.forgejo-seed-snapshot.tar.gz
integration/.forgejo-seed-hash

2
.swiftformat Normal file
View file

@ -0,0 +1,2 @@
--swiftversion 6.2
--disable redundantMemberwiseInit

5
.swiftlint.yml Normal file
View file

@ -0,0 +1,5 @@
# Style/formatting rules are owned by SwiftFormat.
# SwiftLint focuses on code quality and safety.
disabled_rules:
- trailing_comma
- opening_brace

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Stefan Hausotte
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

18
Package.swift Normal file
View file

@ -0,0 +1,18 @@
// swift-tools-version: 6.0
import PackageDescription
let package = Package(
name: "ForgejoKit",
platforms: [
.iOS(.v18),
.macOS(.v15),
],
products: [
.library(name: "ForgejoKit", targets: ["ForgejoKit"]),
],
targets: [
.target(name: "ForgejoKit"),
.testTarget(name: "ForgejoKitTests", dependencies: ["ForgejoKit"]),
]
)

91
README.md Normal file
View file

@ -0,0 +1,91 @@
# ForgejoKit
A native Swift library for the [Forgejo](https://forgejo.org) API. ForgejoKit provides a typed, async/await interface for interacting with Forgejo instances from Swift applications on macOS and iOS.
## Features
- Authentication via username/password (with optional OTP) or API 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
- Notifications: list, unread count, mark as read
- Self-signed certificate support
## Installation
Add ForgejoKit as a dependency in your `Package.swift`:
```swift
dependencies: [
.package(url: "https://codeberg.org/secana/ForgejoKit.git", from: "0.1.0"),
],
targets: [
.target(
name: "MyApp",
dependencies: ["ForgejoKit"]
),
]
```
## Usage
```swift
import ForgejoKit
// Authenticate with a token
let client = ForgejoClient(serverURL: "https://codeberg.org", username: "user", token: "your-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
// Fetch repositories
let repoService = RepositoryService(client: client)
let repos = try await repoService.searchRepositories(query: "swift")
// Work with issues
let issueService = IssueService(client: client)
let issues = try await issueService.fetchIssues(owner: "owner", repo: "repo")
// Check notifications
let notificationService = NotificationService(client: client)
let unread = try await notificationService.fetchUnreadCount()
```
## Development
### Prerequisites
Install [Nix](https://nixos.org/download) and enable [flakes](https://wiki.nixos.org/wiki/Flakes).
### Dev Shell
Enter the development shell with all required tools:
```sh
nix develop
```
This provides `swift`, `swiftformat`, and `just` on all platforms, plus `swiftlint` and `xcbeautify` on macOS.
### Building and Testing
The project uses [just](https://github.com/casey/just) as a command runner. Run `just` to see all available commands:
```sh
just # list available commands
just build # build the library
just test # run tests
just lint # lint sources with swiftlint
just format # format sources with swiftformat
```
## Releasing a New Version
Make sure all changes are committed and pushed, then run:
```sh
just release 1.0.0
```
This will verify the tag doesn't already exist, update the version in `README.md`, commit, tag, and push. Swift Package Manager resolves versions from git tags, so no registry publication is needed.

View file

@ -0,0 +1,35 @@
import Foundation
private nonisolated(unsafe) let relativeDateFormatter: RelativeDateTimeFormatter = {
let formatter = RelativeDateTimeFormatter()
formatter.unitsStyle = .short
return formatter
}()
public func formatRelativeDate(_ date: Date) -> String {
relativeDateFormatter.localizedString(for: date, relativeTo: Date())
}
private nonisolated(unsafe) let iso8601WithFractional: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return formatter
}()
private nonisolated(unsafe) let iso8601WithoutFractional: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
return formatter
}()
public let forgejoDateDecodingStrategy: JSONDecoder.DateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
if let date = iso8601WithFractional.date(from: string) {
return date
}
if let date = iso8601WithoutFractional.date(from: string) {
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date: \(string)")
}

View file

@ -0,0 +1,24 @@
import Foundation
public func describeDecodingError(_ error: DecodingError) -> String {
switch error {
case let .keyNotFound(key, context):
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
let location = path.isEmpty ? "root" : path
return "Missing key '\(key.stringValue)' at \(location)"
case let .typeMismatch(type, context):
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
let location = path.isEmpty ? "root" : path
return "Type mismatch for \(type) at \(location)"
case let .valueNotFound(type, context):
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
let location = path.isEmpty ? "root" : path
return "Null value for \(type) at \(location)"
case let .dataCorrupted(context):
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
let location = path.isEmpty ? "root" : path
return "Data corrupted at \(location): \(context.debugDescription)"
@unknown default:
return error.localizedDescription
}
}

View file

@ -0,0 +1,260 @@
import Foundation
public struct InlineCommentContext: Identifiable, Sendable {
public let id = UUID()
public let line: DiffLine
public let path: String
public init(line: DiffLine, path: String) {
self.line = line
self.path = path
}
}
public struct ParsedDiff: Sendable {
public let files: [DiffFile]
public init(files: [DiffFile]) {
self.files = files
}
}
public struct DiffFile: Sendable {
public let oldName: String
public let newName: String
public let hunks: [DiffHunk]
public init(oldName: String, newName: String, hunks: [DiffHunk]) {
self.oldName = oldName
self.newName = newName
self.hunks = hunks
}
}
public struct DiffHunk: Sendable {
public let header: String
public let lines: [DiffLine]
public init(header: String, lines: [DiffLine]) {
self.header = header
self.lines = lines
}
}
public struct DiffLine: Identifiable, Sendable {
public let id = UUID()
public let type: DiffLineType
public let content: String
public let oldLineNumber: Int?
public let newLineNumber: Int?
public let diffPosition: Int?
public init(type: DiffLineType, content: String, oldLineNumber: Int?, newLineNumber: Int?, diffPosition: Int?) {
self.type = type
self.content = content
self.oldLineNumber = oldLineNumber
self.newLineNumber = newLineNumber
self.diffPosition = diffPosition
}
}
public enum DiffLineType: Sendable {
case context
case addition
case deletion
case header
}
public enum DiffParser {
public static func parse(_ rawDiff: String) -> ParsedDiff {
let fileChunks = splitByFiles(rawDiff)
let files = fileChunks.compactMap { parseFile($0) }
return ParsedDiff(files: files)
}
private static func splitByFiles(_ raw: String) -> [String] {
let pattern = "diff --git "
var chunks: [String] = []
var current = ""
for line in raw.components(separatedBy: "\n") {
if line.hasPrefix(pattern) {
if !current.isEmpty {
chunks.append(current)
}
current = line + "\n"
} else {
current += line + "\n"
}
}
if !current.isEmpty {
chunks.append(current)
}
return chunks
}
private static func parseFile(_ chunk: String) -> DiffFile? {
var lines = chunk.components(separatedBy: "\n")
while lines.last?.isEmpty == true {
lines.removeLast()
}
guard !lines.isEmpty else { return nil }
let names = parseFileNames(lines)
let hunks = parseHunks(lines)
if names.old.isEmpty, names.new.isEmpty, hunks.isEmpty {
return nil
}
return DiffFile(oldName: names.old, newName: names.new, hunks: hunks)
}
private static func parseFileNames(
_ lines: [String],
) -> (old: String, new: String) {
var oldName = ""
var newName = ""
for line in lines {
if line.hasPrefix("--- a/") {
oldName = String(line.dropFirst(6))
} else if line.hasPrefix("+++ b/") {
newName = String(line.dropFirst(6))
} else if line.hasPrefix("--- /dev/null") {
oldName = "/dev/null"
} else if line.hasPrefix("+++ /dev/null") {
newName = "/dev/null"
}
}
if oldName.isEmpty, newName.isEmpty {
if let first = lines.first,
first.hasPrefix("diff --git ")
{
let parts = first.dropFirst("diff --git ".count)
let components = parts.components(separatedBy: " ")
if components.count >= 2 {
oldName = String(components[0].dropFirst(2))
newName = String(components[1].dropFirst(2))
}
}
}
return (oldName, newName)
}
private static func parseHunks(_ lines: [String]) -> [DiffHunk] {
var hunks: [DiffHunk] = []
var currentHunkLines: [DiffLine] = []
var currentHeader = ""
var oldLine = 0
var newLine = 0
var diffPosition = 0
var inHunk = false
for line in lines {
if line.hasPrefix("@@") {
if inHunk {
hunks.append(DiffHunk(
header: currentHeader,
lines: currentHunkLines,
))
}
currentHeader = line
currentHunkLines = [DiffLine(
type: .header, content: line,
oldLineNumber: nil, newLineNumber: nil,
diffPosition: nil,
)]
inHunk = true
let (parsedOld, parsedNew) = parseHunkHeader(line)
oldLine = parsedOld
newLine = parsedNew
} else if inHunk {
parseContentLine(
line, into: &currentHunkLines,
oldLine: &oldLine, newLine: &newLine,
diffPosition: &diffPosition,
)
}
}
if inHunk {
hunks.append(DiffHunk(
header: currentHeader,
lines: currentHunkLines,
))
}
return hunks
}
private static func parseContentLine(
_ line: String,
into hunkLines: inout [DiffLine],
oldLine: inout Int,
newLine: inout Int,
diffPosition: inout Int,
) {
if line.hasPrefix("\\ ") {
return
} else if line.hasPrefix("+") {
diffPosition += 1
hunkLines.append(DiffLine(
type: .addition,
content: String(line.dropFirst()),
oldLineNumber: nil,
newLineNumber: newLine,
diffPosition: diffPosition,
))
newLine += 1
} else if line.hasPrefix("-") {
diffPosition += 1
hunkLines.append(DiffLine(
type: .deletion,
content: String(line.dropFirst()),
oldLineNumber: oldLine,
newLineNumber: nil,
diffPosition: diffPosition,
))
oldLine += 1
} else if line.hasPrefix(" ") || line.isEmpty {
diffPosition += 1
let content = line.isEmpty ? "" : String(line.dropFirst())
hunkLines.append(DiffLine(
type: .context,
content: content,
oldLineNumber: oldLine,
newLineNumber: newLine,
diffPosition: diffPosition,
))
oldLine += 1
newLine += 1
}
}
private static func parseHunkHeader(_ header: String) -> (oldStart: Int, newStart: Int) {
let stripped = header
.replacingOccurrences(of: "@@", with: "")
.trimmingCharacters(in: .whitespaces)
let parts = stripped.components(separatedBy: " ")
var oldStart = 1
var newStart = 1
for part in parts {
if part.hasPrefix("-") {
let nums = String(part.dropFirst()).components(separatedBy: ",")
oldStart = Int(nums[0]) ?? 1
} else if part.hasPrefix("+") {
let nums = String(part.dropFirst()).components(separatedBy: ",")
newStart = Int(nums[0]) ?? 1
}
}
return (oldStart, newStart)
}
}

View file

@ -0,0 +1,5 @@
extension Optional where Wrapped: Collection {
var nilIfEmpty: Self {
self?.isEmpty == true ? nil : self
}
}

View file

@ -0,0 +1,91 @@
import Foundation
public struct Commit: Codable, Identifiable, Sendable {
public let sha: String
public let url: String?
public let htmlUrl: String?
public let commit: CommitDetail
public let author: User?
public let committer: User?
public let parents: [CommitParent]?
public var id: String {
sha
}
public init(
sha: String,
url: String? = nil,
htmlUrl: String? = nil,
commit: CommitDetail,
author: User? = nil,
committer: User? = nil,
parents: [CommitParent]? = nil,
) {
self.sha = sha
self.url = url
self.htmlUrl = htmlUrl
self.commit = commit
self.author = author
self.committer = committer
self.parents = parents
}
enum CodingKeys: String, CodingKey {
case sha
case url
case htmlUrl = "html_url"
case commit
case author
case committer
case parents
}
}
public struct CommitDetail: Codable, Sendable {
public let url: String?
public let message: String
public let author: CommitSignature?
public let committer: CommitSignature?
public init(
url: String? = nil, message: String,
author: CommitSignature? = nil,
committer: CommitSignature? = nil,
) {
self.url = url
self.message = message
self.author = author
self.committer = committer
}
}
public struct CommitSignature: Codable, Sendable {
public let name: String
public let email: String
public let date: Date?
public init(name: String, email: String, date: Date? = nil) {
self.name = name
self.email = email
self.date = date
}
}
public struct CommitParent: Codable, Sendable {
public let sha: String
public let url: String?
public let htmlUrl: String?
public init(sha: String, url: String? = nil, htmlUrl: String? = nil) {
self.sha = sha
self.url = url
self.htmlUrl = htmlUrl
}
enum CodingKeys: String, CodingKey {
case sha
case url
case htmlUrl = "html_url"
}
}

View file

@ -0,0 +1,127 @@
import Foundation
public struct Issue: Codable, Identifiable, Sendable {
public let id: Int
public let number: Int
public let title: String
public let body: String?
public let state: String
public let user: User
public let labels: [IssueLabel]
public let milestone: IssueMilestone?
public let assignees: [User]?
public let createdAt: Date
public let updatedAt: Date
public let closedAt: Date?
public let dueDate: Date?
public let pullRequest: IssuePullRequest?
public let comments: Int
public let repository: Repository?
public init(
id: Int, number: Int, title: String,
body: String? = nil, state: String, user: User,
labels: [IssueLabel] = [],
milestone: IssueMilestone? = nil,
assignees: [User]? = nil,
createdAt: Date, updatedAt: Date,
closedAt: Date? = nil, dueDate: Date? = nil,
pullRequest: IssuePullRequest? = nil,
comments: Int = 0, repository: Repository? = nil,
) {
self.id = id
self.number = number
self.title = title
self.body = body
self.state = state
self.user = user
self.labels = labels
self.milestone = milestone
self.assignees = assignees
self.createdAt = createdAt
self.updatedAt = updatedAt
self.closedAt = closedAt
self.dueDate = dueDate
self.pullRequest = pullRequest
self.comments = comments
self.repository = repository
}
enum CodingKeys: String, CodingKey {
case id
case number
case title
case body
case state
case user
case labels
case milestone
case assignees
case createdAt = "created_at"
case updatedAt = "updated_at"
case closedAt = "closed_at"
case dueDate = "due_date"
case pullRequest = "pull_request"
case comments
case repository
}
}
public struct IssueLabel: Codable, Identifiable, Sendable {
public let id: Int
public let name: String
public let color: String
public let description: String?
public init(id: Int, name: String, color: String, description: String? = nil) {
self.id = id
self.name = name
self.color = color
self.description = description
}
}
public struct IssueMilestone: Codable, Identifiable, Sendable {
public let id: Int
public let title: String
public let description: String?
public let state: String
public let dueOn: Date?
public let closedAt: Date?
public init(
id: Int, title: String, description: String? = nil,
state: String, dueOn: Date? = nil, closedAt: Date? = nil,
) {
self.id = id
self.title = title
self.description = description
self.state = state
self.dueOn = dueOn
self.closedAt = closedAt
}
enum CodingKeys: String, CodingKey {
case id
case title
case description
case state
case dueOn = "due_on"
case closedAt = "closed_at"
}
}
public struct IssuePullRequest: Codable, Sendable {
public let merged: Bool?
public let mergedAt: Date?
public init(merged: Bool? = nil, mergedAt: Date? = nil) {
self.merged = merged
self.mergedAt = mergedAt
}
enum CodingKeys: String, CodingKey {
case merged
case mergedAt = "merged_at"
}
}

View file

@ -0,0 +1,25 @@
import Foundation
public struct IssueComment: Codable, Identifiable, Sendable {
public let id: Int
public let body: String
public let user: User
public let createdAt: Date
public let updatedAt: Date
public init(id: Int, body: String, user: User, createdAt: Date, updatedAt: Date) {
self.id = id
self.body = body
self.user = user
self.createdAt = createdAt
self.updatedAt = updatedAt
}
enum CodingKeys: String, CodingKey {
case id
case body
case user
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}

View file

@ -0,0 +1,76 @@
import Foundation
public struct NotificationThread: Codable, Identifiable, Sendable {
public let id: Int
public let unread: Bool
public let pinned: Bool
public let updatedAt: Date
public let url: String?
public let subject: NotificationSubject
public let repository: Repository
public init(
id: Int, unread: Bool, pinned: Bool, updatedAt: Date,
url: String? = nil, subject: NotificationSubject,
repository: Repository,
) {
self.id = id
self.unread = unread
self.pinned = pinned
self.updatedAt = updatedAt
self.url = url
self.subject = subject
self.repository = repository
}
enum CodingKeys: String, CodingKey {
case id, unread, pinned
case updatedAt = "updated_at"
case url, subject, repository
}
}
public struct NotificationSubject: Codable, Sendable {
public let type: String
public let title: String
public let url: String?
public let htmlUrl: String?
public let state: String?
public let latestCommentUrl: String?
public let latestCommentHtmlUrl: String?
public init(
type: String, title: String, url: String? = nil,
htmlUrl: String? = nil, state: String? = nil,
latestCommentUrl: String? = nil,
latestCommentHtmlUrl: String? = nil,
) {
self.type = type
self.title = title
self.url = url
self.htmlUrl = htmlUrl
self.state = state
self.latestCommentUrl = latestCommentUrl
self.latestCommentHtmlUrl = latestCommentHtmlUrl
}
enum CodingKeys: String, CodingKey {
case type, title, url, state
case htmlUrl = "html_url"
case latestCommentUrl = "latest_comment_url"
case latestCommentHtmlUrl = "latest_comment_html_url"
}
}
public struct NotificationCount: Codable, Sendable {
public let new: Int
public init(new: Int) {
self.new = new
}
}
public func notificationSubjectNumber(from urlString: String?) -> Int? {
guard let urlString, let parsed = URL(string: urlString) else { return nil }
return Int(parsed.lastPathComponent)
}

View file

@ -0,0 +1,122 @@
import Foundation
public struct PullRequest: Codable, Identifiable, Sendable {
public let id: Int
public let number: Int
public let title: String
public let body: String?
public let state: String
public let user: User
public let labels: [IssueLabel]
public let milestone: IssueMilestone?
public let assignees: [User]?
public let head: PRBranchRef
public let base: PRBranchRef
public let mergeable: Bool?
public let merged: Bool?
public let mergedBy: User?
public let requestedReviewers: [User]?
public let draft: Bool?
public let comments: Int
public let createdAt: Date
public let updatedAt: Date
public let closedAt: Date?
public let mergedAt: Date?
public let htmlUrl: String?
public init(
id: Int, number: Int, title: String,
body: String? = nil, state: String, user: User,
labels: [IssueLabel] = [],
milestone: IssueMilestone? = nil,
assignees: [User]? = nil,
head: PRBranchRef, base: PRBranchRef,
mergeable: Bool? = nil, merged: Bool? = nil,
mergedBy: User? = nil,
requestedReviewers: [User]? = nil,
draft: Bool? = nil, comments: Int = 0,
createdAt: Date, updatedAt: Date,
closedAt: Date? = nil, mergedAt: Date? = nil,
htmlUrl: String? = nil,
) {
self.id = id
self.number = number
self.title = title
self.body = body
self.state = state
self.user = user
self.labels = labels
self.milestone = milestone
self.assignees = assignees
self.head = head
self.base = base
self.mergeable = mergeable
self.merged = merged
self.mergedBy = mergedBy
self.requestedReviewers = requestedReviewers
self.draft = draft
self.comments = comments
self.createdAt = createdAt
self.updatedAt = updatedAt
self.closedAt = closedAt
self.mergedAt = mergedAt
self.htmlUrl = htmlUrl
}
enum CodingKeys: String, CodingKey {
case id
case number
case title
case body
case state
case user
case labels
case milestone
case assignees
case head
case base
case mergeable
case merged
case mergedBy = "merged_by"
case requestedReviewers = "requested_reviewers"
case draft
case comments
case createdAt = "created_at"
case updatedAt = "updated_at"
case closedAt = "closed_at"
case mergedAt = "merged_at"
case htmlUrl = "html_url"
}
}
public struct PRBranchRef: Codable, Sendable {
public let label: String
public let ref: String
public let sha: String
public let repo: PRRepo?
public init(label: String, ref: String, sha: String, repo: PRRepo? = nil) {
self.label = label
self.ref = ref
self.sha = sha
self.repo = repo
}
}
public struct PRRepo: Codable, Sendable {
public let id: Int
public let name: String
public let fullName: String
public init(id: Int, name: String, fullName: String) {
self.id = id
self.name = name
self.fullName = fullName
}
enum CodingKeys: String, CodingKey {
case id
case name
case fullName = "full_name"
}
}

View file

@ -0,0 +1,111 @@
import Foundation
public struct PullRequestReview: Codable, Identifiable, Sendable {
public let id: Int
public let body: String?
public let user: User?
public let state: String
public let commentsCount: Int?
public let submittedAt: Date?
public let updatedAt: Date?
public let commitId: String?
public let stale: Bool?
public let official: Bool?
public let dismissed: Bool?
public init(
id: Int, body: String? = nil, user: User? = nil,
state: String, commentsCount: Int? = nil,
submittedAt: Date? = nil, updatedAt: Date? = nil,
commitId: String? = nil, stale: Bool? = nil,
official: Bool? = nil, dismissed: Bool? = nil,
) {
self.id = id
self.body = body
self.user = user
self.state = state
self.commentsCount = commentsCount
self.submittedAt = submittedAt
self.updatedAt = updatedAt
self.commitId = commitId
self.stale = stale
self.official = official
self.dismissed = dismissed
}
enum CodingKeys: String, CodingKey {
case id
case body
case user
case state
case commentsCount = "comments_count"
case submittedAt = "submitted_at"
case updatedAt = "updated_at"
case commitId = "commit_id"
case stale
case official
case dismissed
}
}
public struct ReviewComment: Codable, Identifiable, Sendable {
public let id: Int
public let body: String
public let user: User?
public let path: String
public let diffHunk: String?
public let position: Int?
public let originalPosition: Int?
public let createdAt: Date?
public let updatedAt: Date?
public init(
id: Int, body: String, user: User? = nil,
path: String, diffHunk: String? = nil,
position: Int? = nil, originalPosition: Int? = nil,
createdAt: Date? = nil, updatedAt: Date? = nil,
) {
self.id = id
self.body = body
self.user = user
self.path = path
self.diffHunk = diffHunk
self.position = position
self.originalPosition = originalPosition
self.createdAt = createdAt
self.updatedAt = updatedAt
}
enum CodingKeys: String, CodingKey {
case id
case body
case user
case path
case diffHunk = "diff_hunk"
case position
case originalPosition = "original_position"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
public struct CreateReviewComment: Encodable, Sendable {
public let body: String
public let path: String
public let oldPosition: Int?
public let newPosition: Int?
public init(body: String, path: String, oldPosition: Int? = nil, newPosition: Int? = nil) {
self.body = body
self.path = path
self.oldPosition = oldPosition
self.newPosition = newPosition
}
enum CodingKeys: String, CodingKey {
case body
case path
case oldPosition = "old_position"
case newPosition = "new_position"
}
}

View file

@ -0,0 +1,229 @@
import Foundation
public struct Repository: Codable, Identifiable, Equatable, Hashable, Sendable {
public let id: Int
public let name: String
public let fullName: String
public let description: String?
public let empty: Bool?
public let `private`: Bool?
public let fork: Bool?
public let parent: ParentRepository?
public let mirror: Bool?
public let size: Int?
public let language: String?
public let languagesUrl: String?
public let htmlUrl: String?
public let sshUrl: String?
public let cloneUrl: String?
public let website: String?
public let starsCount: Int?
public let forksCount: Int?
public let watchersCount: Int?
public let openIssuesCount: Int?
public let openPrCounter: Int?
public let releaseCounter: Int?
public let defaultBranch: String?
public let archived: Bool?
public let createdAt: Date?
public let updatedAt: Date?
public let permissions: RepositoryPermissions?
public let hasIssues: Bool?
public let internalTracker: InternalTracker?
public let hasWiki: Bool?
public let hasPullRequests: Bool?
public let hasProjects: Bool?
public let hasReleases: Bool?
public let hasPackages: Bool?
public let hasActions: Bool?
public let template: Bool?
public let avatarUrl: String?
public init(
id: Int,
name: String,
fullName: String,
description: String? = nil,
empty: Bool? = nil,
private: Bool? = nil,
fork: Bool? = nil,
parent: ParentRepository? = nil,
mirror: Bool? = nil,
size: Int? = nil,
language: String? = nil,
languagesUrl: String? = nil,
htmlUrl: String? = nil,
sshUrl: String? = nil,
cloneUrl: String? = nil,
website: String? = nil,
starsCount: Int? = nil,
forksCount: Int? = nil,
watchersCount: Int? = nil,
openIssuesCount: Int? = nil,
openPrCounter: Int? = nil,
releaseCounter: Int? = nil,
defaultBranch: String? = nil,
archived: Bool? = nil,
createdAt: Date? = nil,
updatedAt: Date? = nil,
permissions: RepositoryPermissions? = nil,
hasIssues: Bool? = nil,
internalTracker: InternalTracker? = nil,
hasWiki: Bool? = nil,
hasPullRequests: Bool? = nil,
hasProjects: Bool? = nil,
hasReleases: Bool? = nil,
hasPackages: Bool? = nil,
hasActions: Bool? = nil,
template: Bool? = nil,
avatarUrl: String? = nil,
) {
self.id = id
self.name = name
self.fullName = fullName
self.description = description
self.empty = empty
self.private = `private`
self.fork = fork
self.parent = parent
self.mirror = mirror
self.size = size
self.language = language
self.languagesUrl = languagesUrl
self.htmlUrl = htmlUrl
self.sshUrl = sshUrl
self.cloneUrl = cloneUrl
self.website = website
self.starsCount = starsCount
self.forksCount = forksCount
self.watchersCount = watchersCount
self.openIssuesCount = openIssuesCount
self.openPrCounter = openPrCounter
self.releaseCounter = releaseCounter
self.defaultBranch = defaultBranch
self.archived = archived
self.createdAt = createdAt
self.updatedAt = updatedAt
self.permissions = permissions
self.hasIssues = hasIssues
self.internalTracker = internalTracker
self.hasWiki = hasWiki
self.hasPullRequests = hasPullRequests
self.hasProjects = hasProjects
self.hasReleases = hasReleases
self.hasPackages = hasPackages
self.hasActions = hasActions
self.template = template
self.avatarUrl = avatarUrl
}
public var owner: String {
String(fullName.split(separator: "/").first ?? "")
}
public var repoName: String {
name
}
enum CodingKeys: String, CodingKey {
case id
case name
case fullName = "full_name"
case description
case empty
case `private`
case fork
case parent
case mirror
case size
case language
case languagesUrl = "languages_url"
case htmlUrl = "html_url"
case sshUrl = "ssh_url"
case cloneUrl = "clone_url"
case website
case starsCount = "stars_count"
case forksCount = "forks_count"
case watchersCount = "watchers_count"
case openIssuesCount = "open_issues_count"
case openPrCounter = "open_pr_counter"
case releaseCounter = "release_counter"
case defaultBranch = "default_branch"
case archived
case createdAt = "created_at"
case updatedAt = "updated_at"
case permissions
case hasIssues = "has_issues"
case internalTracker = "internal_tracker"
case hasWiki = "has_wiki"
case hasPullRequests = "has_pull_requests"
case hasProjects = "has_projects"
case hasReleases = "has_releases"
case hasPackages = "has_packages"
case hasActions = "has_actions"
case template
case avatarUrl = "avatar_url"
}
public static func == (lhs: Repository, rhs: Repository) -> Bool {
lhs.id == rhs.id
}
public func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}
public struct RepositoryPermissions: Codable, Hashable, Sendable {
public let admin: Bool
public let push: Bool
public let pull: Bool
public init(admin: Bool, push: Bool, pull: Bool) {
self.admin = admin
self.push = push
self.pull = pull
}
}
public struct InternalTracker: Codable, Hashable, Sendable {
public let enableTimeTracker: Bool
public let allowOnlyContributorsToTrackTime: Bool
public let enableIssueDependencies: Bool
public init(enableTimeTracker: Bool, allowOnlyContributorsToTrackTime: Bool, enableIssueDependencies: Bool) {
self.enableTimeTracker = enableTimeTracker
self.allowOnlyContributorsToTrackTime = allowOnlyContributorsToTrackTime
self.enableIssueDependencies = enableIssueDependencies
}
enum CodingKeys: String, CodingKey {
case enableTimeTracker = "enable_time_tracker"
case allowOnlyContributorsToTrackTime = "allow_only_contributors_to_track_time"
case enableIssueDependencies = "enable_issue_dependencies"
}
}
public struct ParentRepository: Codable, Hashable, Sendable {
public let id: Int
public let name: String
public let fullName: String
public let description: String?
public let htmlUrl: String
public init(id: Int, name: String, fullName: String, description: String? = nil, htmlUrl: String) {
self.id = id
self.name = name
self.fullName = fullName
self.description = description
self.htmlUrl = htmlUrl
}
enum CodingKeys: String, CodingKey {
case id
case name
case fullName = "full_name"
case description
case htmlUrl = "html_url"
}
}

View file

@ -0,0 +1,145 @@
import Foundation
public struct RepositoryContent: Codable, Identifiable, Sendable {
public let name: String
public let path: String
public let sha: String
public let size: Int
public let url: String
public let htmlUrl: String
public let gitUrl: String
public let downloadUrl: String?
public let type: ContentType
public let target: String?
public var id: String {
path
}
public init(
name: String, path: String, sha: String, size: Int,
url: String, htmlUrl: String, gitUrl: String,
downloadUrl: String? = nil, type: ContentType,
target: String? = nil,
) {
self.name = name
self.path = path
self.sha = sha
self.size = size
self.url = url
self.htmlUrl = htmlUrl
self.gitUrl = gitUrl
self.downloadUrl = downloadUrl
self.type = type
self.target = target
}
public enum ContentType: String, Codable, Sendable {
case file
case dir
case symlink
case submodule
}
enum CodingKeys: String, CodingKey {
case name
case path
case sha
case size
case url
case htmlUrl = "html_url"
case gitUrl = "git_url"
case downloadUrl = "download_url"
case type
case target
}
}
public struct FileContent: Codable, Sendable {
public let name: String
public let path: String
public let sha: String
public let size: Int
public let url: String
public let htmlUrl: String
public let gitUrl: String
public let downloadUrl: String?
public let type: String
public let content: String?
public let encoding: String?
public init(
name: String, path: String, sha: String, size: Int,
url: String, htmlUrl: String, gitUrl: String,
downloadUrl: String?, type: String,
content: String?, encoding: String?,
) {
self.name = name
self.path = path
self.sha = sha
self.size = size
self.url = url
self.htmlUrl = htmlUrl
self.gitUrl = gitUrl
self.downloadUrl = downloadUrl
self.type = type
self.content = content
self.encoding = encoding
}
enum CodingKeys: String, CodingKey {
case name
case path
case sha
case size
case url
case htmlUrl = "html_url"
case gitUrl = "git_url"
case downloadUrl = "download_url"
case type
case content
case encoding
}
public var decodedContent: String? {
guard let content, let encoding, encoding == "base64" else {
return content
}
let cleanedContent = content.filter { !$0.isWhitespace }
guard let data = Data(base64Encoded: cleanedContent) else {
return nil
}
return String(data: data, encoding: .utf8)
}
}
public struct Branch: Codable, Identifiable, Sendable {
public let name: String
public let commit: BranchCommit
public let protected: Bool
public var id: String {
name
}
public init(name: String, commit: BranchCommit, protected: Bool) {
self.name = name
self.commit = commit
self.protected = protected
}
}
public struct BranchCommit: Codable, Sendable {
public let id: String
public let message: String
public let url: String
public init(id: String, message: String, url: String) {
self.id = id
self.message = message
self.url = url
}
}

View file

@ -0,0 +1,35 @@
import Foundation
public struct User: Codable, Identifiable, Sendable {
public let id: Int
public let login: String
public let fullName: String?
public let email: String?
public let avatarUrl: String?
public let isAdmin: Bool?
public let created: Date?
public init(
id: Int, login: String, fullName: String? = nil,
email: String? = nil, avatarUrl: String? = nil,
isAdmin: Bool? = nil, created: Date? = nil,
) {
self.id = id
self.login = login
self.fullName = fullName
self.email = email
self.avatarUrl = avatarUrl
self.isAdmin = isAdmin
self.created = created
}
enum CodingKeys: String, CodingKey {
case id
case login
case fullName = "full_name"
case email
case avatarUrl = "avatar_url"
case isAdmin = "is_admin"
case created
}
}

View file

@ -0,0 +1,62 @@
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.
private var _session: URLSession!
public var session: URLSession {
_session
}
public init(allowSelfSignedCertificates: Bool = false, trustedHost: String? = nil) {
self.allowSelfSignedCertificates = allowSelfSignedCertificates
self.trustedHost = trustedHost
super.init()
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 300
configuration.urlCredentialStorage = nil
_session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
}
}
extension URLSessionManager: URLSessionDelegate {
public func urlSession(
_: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void,
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust else {
completionHandler(.performDefaultHandling, nil)
return
}
guard let serverTrust = challenge.protectionSpace.serverTrust else {
completionHandler(.cancelAuthenticationChallenge, nil)
return
}
let disposition = trustDisposition(for: challenge.protectionSpace.host)
if disposition == .useCredential {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(disposition, nil)
}
}
/// Determines the auth challenge disposition for a given host.
/// Extracted for testability since URLAuthenticationChallenge cannot be easily constructed in tests.
func trustDisposition(for challengeHost: String) -> URLSession.AuthChallengeDisposition {
guard allowSelfSignedCertificates else {
return .performDefaultHandling
}
guard let trustedHost else {
return .performDefaultHandling
}
if challengeHost.lowercased() == trustedHost.lowercased() {
return .useCredential
}
return .performDefaultHandling
}
}

View file

@ -0,0 +1,395 @@
import Foundation
public final class ForgejoClient: Sendable {
public let serverURL: String
public let username: String
private enum AuthCredential: Sendable {
case basic(base64: String)
case token(String)
}
private let credential: AuthCredential
private let serverHost: String?
private let urlSessionManager: URLSessionManager
private init(serverURL: String, username: String, credential: AuthCredential, allowSelfSignedCertificates: Bool) {
let normalized = Self.normalizeServerURL(serverURL)
self.serverURL = normalized
self.username = username
self.credential = credential
serverHost = URL(string: normalized)?.host
urlSessionManager = URLSessionManager(
allowSelfSignedCertificates: allowSelfSignedCertificates,
trustedHost: URL(string: normalized)?.host,
)
}
public convenience init(
serverURL: String, username: String, password: String,
allowSelfSignedCertificates: Bool = false,
) {
let credentials = "\(username):\(password)"
self.init(
serverURL: serverURL, username: username,
credential: .basic(base64: Data(credentials.utf8).base64EncodedString()),
allowSelfSignedCertificates: allowSelfSignedCertificates,
)
}
public convenience init(
serverURL: String, username: String, token: String,
allowSelfSignedCertificates: Bool = false,
) {
self.init(
serverURL: serverURL, username: username,
credential: .token(token),
allowSelfSignedCertificates: allowSelfSignedCertificates,
)
}
public static func login(
serverURL: String, username: String, password: String,
allowSelfSignedCertificates: Bool = false,
) async throws -> LoginResult {
let basicClient = ForgejoClient(
serverURL: serverURL, username: username,
password: password, allowSelfSignedCertificates: allowSelfSignedCertificates,
)
let user = try await basicClient.validateCredentials()
let token = try await basicClient.createAPIToken(otp: nil)
let tokenClient = ForgejoClient(
serverURL: serverURL, username: username,
token: token, allowSelfSignedCertificates: allowSelfSignedCertificates,
)
return LoginResult(client: tokenClient, user: user, token: token)
}
public static func loginWithOTP(
serverURL: String, username: String, password: String, otp: String,
allowSelfSignedCertificates: Bool = false,
) async throws -> LoginResult {
let basicClient = ForgejoClient(
serverURL: serverURL, username: username,
password: password, allowSelfSignedCertificates: allowSelfSignedCertificates,
)
let user = try await basicClient.validateCredentials(otp: otp)
let token = try await basicClient.createAPIToken(otp: otp)
let tokenClient = ForgejoClient(
serverURL: serverURL, username: username,
token: token, allowSelfSignedCertificates: allowSelfSignedCertificates,
)
return LoginResult(client: tokenClient, user: user, token: token)
}
public func fetchCurrentUser() async throws -> User {
try await validateCredentials()
}
private static let certificateErrorCodes: Set<URLError.Code> = [
.serverCertificateUntrusted, .secureConnectionFailed,
.serverCertificateHasBadDate, .serverCertificateHasUnknownRoot,
]
private func validateCredentials(otp: String? = nil) async throws -> User {
guard let url = URL(string: "\(serverURL)/api/v1/user") else {
throw AuthenticationError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
applyAuthHeader(to: &request)
if let otp {
request.setValue(otp, forHTTPHeaderField: "X-Forgejo-OTP")
}
do {
let (data, response) = try await urlSessionManager.session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw AuthenticationError.invalidResponse
}
switch httpResponse.statusCode {
case 200:
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)
}
} catch let error as URLError where Self.certificateErrorCodes.contains(error.code) {
throw AuthenticationError.certificateError
}
}
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")
}
}
// MARK: - API Token creation
private static let tokenScopes = [
"read:user", "write:user",
"read:repository", "write:repository",
"read:issue", "write:issue",
"read:notification", "write:notification",
"read:organization",
]
private func createAPIToken(otp: String?) async throws -> String {
let encodedUsername = Self.encodedPathSegment(username)
let urlString = "\(serverURL)/api/v1/users/\(encodedUsername)/tokens"
guard let url = URL(string: urlString) else {
throw AuthenticationError.invalidURL
}
let suffix = String((0 ..< 6).map { _ in "abcdefghijklmnopqrstuvwxyz0123456789".randomElement()! })
let tokenName = "Forji-\(suffix)"
return try await postTokenRequest(url: url, name: tokenName, otp: otp)
}
private func postTokenRequest(url: URL, name: String, otp: String?, retryCount: Int = 0) async throws -> String {
guard case let .basic(base64) = credential else {
throw AuthenticationError.invalidResponse
}
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Basic \(base64)", forHTTPHeaderField: "Authorization")
if let otp { request.setValue(otp, forHTTPHeaderField: "X-Forgejo-OTP") }
request.httpBody = try JSONSerialization.data(
withJSONObject: ["name": name, "scopes": Self.tokenScopes] as [String: Any],
)
let (data, response) = try await urlSessionManager.session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw AuthenticationError.invalidResponse
}
switch httpResponse.statusCode {
case 201:
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let sha1 = json["sha1"] as? String
else { throw AuthenticationError.invalidResponse }
return sha1
case 400, 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)
}
guard retryCount < 3 else {
throw AuthenticationError.unknownError(statusCode: httpResponse.statusCode)
}
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)
}
}
// MARK: - Internal request infrastructure
func authenticatedRequest(url: URL, method: String = "GET", body: Data? = nil) -> URLRequest {
var request = URLRequest(url: url)
request.httpMethod = method
if let serverHost, let requestHost = url.host,
serverHost.lowercased() == requestHost.lowercased()
{
applyAuthHeader(to: &request)
}
if let body {
request.httpBody = body
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
return request
}
private func executeRequest(
url: URL, method: String = "GET", body: Data? = nil,
) async throws -> (Data, HTTPURLResponse) {
let request = authenticatedRequest(url: url, method: method, body: body)
let session = urlSessionManager.session
let (data, response) = try await session.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw ServiceError.invalidResponse
}
return (data, httpResponse)
}
private func validateStatus(_ httpResponse: HTTPURLResponse, data: Data) throws {
guard (200 ... 299).contains(httpResponse.statusCode) else {
let serverMessage = String(data: data, encoding: .utf8) ?? ""
throw ServiceError.httpError(statusCode: httpResponse.statusCode, message: serverMessage)
}
}
func performRequest<T: Decodable>(
url: URL, method: String = "GET",
body: Data? = nil, responseType _: T.Type,
) async throws -> T {
let (data, httpResponse) = try await executeRequest(url: url, method: method, body: body)
try validateStatus(httpResponse, data: data)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
do {
return try decoder.decode(T.self, from: data)
} catch let error as DecodingError {
throw ServiceError.decodingFailed(detail: describeDecodingError(error))
}
}
func performRequestNoContent(
url: URL, method: String = "GET",
body: Data? = nil, validateStatus shouldValidate: Bool = false,
) async throws -> Int {
let (data, httpResponse) = try await executeRequest(url: url, method: method, body: body)
if shouldValidate {
try validateStatus(httpResponse, data: data)
}
return httpResponse.statusCode
}
func performRequestRawText(url: URL, method: String = "GET") async throws -> String {
let (data, httpResponse) = try await executeRequest(url: url, method: method)
try validateStatus(httpResponse, data: data)
guard let text = String(data: data, encoding: .utf8) else {
throw ServiceError.invalidResponse
}
return text
}
func performRequestData(url: URL, method: String = "GET", body: Data? = nil) async throws -> Data {
let (data, httpResponse) = try await executeRequest(url: url, method: method, body: body)
try validateStatus(httpResponse, data: data)
return data
}
}
// MARK: - URL helpers & normalization
extension ForgejoClient {
/// Characters allowed in a single URL path segment (`.urlPathAllowed` minus `/`).
private static let pathSegmentAllowed: CharacterSet = {
var allowed = CharacterSet.urlPathAllowed
allowed.remove(charactersIn: "/")
return allowed
}()
/// Percent-encodes a single path segment (e.g. owner or repo name) for safe URL interpolation.
static func encodedPathSegment(_ segment: String) -> String {
segment.addingPercentEncoding(withAllowedCharacters: pathSegmentAllowed) ?? segment
}
func makeURL(path: String, queryItems: [URLQueryItem] = []) throws -> URL {
guard var components = URLComponents(string: "\(serverURL)\(path)") else {
throw ServiceError.invalidURL
}
if !queryItems.isEmpty { components.queryItems = queryItems }
guard let url = components.url else { throw ServiceError.invalidURL }
return url
}
func makeRepoURL(owner: String, repo: String, path: String, queryItems: [URLQueryItem] = []) throws -> URL {
let enc = (Self.encodedPathSegment(owner), Self.encodedPathSegment(repo))
return try makeURL(path: "/api/v1/repos/\(enc.0)/\(enc.1)\(path)", queryItems: queryItems)
}
func encodeRequestBody(_ body: some Encodable) throws -> Data {
try JSONEncoder().encode(body)
}
public static func normalizeServerURL(_ url: String) -> String {
var normalized = url.trimmingCharacters(in: .whitespacesAndNewlines)
while normalized.hasSuffix("/") {
normalized = String(normalized.dropLast())
}
if !normalized.hasPrefix("http://"), !normalized.hasPrefix("https://") {
normalized = "https://" + normalized
}
return normalized
}
}
public struct LoginResult: Sendable {
public let client: ForgejoClient
public let user: User
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 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 enum ServiceError: LocalizedError, Sendable {
case noActiveInstance
case invalidURL
case invalidResponse
case httpError(statusCode: Int, message: String = "")
case notMergeable
case mergeConflict
case decodingFailed(detail: String)
public var errorDescription: String? {
switch self {
case .noActiveInstance:
"No active Forgejo instance"
case .invalidURL:
"Invalid URL"
case .invalidResponse:
"Invalid response from server"
case let .httpError(statusCode, message):
message.isEmpty ? "HTTP error: \(statusCode)" : "HTTP \(statusCode): \(message)"
case .notMergeable:
"This pull request cannot be merged"
case .mergeConflict:
"There are merge conflicts that must be resolved"
case let .decodingFailed(detail):
"Decoding failed: \(detail)"
}
}
}

View file

@ -0,0 +1,187 @@
import Foundation
public final class IssueService: Sendable {
private let client: ForgejoClient
public init(client: ForgejoClient) {
self.client = client
}
private struct IssueCreatePayload: Codable {
let title: String
let body: String?
let labels: [Int]?
let milestone: Int?
let assignees: [String]?
}
private struct IssueEditPayload: Codable {
let title: String?
let body: String?
let state: String?
let milestone: Int?
let assignees: [String]?
}
private struct LabelsPayload: Codable {
let labels: [Int]
}
private struct CommentPayload: Codable {
let body: String
}
public func fetchIssues(
owner: String, repo: String,
state: String = "open",
page: Int = 1, limit: Int = 20,
) async throws -> [Issue] {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/issues", queryItems: [
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "type", value: "issues"),
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "limit", value: "\(limit)"),
])
return try await client.performRequest(
url: url, responseType: [Issue].self,
)
}
public func fetchIssue(
owner: String, repo: String, index: Int,
) async throws -> Issue {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/issues/\(index)")
return try await client.performRequest(
url: url, responseType: Issue.self,
)
}
public func createIssue(
owner: String, repo: String,
title: String, body: String?,
labels: [Int]? = nil, milestone: Int? = nil,
assignees: [String]? = nil,
) async throws -> Issue {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/issues")
let payload = IssueCreatePayload(
title: title,
body: body.nilIfEmpty,
labels: labels.nilIfEmpty,
milestone: milestone,
assignees: assignees.nilIfEmpty,
)
let jsonData = try client.encodeRequestBody(payload)
return try await client.performRequest(
url: url, method: "POST", body: jsonData,
responseType: Issue.self,
)
}
// swiftlint:disable:next function_parameter_count
public func editIssue(
owner: String, repo: String, index: Int,
title: String?, body: String?, state: String?,
milestone: Int? = nil, assignees: [String]? = nil,
) async throws -> Issue {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/issues/\(index)")
let payload = IssueEditPayload(
title: title.nilIfEmpty,
body: body.nilIfEmpty,
state: state,
milestone: milestone,
assignees: assignees,
)
let jsonData = try client.encodeRequestBody(payload)
return try await client.performRequest(
url: url, method: "PATCH", body: jsonData,
responseType: Issue.self,
)
}
public func replaceLabels(
owner: String, repo: String,
index: Int, labelIDs: [Int],
) async throws {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/issues/\(index)/labels")
let payload = LabelsPayload(labels: labelIDs)
let jsonData = try client.encodeRequestBody(payload)
_ = try await client.performRequest(
url: url, method: "PUT", body: jsonData,
responseType: [IssueLabel].self,
)
}
public func fetchComments(
owner: String, repo: String, index: Int,
) async throws -> [IssueComment] {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/issues/\(index)/comments")
return try await client.performRequest(
url: url, responseType: [IssueComment].self,
)
}
public func createComment(
owner: String, repo: String,
index: Int, body: String,
) async throws -> IssueComment {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/issues/\(index)/comments")
let payload = CommentPayload(body: body)
let jsonData = try client.encodeRequestBody(payload)
return try await client.performRequest(
url: url, method: "POST", body: jsonData,
responseType: IssueComment.self,
)
}
public func searchIssues(
type: String = "issues", state: String = "open",
query: String = "",
page: Int = 1, limit: Int = 20,
assigned: Bool = false,
created: Bool = false,
mentioned: Bool = false,
reviewRequested: Bool = false,
) async throws -> [Issue] {
var queryItems = [
URLQueryItem(name: "type", value: type),
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "limit", value: "\(limit)"),
]
if !query.isEmpty {
queryItems.append(URLQueryItem(name: "q", value: query))
}
if assigned {
queryItems.append(URLQueryItem(name: "assigned", value: "true"))
}
if created {
queryItems.append(URLQueryItem(name: "created", value: "true"))
}
if mentioned {
queryItems.append(URLQueryItem(name: "mentioned", value: "true"))
}
if reviewRequested {
queryItems.append(URLQueryItem(name: "review_requested", value: "true"))
}
let url = try client.makeURL(
path: "/api/v1/repos/issues/search",
queryItems: queryItems,
)
return try await client.performRequest(
url: url, responseType: [Issue].self,
)
}
public func editComment(
owner: String, repo: String,
commentId: Int, body: String,
) async throws -> IssueComment {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/issues/comments/\(commentId)")
let payload = CommentPayload(body: body)
let jsonData = try client.encodeRequestBody(payload)
return try await client.performRequest(
url: url, method: "PATCH", body: jsonData,
responseType: IssueComment.self,
)
}
}

View file

@ -0,0 +1,38 @@
import Foundation
public final class NotificationService: Sendable {
private let client: ForgejoClient
public init(client: ForgejoClient) {
self.client = client
}
public func fetchNotifications(
statusTypes: [String] = ["unread"],
page: Int = 1, limit: Int = 20,
) async throws -> [NotificationThread] {
var queryItems = [
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "limit", value: "\(limit)"),
]
for status in statusTypes {
queryItems.append(URLQueryItem(name: "status-types", value: status))
}
let url = try client.makeURL(path: "/api/v1/notifications", queryItems: queryItems)
return try await client.performRequest(url: url, responseType: [NotificationThread].self)
}
public func fetchUnreadCount() async throws -> Int {
let url = try client.makeURL(path: "/api/v1/notifications/new")
let count = try await client.performRequest(url: url, responseType: NotificationCount.self)
return count.new
}
public func markAsRead(id: Int) async throws {
let url = try client.makeURL(
path: "/api/v1/notifications/threads/\(id)",
queryItems: [URLQueryItem(name: "to-status", value: "read")],
)
_ = try await client.performRequestNoContent(url: url, method: "PATCH", validateStatus: true)
}
}

View file

@ -0,0 +1,267 @@
import Foundation
public final class PullRequestService: Sendable {
private let client: ForgejoClient
private let issueService: IssueService
public init(client: ForgejoClient) {
self.client = client
issueService = IssueService(client: client)
}
private struct PullRequestCreatePayload: Codable {
let title: String
let head: String
let base: String
let body: String?
let labels: [Int]?
let milestone: Int?
let assignees: [String]?
}
private struct PullRequestEditPayload: Codable {
let title: String?
let body: String?
let state: String?
let milestone: Int?
let assignees: [String]?
}
private struct RequestReviewersPayload: Codable {
let reviewers: [String]
}
private struct MergePullRequestPayload: Codable {
let method: String
let deleteBranchAfterMerge: Bool
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"
}
}
private struct ReviewCreatePayload: Codable {
let body: String
let event: String
let comments: [ReviewCommentPayload]?
}
private struct ReviewCommentPayload: Codable {
let body: String
let path: String
let oldPosition: Int?
let newPosition: Int?
// swiftlint:disable:next nesting
enum CodingKeys: String, CodingKey {
case body
case path
case oldPosition = "old_position"
case newPosition = "new_position"
}
}
// MARK: - Pull Requests
public func fetchPullRequests(
owner: String, repo: String,
state: String = "open",
page: Int = 1, limit: Int = 20,
) async throws -> [PullRequest] {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/pulls", queryItems: [
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "limit", value: "\(limit)"),
])
return try await client.performRequest(
url: url, responseType: [PullRequest].self,
)
}
public func fetchPullRequest(
owner: String, repo: String, index: Int,
) async throws -> PullRequest {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/pulls/\(index)")
return try await client.performRequest(
url: url, responseType: PullRequest.self,
)
}
// swiftlint:disable:next function_parameter_count
public func createPullRequest(
owner: String, repo: String,
title: String, head: String, base: String,
body: String?, labels: [Int]? = nil,
milestone: Int? = nil, assignees: [String]? = nil,
) async throws -> PullRequest {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/pulls")
let payload = PullRequestCreatePayload(
title: title,
head: head,
base: base,
body: body.nilIfEmpty,
labels: labels.nilIfEmpty,
milestone: milestone,
assignees: assignees.nilIfEmpty,
)
let jsonData = try client.encodeRequestBody(payload)
return try await client.performRequest(
url: url, method: "POST", body: jsonData,
responseType: PullRequest.self,
)
}
// swiftlint:disable:next function_parameter_count
public func editPullRequest(
owner: String, repo: String, index: Int,
title: String?, body: String?, state: String?,
milestone: Int? = nil, assignees: [String]? = nil,
) async throws -> PullRequest {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/pulls/\(index)")
let payload = PullRequestEditPayload(
title: title.nilIfEmpty,
body: body.nilIfEmpty,
state: state,
milestone: milestone,
assignees: assignees,
)
let jsonData = try client.encodeRequestBody(payload)
return try await client.performRequest(
url: url, method: "PATCH", body: jsonData,
responseType: PullRequest.self,
)
}
public func requestReviewers(
owner: String, repo: String,
index: Int, reviewers: [String],
) async throws {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/pulls/\(index)/requested_reviewers")
let payload = RequestReviewersPayload(reviewers: reviewers)
let jsonData = try client.encodeRequestBody(payload)
_ = try await client.performRequestNoContent(url: url, method: "POST", body: jsonData, validateStatus: true)
}
public func removeReviewers(
owner: String, repo: String,
index: Int, reviewers: [String],
) async throws {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/pulls/\(index)/requested_reviewers")
let payload = RequestReviewersPayload(reviewers: reviewers)
let jsonData = try client.encodeRequestBody(payload)
_ = 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?,
deleteBranch: Bool,
) async throws {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/pulls/\(index)/merge")
let payload = MergePullRequestPayload(
method: method,
deleteBranchAfterMerge: deleteBranch,
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
}
}
public func fetchDiff(
owner: String, repo: String, index: Int,
) async throws -> String {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/pulls/\(index).diff")
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] {
try await issueService.fetchComments(owner: owner, repo: repo, index: index)
}
public func createComment(
owner: String, repo: String, index: Int, body: String,
) async throws -> IssueComment {
try await issueService.createComment(owner: owner, repo: repo, index: index, body: body)
}
public func editComment(
owner: String, repo: String,
commentId: Int, body: String,
) async throws -> IssueComment {
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] {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/pulls/\(index)/reviews")
return try await client.performRequest(
url: url, responseType: [PullRequestReview].self,
)
}
public func fetchReviewComments(
owner: String, repo: String,
index: Int, reviewId: Int,
) async throws -> [ReviewComment] {
let url = try client.makeRepoURL(
owner: owner, repo: repo,
path: "/pulls/\(index)/reviews/\(reviewId)/comments",
)
return try await client.performRequest(
url: url, responseType: [ReviewComment].self,
)
}
// swiftlint:disable:next function_parameter_count
public func createReview(
owner: String, repo: String, index: Int,
body: String, event: String,
comments: [CreateReviewComment],
) async throws -> PullRequestReview {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/pulls/\(index)/reviews")
let commentPayloads = comments.map {
ReviewCommentPayload(
body: $0.body,
path: $0.path,
oldPosition: $0.oldPosition,
newPosition: $0.newPosition,
)
}
let payload = ReviewCreatePayload(
body: body,
event: event,
comments: commentPayloads.isEmpty ? nil : commentPayloads,
)
let jsonData = try client.encodeRequestBody(payload)
return try await client.performRequest(
url: url, method: "POST", body: jsonData,
responseType: PullRequestReview.self,
)
}
}

View file

@ -0,0 +1,301 @@
import Foundation
// swiftlint:disable:next type_body_length
public final class RepositoryService: Sendable {
private let client: ForgejoClient
public init(client: ForgejoClient) {
self.client = client
}
private struct SearchResponse: Codable {
let data: [Repository]
}
private struct UpdateFilePayload: Codable {
let content: String
let sha: String
let message: String
let branch: String?
}
private struct UpdateFileResponse: Codable {
let content: FileContent
}
private struct GitRef: Codable {
let ref: String
let object: GitRefObject
}
private struct GitRefObject: Codable {
let type: String
let sha: String
let url: String
}
public func fetchUserRepositories(
page: Int = 1, limit: Int = 20,
) async throws -> [Repository] {
let url = try client.makeURL(path: "/api/v1/user/repos", queryItems: [
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "limit", value: "\(limit)"),
])
return try await client.performRequest(
url: url, responseType: [Repository].self,
)
}
public func fetchStarredRepositories(
page: Int = 1, limit: Int = 20,
) async throws -> [Repository] {
let encodedUsername = ForgejoClient.encodedPathSegment(client.username)
let url = try client.makeURL(
path: "/api/v1/users/\(encodedUsername)/starred",
queryItems: [
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "limit", value: "\(limit)"),
],
)
return try await client.performRequest(
url: url, responseType: [Repository].self,
)
}
/// Fetches IDs of all starred repositories by paginating through all pages.
public func fetchAllStarredRepoIds() async throws -> Set<Int> {
var ids = Set<Int>()
var page = 1
let limit = 50
let maxPages = 100
while page <= maxPages {
try Task.checkCancellation()
let batch = try await fetchStarredRepositories(page: page, limit: limit)
ids.formUnion(batch.map(\.id))
if batch.count < limit { break }
page += 1
}
return ids
}
public func searchRepositories(
query: String, page: Int = 1, limit: Int = 20,
) async throws -> [Repository] {
let url = try client.makeURL(path: "/api/v1/repos/search", queryItems: [
URLQueryItem(name: "q", value: query),
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "limit", value: "\(limit)"),
])
let searchResponse = try await client.performRequest(
url: url, responseType: SearchResponse.self,
)
return searchResponse.data
}
public func fetchContents(
owner: String, repo: String,
path: String = "", ref: String? = nil,
) async throws -> [RepositoryContent] {
let pathComponent = path.isEmpty
? ""
: "/\(path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? path)"
let queryItems = ref.map { [URLQueryItem(name: "ref", value: $0)] } ?? []
let url = try client.makeRepoURL(
owner: owner, repo: repo,
path: "/contents\(pathComponent)",
queryItems: queryItems,
)
// The API returns an array for directories but a single object for files.
// Fetch data once, then pick the right decode based on the JSON shape.
let data = try await client.performRequestData(url: url)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = forgejoDateDecodingStrategy
let firstByte = data.first
if firstByte == UInt8(ascii: "[") {
return try decoder.decode([RepositoryContent].self, from: data)
} else {
let single = try decoder.decode(RepositoryContent.self, from: data)
return [single]
}
}
public func fetchFileContent(
owner: String, repo: String,
path: String, ref: String? = nil,
) async throws -> FileContent {
let encodedPath = path.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? path
let queryItems = ref.map { [URLQueryItem(name: "ref", value: $0)] } ?? []
let url = try client.makeRepoURL(
owner: owner, repo: repo,
path: "/contents/\(encodedPath)",
queryItems: queryItems,
)
return try await client.performRequest(
url: url, responseType: FileContent.self,
)
}
public func fetchBranches(
owner: String, repo: String,
) async throws -> [Branch] {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/branches")
let branches = try await client.performRequest(
url: url, responseType: [Branch].self,
)
// Some Forgejo versions don't return all branches via /branches endpoint.
// Fall back to git refs which reliably lists all branches.
if branches.count <= 1 {
let refsURL = try client.makeRepoURL(
owner: owner, repo: repo, path: "/git/refs",
)
let refs = try await client.performRequest(
url: refsURL, responseType: [GitRef].self,
)
let branchPrefix = "refs/heads/"
let refBranches = refs
.filter { $0.ref.hasPrefix(branchPrefix) }
.map { ref in
let name = String(ref.ref.dropFirst(branchPrefix.count))
return Branch(
name: name,
commit: BranchCommit(
id: ref.object.sha,
message: "",
url: ref.object.url,
),
protected: false,
)
}
if refBranches.count > branches.count {
return refBranches
}
}
return branches
}
public func isStarred(
owner: String, repo: String,
) async throws -> Bool {
let encOwner = ForgejoClient.encodedPathSegment(owner)
let encRepo = ForgejoClient.encodedPathSegment(repo)
let url = try client.makeURL(path: "/api/v1/user/starred/\(encOwner)/\(encRepo)")
let statusCode = try await client.performRequestNoContent(url: url)
switch statusCode {
case 204:
return true
case 404:
return false
default:
throw ServiceError.httpError(statusCode: statusCode)
}
}
public func starRepository(
owner: String, repo: String,
) async throws {
let encOwner = ForgejoClient.encodedPathSegment(owner)
let encRepo = ForgejoClient.encodedPathSegment(repo)
let url = try client.makeURL(path: "/api/v1/user/starred/\(encOwner)/\(encRepo)")
_ = try await client.performRequestNoContent(
url: url, method: "PUT", validateStatus: true,
)
}
public func unstarRepository(
owner: String, repo: String,
) async throws {
let encOwner = ForgejoClient.encodedPathSegment(owner)
let encRepo = ForgejoClient.encodedPathSegment(repo)
let url = try client.makeURL(path: "/api/v1/user/starred/\(encOwner)/\(encRepo)")
_ = try await client.performRequestNoContent(
url: url, method: "DELETE", validateStatus: true,
)
}
public func fetchLabels(
owner: String, repo: String,
) async throws -> [IssueLabel] {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/labels")
return try await client.performRequest(
url: url, responseType: [IssueLabel].self,
)
}
public func fetchMilestones(
owner: String, repo: String,
) async throws -> [IssueMilestone] {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/milestones")
return try await client.performRequest(
url: url, responseType: [IssueMilestone].self,
)
}
public func fetchAssignees(
owner: String, repo: String,
) async throws -> [User] {
let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/assignees")
return try await client.performRequest(
url: url, responseType: [User].self,
)
}
public func fetchCommits(
owner: String, repo: String,
sha: String? = nil,
page: Int = 1, limit: Int = 20,
) async throws -> [Commit] {
var queryItems: [URLQueryItem] = [
URLQueryItem(name: "page", value: "\(page)"),
URLQueryItem(name: "limit", value: "\(limit)"),
]
if let sha {
queryItems.append(URLQueryItem(name: "sha", value: sha))
}
let url = try client.makeRepoURL(
owner: owner, repo: repo,
path: "/commits",
queryItems: queryItems,
)
return try await client.performRequest(
url: url, responseType: [Commit].self,
)
}
public func fetchCommitDiff(
owner: String, repo: String, sha: String,
) async throws -> String {
let encodedSHA = ForgejoClient.encodedPathSegment(sha)
let url = try client.makeRepoURL(
owner: owner, repo: repo,
path: "/git/commits/\(encodedSHA).diff",
)
return try await client.performRequestRawText(url: url)
}
// swiftlint:disable:next function_parameter_count
public func updateFile(
owner: String, repo: String, path: String,
content: String, sha: String, message: String,
branch: 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 = UpdateFilePayload(
content: base64Content, sha: sha,
message: message, branch: branch,
)
let jsonData = try client.encodeRequestBody(payload)
let response = try await client.performRequest(
url: url, method: "PUT", body: jsonData,
responseType: UpdateFileResponse.self,
)
return response.content
}
}

View file

@ -0,0 +1,83 @@
import Foundation
import Testing
@testable import ForgejoKit
struct DateDecodingTests {
private func decoder() -> JSONDecoder {
let d = JSONDecoder()
d.dateDecodingStrategy = forgejoDateDecodingStrategy
return d
}
private struct DateWrapper: Codable {
let date: Date
}
private var utcCalendar: Calendar {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "UTC")!
return calendar
}
// MARK: - ISO 8601 without fractional seconds
@Test func decodesDateWithoutFractionalSeconds() throws {
let json = #"{"date":"2024-01-15T10:30:00Z"}"#
let result = try decoder().decode(DateWrapper.self, from: Data(json.utf8))
#expect(utcCalendar.component(.year, from: result.date) == 2024)
#expect(utcCalendar.component(.month, from: result.date) == 1)
#expect(utcCalendar.component(.day, from: result.date) == 15)
#expect(utcCalendar.component(.hour, from: result.date) == 10)
#expect(utcCalendar.component(.minute, from: result.date) == 30)
}
// MARK: - ISO 8601 with fractional seconds
@Test func decodesDateWithFractionalSeconds() throws {
let json = #"{"date":"2024-06-20T14:05:30.123Z"}"#
let result = try decoder().decode(DateWrapper.self, from: Data(json.utf8))
#expect(utcCalendar.component(.year, from: result.date) == 2024)
#expect(utcCalendar.component(.month, from: result.date) == 6)
#expect(utcCalendar.component(.day, from: result.date) == 20)
#expect(utcCalendar.component(.hour, from: result.date) == 14)
}
@Test func decodesDateWithTripleZeroFraction() throws {
let json = #"{"date":"2024-01-15T10:30:00.000Z"}"#
let result = try decoder().decode(DateWrapper.self, from: Data(json.utf8))
#expect(utcCalendar.component(.second, from: result.date) == 0)
}
// MARK: - Invalid dates
@Test func throwsOnInvalidDateString() {
let json = #"{"date":"not-a-date"}"#
#expect(throws: DecodingError.self) {
_ = try decoder().decode(DateWrapper.self, from: Data(json.utf8))
}
}
@Test func throwsOnEmptyDateString() {
let json = #"{"date":""}"#
#expect(throws: DecodingError.self) {
_ = try decoder().decode(DateWrapper.self, from: Data(json.utf8))
}
}
// MARK: - Both formats decode to same value
@Test func bothFormatsProduceSameDate() throws {
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))
#expect(resultWithout.date == resultWith.date)
}
}

View file

@ -0,0 +1,363 @@
import Foundation
import Testing
@testable import ForgejoKit
struct DiffParserTests {
// MARK: - Empty and minimal input
@Test func parseEmptyString() {
let result = DiffParser.parse("")
#expect(result.files.isEmpty)
}
@Test func parseGarbageInput() {
let result = DiffParser.parse("this is not a diff")
#expect(result.files.isEmpty)
}
// MARK: - Single file diff
@Test func parseSingleFileAddition() {
let diff = [
"diff --git a/hello.txt b/hello.txt",
"new file mode 100644",
"--- /dev/null",
"+++ b/hello.txt",
"@@ -0,0 +1,3 @@",
"+line one",
"+line two",
"+line three",
].joined(separator: "\n")
let result = DiffParser.parse(diff)
#expect(result.files.count == 1)
let file = result.files[0]
#expect(file.oldName == "/dev/null")
#expect(file.newName == "hello.txt")
#expect(file.hunks.count == 1)
let hunk = file.hunks[0]
#expect(hunk.lines.count == 4)
#expect(hunk.lines[0].type == .header)
#expect(hunk.lines[1].type == .addition)
#expect(hunk.lines[2].type == .addition)
#expect(hunk.lines[3].type == .addition)
}
@Test func parseSingleFileDeletion() {
let diff = [
"diff --git a/old.txt b/old.txt",
"deleted file mode 100644",
"--- a/old.txt",
"+++ /dev/null",
"@@ -1,2 +0,0 @@",
"-removed line one",
"-removed line two",
].joined(separator: "\n")
let result = DiffParser.parse(diff)
#expect(result.files.count == 1)
let file = result.files[0]
#expect(file.oldName == "old.txt")
#expect(file.newName == "/dev/null")
}
@Test func parseSingleFileModification() {
let diff = [
"diff --git a/file.txt b/file.txt",
"--- a/file.txt",
"+++ b/file.txt",
"@@ -1,3 +1,3 @@",
" context line",
"-old line",
"+new line",
" another context",
].joined(separator: "\n")
let result = DiffParser.parse(diff)
#expect(result.files.count == 1)
let hunk = result.files[0].hunks[0]
#expect(hunk.lines.count == 5)
let contextLine = hunk.lines[1]
#expect(contextLine.type == .context)
#expect(contextLine.oldLineNumber == 1)
#expect(contextLine.newLineNumber == 1)
let deletionLine = hunk.lines[2]
#expect(deletionLine.type == .deletion)
#expect(deletionLine.content == "old line")
#expect(deletionLine.oldLineNumber == 2)
#expect(deletionLine.newLineNumber == nil)
let additionLine = hunk.lines[3]
#expect(additionLine.type == .addition)
#expect(additionLine.content == "new line")
#expect(additionLine.oldLineNumber == nil)
#expect(additionLine.newLineNumber == 2)
let trailingContext = hunk.lines[4]
#expect(trailingContext.type == .context)
#expect(trailingContext.oldLineNumber == 3)
#expect(trailingContext.newLineNumber == 3)
}
// MARK: - Multiple files
@Test func parseMultipleFiles() {
let diff = [
"diff --git a/a.txt b/a.txt",
"--- a/a.txt",
"+++ b/a.txt",
"@@ -1 +1 @@",
"-old a",
"+new a",
"diff --git a/b.txt b/b.txt",
"--- a/b.txt",
"+++ b/b.txt",
"@@ -1 +1 @@",
"-old b",
"+new b",
].joined(separator: "\n")
let result = DiffParser.parse(diff)
#expect(result.files.count == 2)
#expect(result.files[0].newName == "a.txt")
#expect(result.files[1].newName == "b.txt")
}
// MARK: - Multiple hunks
@Test func parseMultipleHunks() {
let diff = [
"diff --git a/file.txt b/file.txt",
"--- a/file.txt",
"+++ b/file.txt",
"@@ -1,3 +1,3 @@",
" context",
"-old first",
"+new first",
" context",
"@@ -10,3 +10,3 @@",
" context",
"-old second",
"+new second",
" context",
].joined(separator: "\n")
let result = DiffParser.parse(diff)
#expect(result.files[0].hunks.count == 2)
let hunk2 = result.files[0].hunks[1]
let contextLine = hunk2.lines[1]
#expect(contextLine.oldLineNumber == 10)
#expect(contextLine.newLineNumber == 10)
}
// MARK: - Line number tracking
@Test func lineNumbersTrackCorrectlyWithMultipleAdditions() {
let diff = [
"diff --git a/file.txt b/file.txt",
"--- a/file.txt",
"+++ b/file.txt",
"@@ -5,4 +5,6 @@",
" context",
"+added one",
"+added two",
" old context",
"-removed",
"+replaced",
" trailing",
].joined(separator: "\n")
let result = DiffParser.parse(diff)
let lines = result.files[0].hunks[0].lines
let ctx1 = lines[1]
#expect(ctx1.oldLineNumber == 5)
#expect(ctx1.newLineNumber == 5)
let add1 = lines[2]
#expect(add1.newLineNumber == 6)
#expect(add1.oldLineNumber == nil)
let add2 = lines[3]
#expect(add2.newLineNumber == 7)
let ctx2 = lines[4]
#expect(ctx2.oldLineNumber == 6)
#expect(ctx2.newLineNumber == 8)
let del = lines[5]
#expect(del.oldLineNumber == 7)
#expect(del.newLineNumber == nil)
let repl = lines[6]
#expect(repl.newLineNumber == 9)
#expect(repl.oldLineNumber == nil)
let trailing = lines[7]
#expect(trailing.oldLineNumber == 8)
#expect(trailing.newLineNumber == 10)
}
// MARK: - Hunk header parsing
@Test func hunkHeaderWithFunctionContext() {
let diff = [
"diff --git a/main.swift b/main.swift",
"--- a/main.swift",
"+++ b/main.swift",
"@@ -42,6 +42,7 @@ func doSomething() {",
" context",
"+new line",
" context",
].joined(separator: "\n")
let result = DiffParser.parse(diff)
let firstLine = result.files[0].hunks[0].lines[1]
#expect(firstLine.oldLineNumber == 42)
#expect(firstLine.newLineNumber == 42)
}
// MARK: - File name extraction
@Test func fileNameExtractedFromMinusPlus() {
let diff = [
"diff --git a/src/app.swift b/src/app.swift",
"--- a/src/app.swift",
"+++ b/src/app.swift",
"@@ -1 +1 @@",
"-old",
"+new",
].joined(separator: "\n")
let result = DiffParser.parse(diff)
#expect(result.files[0].oldName == "src/app.swift")
#expect(result.files[0].newName == "src/app.swift")
}
@Test func fileNameFallsBackToGitHeader() {
let diff = [
"diff --git a/image.png b/image.png",
"Binary files differ",
].joined(separator: "\n")
let result = DiffParser.parse(diff)
#expect(result.files.count == 1)
#expect(result.files[0].oldName == "image.png")
#expect(result.files[0].newName == "image.png")
}
// MARK: - Commit diff (new file from Forgejo API)
@Test func parseCommitDiffNewFile() {
let diff = [
"diff --git a/src/main.py b/src/main.py",
"new file mode 100644",
"index 0000000..67e9324",
"--- /dev/null",
"+++ b/src/main.py",
"@@ -0,0 +1 @@",
"+def main():\\n print(\"Main module\")",
].joined(separator: "\n")
let result = DiffParser.parse(diff)
#expect(result.files.count == 1)
let file = result.files[0]
#expect(file.oldName == "/dev/null")
#expect(file.newName == "src/main.py")
#expect(file.hunks.count == 1)
let hunk = file.hunks[0]
#expect(hunk.lines[0].type == .header)
#expect(hunk.lines[1].type == .addition)
#expect(hunk.lines[1].newLineNumber == 1)
#expect(hunk.lines[1].oldLineNumber == nil)
}
@Test func parseCommitDiffMultipleFiles() {
let diff = [
"diff --git a/hello.py b/hello.py",
"new file mode 100644",
"--- /dev/null",
"+++ b/hello.py",
"@@ -0,0 +1,2 @@",
"+print(\"hello\")",
"+print(\"world\")",
"diff --git a/README.md b/README.md",
"--- a/README.md",
"+++ b/README.md",
"@@ -1,2 +1,3 @@",
" # Project",
"+",
"+New section",
].joined(separator: "\n")
let result = DiffParser.parse(diff)
#expect(result.files.count == 2)
#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[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)
}
// MARK: - No newline at end of file sentinel
@Test func noNewlineAtEndOfFileSentinelIsSkipped() {
let diff = [
"diff --git a/file.txt b/file.txt",
"--- a/file.txt",
"+++ b/file.txt",
"@@ -1,2 +1,2 @@",
" context",
"-old line",
"\\ No newline at end of file",
"+new line",
"\\ No newline at end of file",
].joined(separator: "\n")
let result = DiffParser.parse(diff)
let hunk = result.files[0].hunks[0]
// Header + context + deletion + addition = 4 lines (sentinels skipped)
#expect(hunk.lines.count == 4)
#expect(hunk.lines[0].type == .header)
#expect(hunk.lines[1].type == .context)
#expect(hunk.lines[2].type == .deletion)
#expect(hunk.lines[3].type == .addition)
// diffPosition should be consecutive without gaps from the sentinel
#expect(hunk.lines[1].diffPosition == 1)
#expect(hunk.lines[2].diffPosition == 2)
#expect(hunk.lines[3].diffPosition == 3)
}
// MARK: - DiffLine identity
@Test func eachLineHasUniqueId() {
let diff = [
"diff --git a/f.txt b/f.txt",
"--- a/f.txt",
"+++ b/f.txt",
"@@ -1,2 +1,2 @@",
"-a",
"+b",
].joined(separator: "\n")
let result = DiffParser.parse(diff)
let ids = result.files[0].hunks[0].lines.map(\.id)
let uniqueIds = Set(ids)
#expect(ids.count == uniqueIds.count)
}
}

View file

@ -0,0 +1,81 @@
import Foundation
import Testing
@testable import ForgejoKit
struct FileContentTests {
private func makeFileContent(content: String?, encoding: String?) -> FileContent {
FileContent(
name: "test.txt",
path: "test.txt",
sha: "abc",
size: 100,
url: "https://example.com",
htmlUrl: "https://example.com",
gitUrl: "https://example.com",
downloadUrl: nil,
type: "file",
content: content,
encoding: encoding
)
}
// MARK: - Base64 decoding
@Test func decodesBase64Content() {
let original = "Hello, World!"
let base64 = Data(original.utf8).base64EncodedString()
let file = makeFileContent(content: base64, encoding: "base64")
#expect(file.decodedContent == "Hello, World!")
}
@Test func decodesBase64WithNewlines() {
let original = "Line one\nLine two\nLine three"
let base64 = Data(original.utf8).base64EncodedString()
let wrappedBase64 = String(base64.prefix(20)) + "\n" + String(base64.dropFirst(20))
let file = makeFileContent(content: wrappedBase64, encoding: "base64")
#expect(file.decodedContent == original)
}
@Test func returnsNilContentWhenContentIsNil() {
let file = makeFileContent(content: nil, encoding: "base64")
#expect(file.decodedContent == nil)
}
@Test func returnsRawContentWhenEncodingIsNil() {
let file = makeFileContent(content: "raw text", encoding: nil)
#expect(file.decodedContent == "raw text")
}
@Test func returnsRawContentWhenEncodingIsNotBase64() {
let file = makeFileContent(content: "raw text", encoding: "utf-8")
#expect(file.decodedContent == "raw text")
}
@Test func returnsNilForInvalidBase64() {
let file = makeFileContent(content: "!!!not-base64!!!", encoding: "base64")
#expect(file.decodedContent == nil)
}
// MARK: - JSON decoding
@Test func decodesFileContentFromJSON() throws {
let json = """
{
"name": "readme.md",
"path": "readme.md",
"sha": "xyz789",
"size": 50,
"url": "https://example.com/api",
"html_url": "https://example.com/html",
"git_url": "https://example.com/git",
"type": "file",
"content": "SGVsbG8=",
"encoding": "base64"
}
"""
let file = try JSONDecoder().decode(FileContent.self, from: Data(json.utf8))
#expect(file.name == "readme.md")
#expect(file.decodedContent == "Hello")
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,51 @@
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")
}
@Test func addsHttpsWhenNoScheme() {
let result = ForgejoClient.normalizeServerURL("forgejo.example.com")
#expect(result == "https://forgejo.example.com")
}
@Test func preservesHttpScheme() {
let result = ForgejoClient.normalizeServerURL("http://localhost:3000")
#expect(result == "http://localhost:3000")
}
@Test func preservesHttpsScheme() {
let result = ForgejoClient.normalizeServerURL("https://forgejo.example.com")
#expect(result == "https://forgejo.example.com")
}
@Test func trimsWhitespace() {
let result = ForgejoClient.normalizeServerURL(" https://forgejo.example.com ")
#expect(result == "https://forgejo.example.com")
}
@Test func handlesTrailingSlashAndWhitespace() {
let result = ForgejoClient.normalizeServerURL(" forgejo.example.com/ ")
#expect(result == "https://forgejo.example.com")
}
@Test func preservesPort() {
let result = ForgejoClient.normalizeServerURL("https://forgejo.example.com:3000")
#expect(result == "https://forgejo.example.com:3000")
}
@Test func preservesPath() {
let result = ForgejoClient.normalizeServerURL("https://example.com/forgejo")
#expect(result == "https://example.com/forgejo")
}
@Test func stripsMultipleTrailingSlashes() {
let result = ForgejoClient.normalizeServerURL("https://example.com///")
#expect(result == "https://example.com")
}
}

View file

@ -0,0 +1,249 @@
import Foundation
import Testing
@testable import ForgejoKit
struct NotificationTests {
private func decoder() -> JSONDecoder {
let d = JSONDecoder()
d.dateDecodingStrategy = forgejoDateDecodingStrategy
return d
}
// MARK: - Subject number extraction
@Test func extractsIssueNumberFromSubjectUrl() {
let url = "https://forgejo.example.com/api/v1/repos/org/app/issues/42"
#expect(notificationSubjectNumber(from: url) == 42)
}
@Test func extractsPullRequestNumberFromSubjectUrl() {
let url = "https://forgejo.example.com/api/v1/repos/user/repo/pulls/7"
#expect(notificationSubjectNumber(from: url) == 7)
}
@Test func returnsNilForNilUrl() {
#expect(notificationSubjectNumber(from: nil) == nil)
}
@Test func returnsNilForNonNumericLastComponent() {
let url = "https://forgejo.example.com/api/v1/repos/org/app/commits/abc123"
#expect(notificationSubjectNumber(from: url) == nil)
}
@Test func returnsNilForEmptyString() {
#expect(notificationSubjectNumber(from: "") == nil)
}
@Test func returnsNilForInvalidUrl() {
#expect(notificationSubjectNumber(from: "not a url at all %%%") == nil)
}
@Test func extractsNumberFromUrlWithTrailingSlash() {
let url = "https://forgejo.example.com/api/v1/repos/org/app/issues/42/"
#expect(notificationSubjectNumber(from: url) == 42)
}
@Test func extractsLargeNumber() {
let url = "https://forgejo.example.com/api/v1/repos/org/app/issues/99999"
#expect(notificationSubjectNumber(from: url) == 99999)
}
@Test func extractsNumberFromUrlWithQueryParams() {
let url = "https://forgejo.example.com/api/v1/repos/org/app/issues/42?foo=bar&baz=1"
#expect(notificationSubjectNumber(from: url) == 42)
}
@Test func extractsNumberFromUrlWithFragment() {
let url = "https://forgejo.example.com/api/v1/repos/org/app/pulls/7#section"
#expect(notificationSubjectNumber(from: url) == 7)
}
@Test func extractsNumberFromUrlWithQueryAndFragment() {
let url = "https://forgejo.example.com/api/v1/repos/org/app/issues/15?ref=main#comment-1"
#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 {
let json = """
[
{
"id": 1,
"unread": true,
"pinned": false,
"updated_at": "2024-06-15T14:30:00Z",
"subject": {
"type": "Issue",
"title": "Bug report",
"url": "https://forgejo.example.com/api/v1/repos/org/app/issues/1",
"state": "open"
},
"repository": {"id": 10, "name": "app", "full_name": "org/app"}
},
{
"id": 2,
"unread": false,
"pinned": true,
"updated_at": "2024-06-14T10:00:00Z",
"subject": {
"type": "Pull",
"title": "Add feature",
"url": "https://forgejo.example.com/api/v1/repos/org/app/pulls/5",
"state": "closed"
},
"repository": {"id": 10, "name": "app", "full_name": "org/app"}
}
]
"""
let notifications = try decoder().decode([NotificationThread].self, from: Data(json.utf8))
#expect(notifications.count == 2)
#expect(notifications[0].id == 1)
#expect(notifications[0].unread == true)
#expect(notifications[0].subject.type == "Issue")
#expect(notifications[1].id == 2)
#expect(notifications[1].pinned == true)
#expect(notifications[1].subject.type == "Pull")
}
@Test func decodesEmptyNotificationArray() throws {
let json = "[]"
let notifications = try decoder().decode([NotificationThread].self, from: Data(json.utf8))
#expect(notifications.isEmpty)
}
@Test func decodesNotificationCountZero() throws {
let json = #"{"new": 0}"#
let count = try decoder().decode(NotificationCount.self, from: Data(json.utf8))
#expect(count.new == 0)
}
@Test func decodesNotificationCountLargeValue() throws {
let json = #"{"new": 9999}"#
let count = try decoder().decode(NotificationCount.self, from: Data(json.utf8))
#expect(count.new == 9999)
}
// MARK: - Date handling in notifications
@Test func decodesNotificationWithFractionalSecondsDate() throws {
let json = """
{
"id": 5,
"unread": true,
"pinned": false,
"updated_at": "2024-06-15T14:30:00.123Z",
"subject": {"type": "Issue", "title": "Test"},
"repository": {"id": 1, "name": "r", "full_name": "u/r"}
}
"""
let notification = try decoder().decode(NotificationThread.self, from: Data(json.utf8))
#expect(notification.id == 5)
#expect(notification.updatedAt.timeIntervalSince1970 > 0)
}
// MARK: - Extra/unknown fields are ignored
@Test func decodesNotificationIgnoringExtraFields() throws {
let json = """
{
"id": 6,
"unread": true,
"pinned": false,
"updated_at": "2024-06-15T14:30:00Z",
"some_future_field": "should be ignored",
"subject": {
"type": "Issue",
"title": "Test",
"some_other_field": 42
},
"repository": {"id": 1, "name": "r", "full_name": "u/r", "extra": true}
}
"""
let notification = try decoder().decode(NotificationThread.self, from: Data(json.utf8))
#expect(notification.id == 6)
#expect(notification.subject.title == "Test")
}
// MARK: - Required fields validation
@Test func failsDecodingNotificationMissingSubject() {
let json = """
{
"id": 7,
"unread": true,
"pinned": false,
"updated_at": "2024-06-15T14:30:00Z",
"repository": {"id": 1, "name": "r", "full_name": "u/r"}
}
"""
#expect(throws: DecodingError.self) {
_ = try decoder().decode(NotificationThread.self, from: Data(json.utf8))
}
}
@Test func failsDecodingNotificationMissingRepository() {
let json = """
{
"id": 8,
"unread": true,
"pinned": false,
"updated_at": "2024-06-15T14:30:00Z",
"subject": {"type": "Issue", "title": "Test"}
}
"""
#expect(throws: DecodingError.self) {
_ = try decoder().decode(NotificationThread.self, from: Data(json.utf8))
}
}
@Test func failsDecodingNotificationMissingId() {
let json = """
{
"unread": true,
"pinned": false,
"updated_at": "2024-06-15T14:30:00Z",
"subject": {"type": "Issue", "title": "Test"},
"repository": {"id": 1, "name": "r", "full_name": "u/r"}
}
"""
#expect(throws: DecodingError.self) {
_ = try decoder().decode(NotificationThread.self, from: Data(json.utf8))
}
}
@Test func failsDecodingNotificationCountMissingNewField() {
let json = #"{}"#
#expect(throws: DecodingError.self) {
_ = try decoder().decode(NotificationCount.self, from: Data(json.utf8))
}
}
@Test func failsDecodingSubjectMissingTitle() {
let json = """
{
"id": 9,
"unread": true,
"pinned": false,
"updated_at": "2024-06-15T14:30:00Z",
"subject": {"type": "Issue"},
"repository": {"id": 1, "name": "r", "full_name": "u/r"}
}
"""
#expect(throws: DecodingError.self) {
_ = try decoder().decode(NotificationThread.self, from: Data(json.utf8))
}
}
}

View file

@ -0,0 +1,63 @@
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")
}
// MARK: - fetchContents URL construction
@Test func makeURLContentsWithoutRef() throws {
let client = makeClient()
let url = try client.makeURL(path: "/api/v1/repos/owner/repo/contents")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/contents")
}
@Test func makeURLContentsWithRef() throws {
let client = makeClient()
let queryItems = [URLQueryItem(name: "ref", value: "develop")]
let url = try client.makeURL(path: "/api/v1/repos/owner/repo/contents", queryItems: queryItems)
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/contents?ref=develop")
}
@Test func makeURLContentsWithRefAndSubpath() throws {
let client = makeClient()
let queryItems = [URLQueryItem(name: "ref", value: "feature/login")]
let url = try client.makeURL(path: "/api/v1/repos/owner/repo/contents/src/main.py", queryItems: queryItems)
#expect(url.query?.contains("ref=feature/login") == true)
#expect(url.path == "/api/v1/repos/owner/repo/contents/src/main.py")
}
@Test func makeURLFileContentWithRef() throws {
let client = makeClient()
let queryItems = [URLQueryItem(name: "ref", value: "v1.0")]
let url = try client.makeURL(path: "/api/v1/repos/owner/repo/contents/README.md", queryItems: queryItems)
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/contents/README.md?ref=v1.0")
}
@Test func makeURLWithEmptyQueryItems() throws {
let client = makeClient()
let url = try client.makeURL(path: "/api/v1/repos/owner/repo/contents", queryItems: [])
#expect(url.query == nil)
}
// MARK: - ref parameter nil-coalescence (same pattern used by fetchContents and fetchFileContent)
@Test func optionalRefProducesEmptyQueryItems() {
let ref: String? = nil
let queryItems = ref.map { [URLQueryItem(name: "ref", value: $0)] } ?? []
#expect(queryItems.isEmpty)
}
@Test func presentRefProducesQueryItem() {
let ref: String? = "main"
let queryItems = ref.map { [URLQueryItem(name: "ref", value: $0)] } ?? []
#expect(queryItems.count == 1)
#expect(queryItems.first?.name == "ref")
#expect(queryItems.first?.value == "main")
}
}

View file

@ -0,0 +1,150 @@
import Foundation
import Testing
@testable import ForgejoKit
struct URLSessionManagerTests {
// MARK: - trustedHost scoping
@Test func selfSignedWithoutTrustedHostAcceptsAny() {
let manager = URLSessionManager(allowSelfSignedCertificates: true, trustedHost: nil)
#expect(manager.allowSelfSignedCertificates == true)
}
@Test func selfSignedWithTrustedHostIsSet() {
let manager = URLSessionManager(allowSelfSignedCertificates: true, trustedHost: "forgejo.example.com")
#expect(manager.allowSelfSignedCertificates == true)
}
@Test func selfSignedDisabledByDefault() {
let manager = URLSessionManager()
#expect(manager.allowSelfSignedCertificates == false)
}
@Test func sessionIsCreatedWithConfiguration() {
let manager = URLSessionManager(allowSelfSignedCertificates: false)
let session = manager.session
#expect(session.configuration.timeoutIntervalForRequest == 30)
#expect(session.configuration.timeoutIntervalForResource == 300)
}
// MARK: - Trust disposition logic
@Test func selfSignedDisabledReturnsDefaultHandling() {
let manager = URLSessionManager(allowSelfSignedCertificates: false)
let disposition = manager.trustDisposition(for: "forgejo.example.com")
#expect(disposition == .performDefaultHandling)
}
@Test func selfSignedEnabledWithNilTrustedHostReturnsDefaultHandling() {
let manager = URLSessionManager(allowSelfSignedCertificates: true, trustedHost: nil)
let disposition = manager.trustDisposition(for: "forgejo.example.com")
#expect(disposition == .performDefaultHandling)
}
@Test func selfSignedEnabledWithMatchingHostReturnsUseCredential() {
let manager = URLSessionManager(allowSelfSignedCertificates: true, trustedHost: "forgejo.example.com")
let disposition = manager.trustDisposition(for: "forgejo.example.com")
#expect(disposition == .useCredential)
}
@Test func selfSignedEnabledWithMismatchedHostReturnsDefaultHandling() {
let manager = URLSessionManager(allowSelfSignedCertificates: true, trustedHost: "forgejo.example.com")
let disposition = manager.trustDisposition(for: "evil.example.com")
#expect(disposition == .performDefaultHandling)
}
@Test func selfSignedTrustMatchesCaseInsensitive() {
let manager = URLSessionManager(allowSelfSignedCertificates: true, trustedHost: "Forgejo.Example.COM")
let disposition = manager.trustDisposition(for: "forgejo.example.com")
#expect(disposition == .useCredential)
}
}
struct ForgejoClientHostMatchTests {
// MARK: - Auth header host matching
@Test func authenticatedRequestIncludesAuthForMatchingHost() {
let client = ForgejoClient(
serverURL: "https://forgejo.example.com",
username: "user",
password: "pass"
)
let url = URL(string: "https://forgejo.example.com/api/v1/user")!
let request = client.authenticatedRequest(url: url)
#expect(request.value(forHTTPHeaderField: "Authorization") != nil)
}
@Test func authenticatedRequestOmitsAuthForDifferentHost() {
let client = ForgejoClient(
serverURL: "https://forgejo.example.com",
username: "user",
password: "pass"
)
let url = URL(string: "https://evil.example.com/api/v1/user")!
let request = client.authenticatedRequest(url: url)
#expect(request.value(forHTTPHeaderField: "Authorization") == nil)
}
@Test func authenticatedRequestMatchesCaseInsensitiveHost() {
let client = ForgejoClient(
serverURL: "https://Forgejo.Example.COM",
username: "user",
password: "pass"
)
let url = URL(string: "https://forgejo.example.com/api/v1/repos")!
let request = client.authenticatedRequest(url: url)
#expect(request.value(forHTTPHeaderField: "Authorization") != nil)
}
@Test func authenticatedRequestSetsMethodAndBody() {
let client = ForgejoClient(
serverURL: "https://forgejo.example.com",
username: "user",
password: "pass"
)
let url = 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")
#expect(request.httpBody == body)
#expect(request.value(forHTTPHeaderField: "Content-Type") == "application/json")
}
@Test func makeURLConstructsValidURL() throws {
let client = ForgejoClient(
serverURL: "https://forgejo.example.com",
username: "user",
password: "pass"
)
let url = try client.makeURL(path: "/api/v1/user")
#expect(url.absoluteString == "https://forgejo.example.com/api/v1/user")
}
@Test func makeURLWithQueryItems() throws {
let client = ForgejoClient(
serverURL: "https://forgejo.example.com",
username: "user",
password: "pass"
)
let url = try client.makeURL(path: "/api/v1/repos/search", queryItems: [
URLQueryItem(name: "q", value: "test"),
URLQueryItem(name: "page", value: "2")
])
#expect(url.absoluteString.contains("q=test"))
#expect(url.absoluteString.contains("page=2"))
}
@Test func clientExtractsHostForTrustedCert() {
// Verify that the client passes the right host to URLSessionManager
let client = ForgejoClient(
serverURL: "https://my-forgejo.local:3000",
username: "user",
password: "pass",
allowSelfSignedCertificates: true
)
// The session should be created with self-signed support
#expect(client.serverURL == "https://my-forgejo.local:3000")
}
}

61
flake.lock Normal file
View file

@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1772173633,
"narHash": "sha256-MOH58F4AIbCkh6qlQcwMycyk5SWvsqnS/TCfnqDlpj4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "c0f3d81a7ddbc2b1332be0d8481a672b4f6004d6",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

31
flake.nix Normal file
View file

@ -0,0 +1,31 @@
{
description = "ForgejoKit - Swift native Forgejo API library";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{ nixpkgs, flake-utils, ... }:
flake-utils.lib.eachSystem [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ] (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
devShells.default = pkgs.mkShell {
packages =
[
pkgs.just
pkgs.swift
pkgs.swiftformat
]
++ pkgs.lib.optionals pkgs.stdenv.hostPlatform.isDarwin [
pkgs.xcbeautify
pkgs.swiftlint
];
};
}
);
}

29
justfile Normal file
View file

@ -0,0 +1,29 @@
default:
@just --list
build:
swift build 2>&1 | xcbeautify
test:
swift test 2>&1 | xcbeautify
lint:
swiftlint lint Sources
format:
swiftformat Sources
# Tag and push a new release: just release 1.0.0
release version:
#!/usr/bin/env bash
set -euo pipefail
if git rev-parse "{{version}}" >/dev/null 2>&1; then
echo "Error: tag '{{version}}' already exists."
exit 1
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 tag "{{version}}"
git push
git push origin "{{version}}"