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