mirror of
https://codeberg.org/secana/ForgejoKit.git
synced 2026-06-16 05:13:53 -07:00
feat: initial commit
Intitial commit for ForgejoKit, a native Swift library to interact with the Frogejo API
This commit is contained in:
commit
897a8ebedd
37 changed files with 5040 additions and 0 deletions
1
.envrc
Normal file
1
.envrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
use flake
|
||||
68
.gitignore
vendored
Normal file
68
.gitignore
vendored
Normal 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
2
.swiftformat
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
--swiftversion 6.2
|
||||
--disable redundantMemberwiseInit
|
||||
5
.swiftlint.yml
Normal file
5
.swiftlint.yml
Normal 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
21
LICENSE
Normal 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
18
Package.swift
Normal 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
91
README.md
Normal 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.
|
||||
35
Sources/ForgejoKit/Helpers/DateFormatting.swift
Normal file
35
Sources/ForgejoKit/Helpers/DateFormatting.swift
Normal 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)")
|
||||
}
|
||||
24
Sources/ForgejoKit/Helpers/DecodingError.swift
Normal file
24
Sources/ForgejoKit/Helpers/DecodingError.swift
Normal 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
|
||||
}
|
||||
}
|
||||
260
Sources/ForgejoKit/Helpers/DiffParser.swift
Normal file
260
Sources/ForgejoKit/Helpers/DiffParser.swift
Normal 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: ¤tHunkLines,
|
||||
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)
|
||||
}
|
||||
}
|
||||
5
Sources/ForgejoKit/Helpers/OptionalCollection.swift
Normal file
5
Sources/ForgejoKit/Helpers/OptionalCollection.swift
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
extension Optional where Wrapped: Collection {
|
||||
var nilIfEmpty: Self {
|
||||
self?.isEmpty == true ? nil : self
|
||||
}
|
||||
}
|
||||
91
Sources/ForgejoKit/Models/Commit.swift
Normal file
91
Sources/ForgejoKit/Models/Commit.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
127
Sources/ForgejoKit/Models/Issue.swift
Normal file
127
Sources/ForgejoKit/Models/Issue.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
25
Sources/ForgejoKit/Models/IssueComment.swift
Normal file
25
Sources/ForgejoKit/Models/IssueComment.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
76
Sources/ForgejoKit/Models/Notification.swift
Normal file
76
Sources/ForgejoKit/Models/Notification.swift
Normal 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)
|
||||
}
|
||||
122
Sources/ForgejoKit/Models/PullRequest.swift
Normal file
122
Sources/ForgejoKit/Models/PullRequest.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
111
Sources/ForgejoKit/Models/PullRequestReview.swift
Normal file
111
Sources/ForgejoKit/Models/PullRequestReview.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
229
Sources/ForgejoKit/Models/Repository.swift
Normal file
229
Sources/ForgejoKit/Models/Repository.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
145
Sources/ForgejoKit/Models/RepositoryContent.swift
Normal file
145
Sources/ForgejoKit/Models/RepositoryContent.swift
Normal 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
|
||||
}
|
||||
}
|
||||
35
Sources/ForgejoKit/Models/User.swift
Normal file
35
Sources/ForgejoKit/Models/User.swift
Normal 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
|
||||
}
|
||||
}
|
||||
62
Sources/ForgejoKit/Networking/URLSessionManager.swift
Normal file
62
Sources/ForgejoKit/Networking/URLSessionManager.swift
Normal 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
|
||||
}
|
||||
}
|
||||
395
Sources/ForgejoKit/Services/ForgejoClient.swift
Normal file
395
Sources/ForgejoKit/Services/ForgejoClient.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
187
Sources/ForgejoKit/Services/IssueService.swift
Normal file
187
Sources/ForgejoKit/Services/IssueService.swift
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
38
Sources/ForgejoKit/Services/NotificationService.swift
Normal file
38
Sources/ForgejoKit/Services/NotificationService.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
267
Sources/ForgejoKit/Services/PullRequestService.swift
Normal file
267
Sources/ForgejoKit/Services/PullRequestService.swift
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
301
Sources/ForgejoKit/Services/RepositoryService.swift
Normal file
301
Sources/ForgejoKit/Services/RepositoryService.swift
Normal 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
|
||||
}
|
||||
}
|
||||
83
Tests/ForgejoKitTests/DateDecodingTests.swift
Normal file
83
Tests/ForgejoKitTests/DateDecodingTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
363
Tests/ForgejoKitTests/DiffParserTests.swift
Normal file
363
Tests/ForgejoKitTests/DiffParserTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
81
Tests/ForgejoKitTests/FileContentTests.swift
Normal file
81
Tests/ForgejoKitTests/FileContentTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
1138
Tests/ForgejoKitTests/ModelDecodingTests.swift
Normal file
1138
Tests/ForgejoKitTests/ModelDecodingTests.swift
Normal file
File diff suppressed because it is too large
Load diff
51
Tests/ForgejoKitTests/NormalizeServerURLTests.swift
Normal file
51
Tests/ForgejoKitTests/NormalizeServerURLTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
249
Tests/ForgejoKitTests/NotificationTests.swift
Normal file
249
Tests/ForgejoKitTests/NotificationTests.swift
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
63
Tests/ForgejoKitTests/RepositoryServiceURLTests.swift
Normal file
63
Tests/ForgejoKitTests/RepositoryServiceURLTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
150
Tests/ForgejoKitTests/URLSessionManagerTests.swift
Normal file
150
Tests/ForgejoKitTests/URLSessionManagerTests.swift
Normal 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
61
flake.lock
Normal 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
31
flake.nix
Normal 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
29
justfile
Normal 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}}"
|
||||
Loading…
Reference in a new issue