From 897a8ebeddfc97444c27752df8e99eda589eeaba Mon Sep 17 00:00:00 2001 From: Stefan Hausotte Date: Sat, 28 Feb 2026 20:10:51 +0100 Subject: [PATCH] feat: initial commit Intitial commit for ForgejoKit, a native Swift library to interact with the Frogejo API --- .envrc | 1 + .gitignore | 68 + .swiftformat | 2 + .swiftlint.yml | 5 + LICENSE | 21 + Package.swift | 18 + README.md | 91 ++ .../ForgejoKit/Helpers/DateFormatting.swift | 35 + .../ForgejoKit/Helpers/DecodingError.swift | 24 + Sources/ForgejoKit/Helpers/DiffParser.swift | 260 ++++ .../Helpers/OptionalCollection.swift | 5 + Sources/ForgejoKit/Models/Commit.swift | 91 ++ Sources/ForgejoKit/Models/Issue.swift | 127 ++ Sources/ForgejoKit/Models/IssueComment.swift | 25 + Sources/ForgejoKit/Models/Notification.swift | 76 ++ Sources/ForgejoKit/Models/PullRequest.swift | 122 ++ .../ForgejoKit/Models/PullRequestReview.swift | 111 ++ Sources/ForgejoKit/Models/Repository.swift | 229 ++++ .../ForgejoKit/Models/RepositoryContent.swift | 145 +++ Sources/ForgejoKit/Models/User.swift | 35 + .../Networking/URLSessionManager.swift | 62 + .../ForgejoKit/Services/ForgejoClient.swift | 395 ++++++ .../ForgejoKit/Services/IssueService.swift | 187 +++ .../Services/NotificationService.swift | 38 + .../Services/PullRequestService.swift | 267 ++++ .../Services/RepositoryService.swift | 301 +++++ Tests/ForgejoKitTests/DateDecodingTests.swift | 83 ++ Tests/ForgejoKitTests/DiffParserTests.swift | 363 ++++++ Tests/ForgejoKitTests/FileContentTests.swift | 81 ++ .../ForgejoKitTests/ModelDecodingTests.swift | 1138 +++++++++++++++++ .../NormalizeServerURLTests.swift | 51 + Tests/ForgejoKitTests/NotificationTests.swift | 249 ++++ .../RepositoryServiceURLTests.swift | 63 + .../URLSessionManagerTests.swift | 150 +++ flake.lock | 61 + flake.nix | 31 + justfile | 29 + 37 files changed, 5040 insertions(+) create mode 100644 .envrc create mode 100644 .gitignore create mode 100644 .swiftformat create mode 100644 .swiftlint.yml create mode 100644 LICENSE create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/ForgejoKit/Helpers/DateFormatting.swift create mode 100644 Sources/ForgejoKit/Helpers/DecodingError.swift create mode 100644 Sources/ForgejoKit/Helpers/DiffParser.swift create mode 100644 Sources/ForgejoKit/Helpers/OptionalCollection.swift create mode 100644 Sources/ForgejoKit/Models/Commit.swift create mode 100644 Sources/ForgejoKit/Models/Issue.swift create mode 100644 Sources/ForgejoKit/Models/IssueComment.swift create mode 100644 Sources/ForgejoKit/Models/Notification.swift create mode 100644 Sources/ForgejoKit/Models/PullRequest.swift create mode 100644 Sources/ForgejoKit/Models/PullRequestReview.swift create mode 100644 Sources/ForgejoKit/Models/Repository.swift create mode 100644 Sources/ForgejoKit/Models/RepositoryContent.swift create mode 100644 Sources/ForgejoKit/Models/User.swift create mode 100644 Sources/ForgejoKit/Networking/URLSessionManager.swift create mode 100644 Sources/ForgejoKit/Services/ForgejoClient.swift create mode 100644 Sources/ForgejoKit/Services/IssueService.swift create mode 100644 Sources/ForgejoKit/Services/NotificationService.swift create mode 100644 Sources/ForgejoKit/Services/PullRequestService.swift create mode 100644 Sources/ForgejoKit/Services/RepositoryService.swift create mode 100644 Tests/ForgejoKitTests/DateDecodingTests.swift create mode 100644 Tests/ForgejoKitTests/DiffParserTests.swift create mode 100644 Tests/ForgejoKitTests/FileContentTests.swift create mode 100644 Tests/ForgejoKitTests/ModelDecodingTests.swift create mode 100644 Tests/ForgejoKitTests/NormalizeServerURLTests.swift create mode 100644 Tests/ForgejoKitTests/NotificationTests.swift create mode 100644 Tests/ForgejoKitTests/RepositoryServiceURLTests.swift create mode 100644 Tests/ForgejoKitTests/URLSessionManagerTests.swift create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 justfile diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6deadc1 --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..e11af52 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,2 @@ +--swiftversion 6.2 +--disable redundantMemberwiseInit diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..eef552d --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,5 @@ +# Style/formatting rules are owned by SwiftFormat. +# SwiftLint focuses on code quality and safety. +disabled_rules: + - trailing_comma + - opening_brace diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eed84d5 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..e204afc --- /dev/null +++ b/Package.swift @@ -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"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..deaeca3 --- /dev/null +++ b/README.md @@ -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. diff --git a/Sources/ForgejoKit/Helpers/DateFormatting.swift b/Sources/ForgejoKit/Helpers/DateFormatting.swift new file mode 100644 index 0000000..81e535c --- /dev/null +++ b/Sources/ForgejoKit/Helpers/DateFormatting.swift @@ -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)") +} diff --git a/Sources/ForgejoKit/Helpers/DecodingError.swift b/Sources/ForgejoKit/Helpers/DecodingError.swift new file mode 100644 index 0000000..ee511b1 --- /dev/null +++ b/Sources/ForgejoKit/Helpers/DecodingError.swift @@ -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 + } +} diff --git a/Sources/ForgejoKit/Helpers/DiffParser.swift b/Sources/ForgejoKit/Helpers/DiffParser.swift new file mode 100644 index 0000000..7c27c24 --- /dev/null +++ b/Sources/ForgejoKit/Helpers/DiffParser.swift @@ -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) + } +} diff --git a/Sources/ForgejoKit/Helpers/OptionalCollection.swift b/Sources/ForgejoKit/Helpers/OptionalCollection.swift new file mode 100644 index 0000000..71172c6 --- /dev/null +++ b/Sources/ForgejoKit/Helpers/OptionalCollection.swift @@ -0,0 +1,5 @@ +extension Optional where Wrapped: Collection { + var nilIfEmpty: Self { + self?.isEmpty == true ? nil : self + } +} diff --git a/Sources/ForgejoKit/Models/Commit.swift b/Sources/ForgejoKit/Models/Commit.swift new file mode 100644 index 0000000..06c34af --- /dev/null +++ b/Sources/ForgejoKit/Models/Commit.swift @@ -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" + } +} diff --git a/Sources/ForgejoKit/Models/Issue.swift b/Sources/ForgejoKit/Models/Issue.swift new file mode 100644 index 0000000..add6f29 --- /dev/null +++ b/Sources/ForgejoKit/Models/Issue.swift @@ -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" + } +} diff --git a/Sources/ForgejoKit/Models/IssueComment.swift b/Sources/ForgejoKit/Models/IssueComment.swift new file mode 100644 index 0000000..835b9f1 --- /dev/null +++ b/Sources/ForgejoKit/Models/IssueComment.swift @@ -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" + } +} diff --git a/Sources/ForgejoKit/Models/Notification.swift b/Sources/ForgejoKit/Models/Notification.swift new file mode 100644 index 0000000..4e8cca9 --- /dev/null +++ b/Sources/ForgejoKit/Models/Notification.swift @@ -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) +} diff --git a/Sources/ForgejoKit/Models/PullRequest.swift b/Sources/ForgejoKit/Models/PullRequest.swift new file mode 100644 index 0000000..d975d96 --- /dev/null +++ b/Sources/ForgejoKit/Models/PullRequest.swift @@ -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" + } +} diff --git a/Sources/ForgejoKit/Models/PullRequestReview.swift b/Sources/ForgejoKit/Models/PullRequestReview.swift new file mode 100644 index 0000000..4be1e44 --- /dev/null +++ b/Sources/ForgejoKit/Models/PullRequestReview.swift @@ -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" + } +} diff --git a/Sources/ForgejoKit/Models/Repository.swift b/Sources/ForgejoKit/Models/Repository.swift new file mode 100644 index 0000000..cb814a1 --- /dev/null +++ b/Sources/ForgejoKit/Models/Repository.swift @@ -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" + } +} diff --git a/Sources/ForgejoKit/Models/RepositoryContent.swift b/Sources/ForgejoKit/Models/RepositoryContent.swift new file mode 100644 index 0000000..a764446 --- /dev/null +++ b/Sources/ForgejoKit/Models/RepositoryContent.swift @@ -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 + } +} diff --git a/Sources/ForgejoKit/Models/User.swift b/Sources/ForgejoKit/Models/User.swift new file mode 100644 index 0000000..a31cf61 --- /dev/null +++ b/Sources/ForgejoKit/Models/User.swift @@ -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 + } +} diff --git a/Sources/ForgejoKit/Networking/URLSessionManager.swift b/Sources/ForgejoKit/Networking/URLSessionManager.swift new file mode 100644 index 0000000..aea0137 --- /dev/null +++ b/Sources/ForgejoKit/Networking/URLSessionManager.swift @@ -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 + } +} diff --git a/Sources/ForgejoKit/Services/ForgejoClient.swift b/Sources/ForgejoKit/Services/ForgejoClient.swift new file mode 100644 index 0000000..7b7cdd4 --- /dev/null +++ b/Sources/ForgejoKit/Services/ForgejoClient.swift @@ -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 = [ + .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( + 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)" + } + } +} diff --git a/Sources/ForgejoKit/Services/IssueService.swift b/Sources/ForgejoKit/Services/IssueService.swift new file mode 100644 index 0000000..7686484 --- /dev/null +++ b/Sources/ForgejoKit/Services/IssueService.swift @@ -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, + ) + } +} diff --git a/Sources/ForgejoKit/Services/NotificationService.swift b/Sources/ForgejoKit/Services/NotificationService.swift new file mode 100644 index 0000000..4227c74 --- /dev/null +++ b/Sources/ForgejoKit/Services/NotificationService.swift @@ -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) + } +} diff --git a/Sources/ForgejoKit/Services/PullRequestService.swift b/Sources/ForgejoKit/Services/PullRequestService.swift new file mode 100644 index 0000000..c582cf5 --- /dev/null +++ b/Sources/ForgejoKit/Services/PullRequestService.swift @@ -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, + ) + } +} diff --git a/Sources/ForgejoKit/Services/RepositoryService.swift b/Sources/ForgejoKit/Services/RepositoryService.swift new file mode 100644 index 0000000..612d640 --- /dev/null +++ b/Sources/ForgejoKit/Services/RepositoryService.swift @@ -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 { + var ids = Set() + 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 + } +} diff --git a/Tests/ForgejoKitTests/DateDecodingTests.swift b/Tests/ForgejoKitTests/DateDecodingTests.swift new file mode 100644 index 0000000..d067c30 --- /dev/null +++ b/Tests/ForgejoKitTests/DateDecodingTests.swift @@ -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) + } +} diff --git a/Tests/ForgejoKitTests/DiffParserTests.swift b/Tests/ForgejoKitTests/DiffParserTests.swift new file mode 100644 index 0000000..1aad492 --- /dev/null +++ b/Tests/ForgejoKitTests/DiffParserTests.swift @@ -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) + } +} diff --git a/Tests/ForgejoKitTests/FileContentTests.swift b/Tests/ForgejoKitTests/FileContentTests.swift new file mode 100644 index 0000000..7585087 --- /dev/null +++ b/Tests/ForgejoKitTests/FileContentTests.swift @@ -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") + } +} diff --git a/Tests/ForgejoKitTests/ModelDecodingTests.swift b/Tests/ForgejoKitTests/ModelDecodingTests.swift new file mode 100644 index 0000000..688ae42 --- /dev/null +++ b/Tests/ForgejoKitTests/ModelDecodingTests.swift @@ -0,0 +1,1138 @@ +import Foundation +import Testing +@testable import ForgejoKit + +struct ModelDecodingTests { + + private func decoder() -> JSONDecoder { + let d = JSONDecoder() + d.dateDecodingStrategy = forgejoDateDecodingStrategy + return d + } + + // MARK: - User + + @Test func decodesUser() throws { + let json = """ + { + "id": 1, + "login": "admin", + "full_name": "Admin User", + "email": "admin@example.com", + "avatar_url": "https://example.com/avatar.png", + "is_admin": true, + "created": "2024-01-01T00:00:00Z" + } + """ + let user = try decoder().decode(User.self, from: Data(json.utf8)) + #expect(user.id == 1) + #expect(user.login == "admin") + #expect(user.fullName == "Admin User") + #expect(user.email == "admin@example.com") + #expect(user.isAdmin == true) + } + + @Test func decodesUserWithMinimalFields() throws { + let json = """ + { + "id": 2, + "login": "bot" + } + """ + let user = try decoder().decode(User.self, from: Data(json.utf8)) + #expect(user.id == 2) + #expect(user.login == "bot") + #expect(user.fullName == nil) + #expect(user.email == nil) + } + + // MARK: - IssueLabel + + @Test func decodesIssueLabel() throws { + let json = """ + { + "id": 10, + "name": "bug", + "color": "ee0701", + "description": "Something is broken" + } + """ + let label = try decoder().decode(IssueLabel.self, from: Data(json.utf8)) + #expect(label.id == 10) + #expect(label.name == "bug") + #expect(label.color == "ee0701") + } + + // MARK: - IssueComment + + @Test func decodesIssueComment() throws { + let json = """ + { + "id": 100, + "body": "Looks good!", + "user": {"id": 1, "login": "reviewer"}, + "created_at": "2024-05-01T12:00:00Z", + "updated_at": "2024-05-01T12:00:00Z" + } + """ + let comment = try decoder().decode(IssueComment.self, from: Data(json.utf8)) + #expect(comment.id == 100) + #expect(comment.body == "Looks good!") + #expect(comment.user.login == "reviewer") + } + + // MARK: - Issue + + @Test func decodesIssue() throws { + let json = """ + { + "id": 50, + "number": 7, + "title": "Fix login bug", + "body": "The login button does not work", + "state": "open", + "user": {"id": 1, "login": "reporter"}, + "labels": [{"id": 1, "name": "bug", "color": "ff0000"}], + "assignees": [], + "created_at": "2024-02-01T09:00:00Z", + "updated_at": "2024-02-02T10:00:00Z", + "comments": 3 + } + """ + let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) + #expect(issue.number == 7) + #expect(issue.title == "Fix login bug") + #expect(issue.state == "open") + #expect(issue.labels.count == 1) + #expect(issue.comments == 3) + #expect(issue.repository == nil) + } + + @Test func decodesIssueWithMilestoneAndAssignees() throws { + let json = """ + { + "id": 56, + "number": 13, + "title": "Issue with metadata", + "body": "Has milestone and assignees", + "state": "open", + "user": {"id": 1, "login": "reporter"}, + "labels": [{"id": 1, "name": "bug", "color": "ff0000"}], + "milestone": { + "id": 5, + "title": "v1.0", + "description": "First release", + "state": "open", + "due_on": "2026-03-01T00:00:00Z" + }, + "assignees": [ + {"id": 2, "login": "dev1", "full_name": "Developer One"}, + {"id": 3, "login": "dev2"} + ], + "created_at": "2024-02-01T09:00:00Z", + "updated_at": "2024-02-02T10:00:00Z", + "comments": 0 + } + """ + let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) + #expect(issue.number == 13) + #expect(issue.labels.count == 1) + #expect(issue.milestone?.id == 5) + #expect(issue.milestone?.title == "v1.0") + #expect(issue.milestone?.description == "First release") + #expect(issue.milestone?.state == "open") + #expect(issue.milestone?.dueOn != nil) + #expect(issue.assignees?.count == 2) + #expect(issue.assignees?[0].login == "dev1") + #expect(issue.assignees?[0].fullName == "Developer One") + #expect(issue.assignees?[1].login == "dev2") + } + + @Test func decodesIssueWithNilMilestoneAndEmptyAssignees() throws { + let json = """ + { + "id": 57, + "number": 14, + "title": "Issue without metadata", + "state": "open", + "user": {"id": 1, "login": "reporter"}, + "labels": [], + "milestone": null, + "assignees": [], + "created_at": "2024-02-01T09:00:00Z", + "updated_at": "2024-02-02T10:00:00Z", + "comments": 0 + } + """ + let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) + #expect(issue.milestone == nil) + #expect(issue.assignees?.isEmpty == true) + } + + @Test func decodesIssueMilestone() throws { + let json = """ + { + "id": 1, + "title": "v2.0", + "description": "Second major release", + "state": "open", + "due_on": "2026-06-15T00:00:00Z", + "closed_at": null + } + """ + let milestone = try decoder().decode(IssueMilestone.self, from: Data(json.utf8)) + #expect(milestone.id == 1) + #expect(milestone.title == "v2.0") + #expect(milestone.description == "Second major release") + #expect(milestone.state == "open") + #expect(milestone.dueOn != nil) + #expect(milestone.closedAt == nil) + } + + @Test func decodesIssueMilestoneWithMinimalFields() throws { + let json = """ + { + "id": 2, + "title": "Backlog", + "state": "open" + } + """ + let milestone = try decoder().decode(IssueMilestone.self, from: Data(json.utf8)) + #expect(milestone.id == 2) + #expect(milestone.title == "Backlog") + #expect(milestone.description == nil) + #expect(milestone.dueOn == nil) + #expect(milestone.closedAt == nil) + } + + @Test func decodesIssueWithMinimalRepository() throws { + let json = """ + { + "id": 52, + "number": 9, + "title": "Search result issue", + "state": "open", + "user": {"id": 1, "login": "reporter"}, + "labels": [], + "created_at": "2024-02-01T09:00:00Z", + "updated_at": "2024-02-02T10:00:00Z", + "comments": 1, + "repository": { + "id": 10, + "name": "backend", + "full_name": "org/backend" + } + } + """ + let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) + #expect(issue.number == 9) + #expect(issue.repository?.id == 10) + #expect(issue.repository?.fullName == "org/backend") + #expect(issue.repository?.name == "backend") + #expect(issue.repository?.empty == nil) + #expect(issue.repository?.starsCount == nil) + #expect(issue.repository?.defaultBranch == nil) + } + + @Test func decodesIssueWithRepository() throws { + let json = """ + { + "id": 51, + "number": 8, + "title": "Add dark mode", + "state": "open", + "user": {"id": 1, "login": "reporter"}, + "labels": [], + "created_at": "2024-02-01T09:00:00Z", + "updated_at": "2024-02-02T10:00:00Z", + "comments": 0, + "repository": { + "id": 1, + "name": "my-repo", + "full_name": "user/my-repo", + "empty": false, + "private": false, + "fork": false, + "mirror": false, + "size": 1024, + "html_url": "https://forgejo.example.com/user/my-repo", + "ssh_url": "git@forgejo.example.com:user/my-repo.git", + "clone_url": "https://forgejo.example.com/user/my-repo.git", + "stars_count": 5, + "forks_count": 2, + "watchers_count": 3, + "open_issues_count": 1, + "open_pr_counter": 0, + "release_counter": 1, + "default_branch": "main", + "archived": false, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-06-01T00:00:00Z", + "has_issues": true, + "has_wiki": false, + "has_pull_requests": true, + "has_projects": false, + "has_releases": true, + "has_packages": false, + "has_actions": false, + "template": false + } + } + """ + let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) + #expect(issue.number == 8) + #expect(issue.title == "Add dark mode") + #expect(issue.repository?.fullName == "user/my-repo") + } + + @Test func decodesIssueWithMergedPullRequestField() throws { + let json = """ + { + "id": 53, + "number": 10, + "title": "Add dark mode support", + "state": "closed", + "user": {"id": 1, "login": "dev"}, + "labels": [], + "created_at": "2024-03-01T09:00:00Z", + "updated_at": "2024-03-05T10:00:00Z", + "comments": 2, + "pull_request": { + "merged": true, + "merged_at": "2024-03-05T10:00:00Z" + }, + "repository": { + "id": 10, + "name": "app", + "full_name": "org/app" + } + } + """ + let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) + #expect(issue.number == 10) + #expect(issue.state == "closed") + #expect(issue.pullRequest?.merged == true) + #expect(issue.pullRequest?.mergedAt != nil) + #expect(issue.repository?.fullName == "org/app") + } + + @Test func decodesIssueWithOpenPullRequestField() throws { + let json = """ + { + "id": 54, + "number": 11, + "title": "WIP: refactor auth", + "state": "open", + "user": {"id": 2, "login": "contributor"}, + "labels": [{"id": 1, "name": "WIP", "color": "fbca04"}], + "created_at": "2024-04-01T09:00:00Z", + "updated_at": "2024-04-02T10:00:00Z", + "comments": 0, + "pull_request": { + "merged": false + }, + "repository": { + "id": 10, + "name": "app", + "full_name": "org/app" + } + } + """ + let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) + #expect(issue.number == 11) + #expect(issue.state == "open") + #expect(issue.pullRequest?.merged == false) + #expect(issue.pullRequest?.mergedAt == nil) + #expect(issue.labels.count == 1) + #expect(issue.comments == 0) + } + + @Test func decodesIssueWithMinimalPullRequestField() throws { + let json = """ + { + "id": 55, + "number": 12, + "title": "Quick fix", + "state": "closed", + "user": {"id": 1, "login": "dev"}, + "labels": [], + "created_at": "2024-04-10T09:00:00Z", + "updated_at": "2024-04-10T10:00:00Z", + "comments": 1, + "pull_request": {} + } + """ + let issue = try decoder().decode(Issue.self, from: Data(json.utf8)) + #expect(issue.number == 12) + #expect(issue.pullRequest != nil) + #expect(issue.pullRequest?.merged == nil) + #expect(issue.pullRequest?.mergedAt == nil) + #expect(issue.repository == nil) + } + + // MARK: - PullRequest + + @Test func decodesPullRequest() throws { + let json = """ + { + "id": 200, + "number": 42, + "title": "Add feature X", + "body": "Implements feature X", + "state": "open", + "user": {"id": 1, "login": "dev"}, + "labels": [], + "head": { + "label": "dev:feature-x", + "ref": "feature-x", + "sha": "abc123" + }, + "base": { + "label": "dev:main", + "ref": "main", + "sha": "def456" + }, + "mergeable": true, + "merged": false, + "draft": false, + "comments": 1, + "created_at": "2024-03-01T08:00:00Z", + "updated_at": "2024-03-02T09:00:00Z" + } + """ + let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8)) + #expect(pr.number == 42) + #expect(pr.title == "Add feature X") + #expect(pr.head.ref == "feature-x") + #expect(pr.base.ref == "main") + #expect(pr.mergeable == true) + #expect(pr.merged == false) + #expect(pr.draft == false) + } + + @Test func decodesPullRequestWithFullMetadata() throws { + let json = """ + { + "id": 204, + "number": 46, + "title": "PR with full metadata", + "body": "Has labels, milestone, assignees, and reviewers", + "state": "open", + "user": {"id": 1, "login": "dev"}, + "labels": [ + {"id": 1, "name": "enhancement", "color": "0075ca"}, + {"id": 2, "name": "review-needed", "color": "e4e669"} + ], + "milestone": { + "id": 3, + "title": "v1.0", + "description": "First release", + "state": "open", + "due_on": "2026-03-01T00:00:00Z" + }, + "assignees": [ + {"id": 2, "login": "assignee1", "full_name": "Assignee One"}, + {"id": 3, "login": "assignee2"} + ], + "requested_reviewers": [ + {"id": 4, "login": "reviewer1"} + ], + "head": {"label": "dev:feature", "ref": "feature", "sha": "aaa"}, + "base": {"label": "dev:main", "ref": "main", "sha": "bbb"}, + "mergeable": true, + "merged": false, + "draft": false, + "comments": 0, + "created_at": "2024-03-01T08:00:00Z", + "updated_at": "2024-03-02T09:00:00Z" + } + """ + let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8)) + #expect(pr.number == 46) + #expect(pr.labels.count == 2) + #expect(pr.labels[0].name == "enhancement") + #expect(pr.milestone?.id == 3) + #expect(pr.milestone?.title == "v1.0") + #expect(pr.milestone?.dueOn != nil) + #expect(pr.assignees?.count == 2) + #expect(pr.assignees?[0].login == "assignee1") + #expect(pr.assignees?[0].fullName == "Assignee One") + #expect(pr.requestedReviewers?.count == 1) + #expect(pr.requestedReviewers?[0].login == "reviewer1") + } + + @Test func decodesPullRequestWithRequestedReviewers() throws { + let json = """ + { + "id": 202, + "number": 44, + "title": "PR with reviewers", + "state": "open", + "user": {"id": 1, "login": "dev"}, + "labels": [], + "head": {"label": "dev:feature", "ref": "feature", "sha": "aaa"}, + "base": {"label": "dev:main", "ref": "main", "sha": "bbb"}, + "mergeable": true, + "merged": false, + "draft": false, + "comments": 0, + "created_at": "2024-03-01T08:00:00Z", + "updated_at": "2024-03-02T09:00:00Z", + "requested_reviewers": [ + {"id": 10, "login": "reviewer1"}, + {"id": 11, "login": "reviewer2", "full_name": "Reviewer Two"} + ] + } + """ + let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8)) + #expect(pr.number == 44) + #expect(pr.requestedReviewers?.count == 2) + #expect(pr.requestedReviewers?[0].login == "reviewer1") + #expect(pr.requestedReviewers?[1].fullName == "Reviewer Two") + } + + @Test func decodesPullRequestWithoutRequestedReviewers() throws { + let json = """ + { + "id": 203, + "number": 45, + "title": "PR without reviewers", + "state": "open", + "user": {"id": 1, "login": "dev"}, + "labels": [], + "head": {"label": "dev:fix", "ref": "fix", "sha": "ccc"}, + "base": {"label": "dev:main", "ref": "main", "sha": "ddd"}, + "comments": 0, + "created_at": "2024-03-01T08:00:00Z", + "updated_at": "2024-03-02T09:00:00Z" + } + """ + let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8)) + #expect(pr.number == 45) + #expect(pr.requestedReviewers == nil) + } + + @Test func decodesMergedPullRequest() throws { + let json = """ + { + "id": 201, + "number": 43, + "title": "Merged PR", + "state": "closed", + "user": {"id": 1, "login": "dev"}, + "labels": [], + "head": {"label": "dev:feat", "ref": "feat", "sha": "aaa"}, + "base": {"label": "dev:main", "ref": "main", "sha": "bbb"}, + "merged": true, + "merged_by": {"id": 2, "login": "maintainer"}, + "merged_at": "2024-04-01T12:00:00Z", + "comments": 0, + "created_at": "2024-03-01T08:00:00Z", + "updated_at": "2024-04-01T12:00:00Z" + } + """ + let pr = try decoder().decode(PullRequest.self, from: Data(json.utf8)) + #expect(pr.merged == true) + #expect(pr.mergedBy?.login == "maintainer") + #expect(pr.mergedAt != nil) + } + + // MARK: - PullRequestReview + + @Test func decodesPullRequestReview() throws { + let json = """ + { + "id": 300, + "body": "Approved with minor comments", + "user": {"id": 5, "login": "reviewer"}, + "state": "APPROVED", + "comments_count": 2, + "submitted_at": "2024-04-10T15:00:00Z", + "updated_at": "2024-04-10T15:00:00Z", + "commit_id": "abc123", + "stale": false, + "official": true, + "dismissed": false + } + """ + let review = try decoder().decode(PullRequestReview.self, from: Data(json.utf8)) + #expect(review.id == 300) + #expect(review.state == "APPROVED") + #expect(review.user?.login == "reviewer") + #expect(review.commentsCount == 2) + #expect(review.official == true) + } + + @Test func decodesReviewWithNullableFields() throws { + let json = """ + { + "id": 301, + "state": "COMMENT" + } + """ + let review = try decoder().decode(PullRequestReview.self, from: Data(json.utf8)) + #expect(review.id == 301) + #expect(review.body == nil) + #expect(review.user == nil) + #expect(review.commentsCount == nil) + } + + // MARK: - ReviewComment + + @Test func decodesReviewComment() throws { + let json = """ + { + "id": 400, + "body": "This line needs a guard clause", + "user": {"id": 5, "login": "reviewer"}, + "path": "Sources/main.swift", + "diff_hunk": "@@ -10,5 +10,7 @@", + "position": 15, + "original_position": 10, + "created_at": "2024-04-10T15:30:00Z", + "updated_at": "2024-04-10T15:30:00Z" + } + """ + let comment = try decoder().decode(ReviewComment.self, from: Data(json.utf8)) + #expect(comment.id == 400) + #expect(comment.path == "Sources/main.swift") + #expect(comment.position == 15) + #expect(comment.originalPosition == 10) + } + + // MARK: - CreateReviewComment encoding + + @Test func encodesCreateReviewComment() throws { + let comment = CreateReviewComment( + body: "Fix this", + path: "file.swift", + oldPosition: 5, + newPosition: 7 + ) + let data = try JSONEncoder().encode(comment) + let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any] + #expect(dict["body"] as? String == "Fix this") + #expect(dict["path"] as? String == "file.swift") + #expect(dict["old_position"] as? Int == 5) + #expect(dict["new_position"] as? Int == 7) + } + + @Test func encodesCreateReviewCommentWithNilPositions() throws { + let comment = CreateReviewComment( + body: "Note", + path: "readme.md", + oldPosition: nil, + newPosition: nil + ) + let data = try JSONEncoder().encode(comment) + let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any] + #expect(dict["old_position"] == nil) + #expect(dict["new_position"] == nil) + } + + // MARK: - Repository + + @Test func decodesRepository() throws { + let json = """ + { + "id": 1, + "name": "my-repo", + "full_name": "user/my-repo", + "empty": false, + "private": false, + "fork": false, + "mirror": false, + "size": 1024, + "html_url": "https://forgejo.example.com/user/my-repo", + "ssh_url": "git@forgejo.example.com:user/my-repo.git", + "clone_url": "https://forgejo.example.com/user/my-repo.git", + "stars_count": 5, + "forks_count": 2, + "watchers_count": 3, + "open_issues_count": 1, + "open_pr_counter": 0, + "release_counter": 1, + "default_branch": "main", + "archived": false, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-06-01T00:00:00Z", + "has_issues": true, + "has_wiki": false, + "has_pull_requests": true, + "has_projects": false, + "has_releases": true, + "has_packages": false, + "has_actions": false, + "template": false + } + """ + let repo = try decoder().decode(Repository.self, from: Data(json.utf8)) + #expect(repo.name == "my-repo") + #expect(repo.fullName == "user/my-repo") + #expect(repo.empty != nil) + #expect(repo.starsCount != nil) + #expect(repo.starsCount == 5) + #expect(repo.defaultBranch == "main") + #expect(repo.hasPullRequests == true) + #expect(repo.createdAt != nil) + #expect(repo.updatedAt != nil) + } + + @Test func decodesRepositoryWithMinimalFields() throws { + let json = """ + { + "id": 10, + "name": "backend", + "full_name": "org/backend" + } + """ + let repo = try decoder().decode(Repository.self, from: Data(json.utf8)) + #expect(repo.id == 10) + #expect(repo.name == "backend") + #expect(repo.fullName == "org/backend") + #expect(repo.empty == nil) + #expect(repo.private == nil) + #expect(repo.fork == nil) + #expect(repo.mirror == nil) + #expect(repo.size == nil) + #expect(repo.htmlUrl == nil) + #expect(repo.sshUrl == nil) + #expect(repo.cloneUrl == nil) + #expect(repo.starsCount == nil) + #expect(repo.forksCount == nil) + #expect(repo.watchersCount == nil) + #expect(repo.openIssuesCount == nil) + #expect(repo.defaultBranch == nil) + #expect(repo.archived == nil) + #expect(repo.createdAt == nil) + #expect(repo.updatedAt == nil) + #expect(repo.hasIssues == nil) + #expect(repo.hasPullRequests == nil) + #expect(repo.hasReleases == nil) + #expect(repo.hasPackages == nil) + #expect(repo.hasActions == nil) + #expect(repo.template == nil) + } + + @Test func decodesRepositoryWithPartialFields() throws { + let json = """ + { + "id": 11, + "name": "frontend", + "full_name": "org/frontend", + "private": true, + "html_url": "https://forgejo.example.com/org/frontend", + "stars_count": 12, + "default_branch": "develop" + } + """ + let repo = try decoder().decode(Repository.self, from: Data(json.utf8)) + #expect(repo.id == 11) + #expect(repo.fullName == "org/frontend") + #expect(repo.private == true) + #expect(repo.htmlUrl == "https://forgejo.example.com/org/frontend") + #expect(repo.starsCount == 12) + #expect(repo.defaultBranch == "develop") + #expect(repo.empty == nil) + #expect(repo.forksCount == nil) + } + + // MARK: - NotificationThread + + @Test func decodesNotificationThread() throws { + let json = """ + { + "id": 1, + "unread": true, + "pinned": false, + "updated_at": "2024-06-15T14:30:00Z", + "url": "https://forgejo.example.com/api/v1/notifications/threads/1", + "subject": { + "type": "Issue", + "title": "Fix login bug", + "url": "https://forgejo.example.com/api/v1/repos/org/app/issues/42", + "html_url": "https://forgejo.example.com/org/app/issues/42", + "state": "open", + "latest_comment_url": "https://forgejo.example.com/api/v1/repos/org/app/issues/comments/100", + "latest_comment_html_url": "https://forgejo.example.com/org/app/issues/42#issuecomment-100" + }, + "repository": { + "id": 10, + "name": "app", + "full_name": "org/app" + } + } + """ + let notification = try decoder().decode(NotificationThread.self, from: Data(json.utf8)) + #expect(notification.id == 1) + #expect(notification.unread == true) + #expect(notification.pinned == false) + #expect(notification.subject.type == "Issue") + #expect(notification.subject.title == "Fix login bug") + #expect(notification.subject.state == "open") + #expect(notification.subject.htmlUrl == "https://forgejo.example.com/org/app/issues/42") + #expect(notification.subject.latestCommentUrl != nil) + #expect(notification.subject.latestCommentHtmlUrl != nil) + #expect(notification.repository.fullName == "org/app") + } + + @Test func decodesNotificationSubjectTypes() throws { + let issueJson = """ + { + "id": 2, + "unread": true, + "pinned": false, + "updated_at": "2024-06-15T14:30:00Z", + "subject": { + "type": "Issue", + "title": "Bug report", + "state": "open" + }, + "repository": {"id": 1, "name": "repo", "full_name": "user/repo"} + } + """ + let pullJson = """ + { + "id": 3, + "unread": false, + "pinned": false, + "updated_at": "2024-06-15T15:00:00Z", + "subject": { + "type": "Pull", + "title": "Add feature", + "state": "closed" + }, + "repository": {"id": 1, "name": "repo", "full_name": "user/repo"} + } + """ + let commitJson = """ + { + "id": 4, + "unread": true, + "pinned": false, + "updated_at": "2024-06-15T16:00:00Z", + "subject": { + "type": "Commit", + "title": "abc123" + }, + "repository": {"id": 1, "name": "repo", "full_name": "user/repo"} + } + """ + + let issue = try decoder().decode(NotificationThread.self, from: Data(issueJson.utf8)) + #expect(issue.subject.type == "Issue") + #expect(issue.subject.state == "open") + + let pull = try decoder().decode(NotificationThread.self, from: Data(pullJson.utf8)) + #expect(pull.subject.type == "Pull") + #expect(pull.subject.state == "closed") + #expect(pull.unread == false) + + let commit = try decoder().decode(NotificationThread.self, from: Data(commitJson.utf8)) + #expect(commit.subject.type == "Commit") + #expect(commit.subject.state == nil) + } + + @Test func decodesNotificationCount() throws { + let json = """ + {"new": 5} + """ + let count = try decoder().decode(NotificationCount.self, from: Data(json.utf8)) + #expect(count.new == 5) + } + + @Test func decodesNotificationWithMinimalFields() throws { + let json = """ + { + "id": 10, + "unread": false, + "pinned": false, + "updated_at": "2024-06-15T14:30:00Z", + "subject": { + "type": "Repository", + "title": "New release" + }, + "repository": {"id": 5, "name": "lib", "full_name": "org/lib"} + } + """ + let notification = try decoder().decode(NotificationThread.self, from: Data(json.utf8)) + #expect(notification.id == 10) + #expect(notification.unread == false) + #expect(notification.url == nil) + #expect(notification.subject.url == nil) + #expect(notification.subject.htmlUrl == nil) + #expect(notification.subject.state == nil) + #expect(notification.subject.latestCommentUrl == nil) + #expect(notification.subject.latestCommentHtmlUrl == nil) + } + + // MARK: - Commit + + @Test func decodesCommit() throws { + let json = """ + { + "sha": "abc123def456", + "url": "https://forgejo.example.com/api/v1/repos/user/repo/git/commits/abc123def456", + "html_url": "https://forgejo.example.com/user/repo/commit/abc123def456", + "commit": { + "url": "https://forgejo.example.com/api/v1/repos/user/repo/git/commits/abc123def456", + "message": "Add new feature\\n\\nDetailed description of the change.", + "author": { + "name": "Test User", + "email": "test@example.com", + "date": "2024-06-01T10:30:00Z" + }, + "committer": { + "name": "Test User", + "email": "test@example.com", + "date": "2024-06-01T10:30:00Z" + } + }, + "author": {"id": 1, "login": "testuser"}, + "committer": {"id": 1, "login": "testuser"}, + "parents": [ + { + "sha": "parent123", + "url": "https://forgejo.example.com/api/v1/repos/user/repo/git/commits/parent123", + "html_url": "https://forgejo.example.com/user/repo/commit/parent123" + } + ] + } + """ + let commit = try decoder().decode(Commit.self, from: Data(json.utf8)) + #expect(commit.sha == "abc123def456") + #expect(commit.htmlUrl == "https://forgejo.example.com/user/repo/commit/abc123def456") + #expect(commit.commit.message == "Add new feature\n\nDetailed description of the change.") + #expect(commit.commit.author?.name == "Test User") + #expect(commit.commit.author?.email == "test@example.com") + #expect(commit.commit.author?.date != nil) + #expect(commit.commit.committer?.name == "Test User") + #expect(commit.author?.login == "testuser") + #expect(commit.committer?.login == "testuser") + #expect(commit.parents?.count == 1) + #expect(commit.parents?[0].sha == "parent123") + #expect(commit.id == "abc123def456") + } + + @Test func decodesCommitWithMinimalFields() throws { + let json = """ + { + "sha": "minimal123", + "commit": { + "message": "Quick fix" + } + } + """ + let commit = try decoder().decode(Commit.self, from: Data(json.utf8)) + #expect(commit.sha == "minimal123") + #expect(commit.commit.message == "Quick fix") + #expect(commit.commit.author == nil) + #expect(commit.commit.committer == nil) + #expect(commit.author == nil) + #expect(commit.committer == nil) + #expect(commit.parents == nil) + #expect(commit.htmlUrl == nil) + #expect(commit.url == nil) + } + + @Test func decodesCommitWithNullAuthor() throws { + let json = """ + { + "sha": "noauthor456", + "commit": { + "message": "Automated commit", + "author": { + "name": "bot", + "email": "bot@noreply.example.com", + "date": "2024-07-15T08:00:00Z" + } + }, + "author": null, + "committer": null, + "parents": [] + } + """ + let commit = try decoder().decode(Commit.self, from: Data(json.utf8)) + #expect(commit.sha == "noauthor456") + #expect(commit.commit.author?.name == "bot") + #expect(commit.author == nil) + #expect(commit.committer == nil) + #expect(commit.parents?.isEmpty == true) + } + + @Test func decodesCommitList() throws { + let json = """ + [ + { + "sha": "first111", + "commit": {"message": "First commit"}, + "parents": [] + }, + { + "sha": "second222", + "commit": {"message": "Second commit"}, + "parents": [{"sha": "first111"}] + } + ] + """ + let commits = try decoder().decode([Commit].self, from: Data(json.utf8)) + #expect(commits.count == 2) + #expect(commits[0].sha == "first111") + #expect(commits[1].sha == "second222") + #expect(commits[1].parents?[0].sha == "first111") + } + + // MARK: - RepositoryContent + + @Test func decodesRepositoryContent() throws { + let json = """ + { + "name": "README.md", + "path": "README.md", + "sha": "abc123", + "size": 256, + "url": "https://example.com/api/v1/repos/u/r/contents/README.md", + "html_url": "https://example.com/u/r/src/branch/main/README.md", + "git_url": "https://example.com/api/v1/repos/u/r/git/blobs/abc123", + "type": "file" + } + """ + let content = try decoder().decode(RepositoryContent.self, from: Data(json.utf8)) + #expect(content.name == "README.md") + #expect(content.type == .file) + #expect(content.id == "README.md") + } + + @Test func decodesDirectoryContent() throws { + let json = """ + { + "name": "src", + "path": "src", + "sha": "def456", + "size": 0, + "url": "https://example.com/api", + "html_url": "https://example.com/html", + "git_url": "https://example.com/git", + "type": "dir" + } + """ + let content = try decoder().decode(RepositoryContent.self, from: Data(json.utf8)) + #expect(content.type == .dir) + } + + // MARK: - Branch + + @Test func decodesBranch() throws { + let json = """ + { + "name": "main", + "commit": { + "id": "abc123", + "message": "Initial commit", + "url": "https://example.com/commit/abc123" + }, + "protected": true + } + """ + let branch = try decoder().decode(Branch.self, from: Data(json.utf8)) + #expect(branch.name == "main") + #expect(branch.commit.message == "Initial commit") + #expect(branch.protected == true) + #expect(branch.id == "main") + } + + // MARK: - Repository Equatable / Hashable + + @Test func repositoriesWithSameIdAreEqual() throws { + let json1 = """ + {"id": 42, "name": "repo-a", "full_name": "owner/repo-a", "owner": {"id": 1, "login": "owner"}, "html_url": "https://example.com/owner/repo-a", "description": "", "empty": false, "private": false, "fork": false, "mirror": false, "archived": false, "stars_count": 0, "forks_count": 0, "open_issues_count": 0, "open_pr_counter": 0, "has_issues": true, "has_pull_requests": true, "default_branch": "main", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"} + """ + let json2 = """ + {"id": 42, "name": "repo-b", "full_name": "owner/repo-b", "owner": {"id": 1, "login": "owner"}, "html_url": "https://example.com/owner/repo-b", "description": "different", "empty": false, "private": false, "fork": false, "mirror": false, "archived": false, "stars_count": 5, "forks_count": 1, "open_issues_count": 3, "open_pr_counter": 0, "has_issues": true, "has_pull_requests": true, "default_branch": "main", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"} + """ + let repo1 = try decoder().decode(Repository.self, from: Data(json1.utf8)) + let repo2 = try decoder().decode(Repository.self, from: Data(json2.utf8)) + #expect(repo1 == repo2) + #expect(repo1.hashValue == repo2.hashValue) + } + + @Test func repositoriesWithDifferentIdsAreNotEqual() throws { + let json1 = """ + {"id": 1, "name": "repo", "full_name": "owner/repo", "owner": {"id": 1, "login": "owner"}, "html_url": "https://example.com/owner/repo", "description": "", "empty": false, "private": false, "fork": false, "mirror": false, "archived": false, "stars_count": 0, "forks_count": 0, "open_issues_count": 0, "open_pr_counter": 0, "has_issues": true, "has_pull_requests": true, "default_branch": "main", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"} + """ + let json2 = """ + {"id": 2, "name": "repo", "full_name": "owner/repo", "owner": {"id": 1, "login": "owner"}, "html_url": "https://example.com/owner/repo", "description": "", "empty": false, "private": false, "fork": false, "mirror": false, "archived": false, "stars_count": 0, "forks_count": 0, "open_issues_count": 0, "open_pr_counter": 0, "has_issues": true, "has_pull_requests": true, "default_branch": "main", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z"} + """ + let repo1 = try decoder().decode(Repository.self, from: Data(json1.utf8)) + let repo2 = try decoder().decode(Repository.self, from: Data(json2.utf8)) + #expect(repo1 != repo2) + } + + // MARK: - RepositoryPermissions + + @Test func decodesRepositoryWithFullPermissions() throws { + let json = """ + { + "id": 20, + "name": "collab-repo", + "full_name": "org/collab-repo", + "permissions": { + "admin": true, + "push": true, + "pull": true + } + } + """ + let repo = try decoder().decode(Repository.self, from: Data(json.utf8)) + #expect(repo.permissions?.admin == true) + #expect(repo.permissions?.push == true) + #expect(repo.permissions?.pull == true) + } + + @Test func decodesRepositoryWithReadOnlyPermissions() throws { + let json = """ + { + "id": 21, + "name": "public-repo", + "full_name": "org/public-repo", + "permissions": { + "admin": false, + "push": false, + "pull": true + } + } + """ + let repo = try decoder().decode(Repository.self, from: Data(json.utf8)) + #expect(repo.permissions?.admin == false) + #expect(repo.permissions?.push == false) + #expect(repo.permissions?.pull == true) + } + + @Test func decodesRepositoryWithMissingPermissions() throws { + let json = """ + { + "id": 22, + "name": "no-perms", + "full_name": "org/no-perms" + } + """ + let repo = try decoder().decode(Repository.self, from: Data(json.utf8)) + #expect(repo.permissions == nil) + } + + // MARK: - DecodingError description + + @Test func decodingErrorAtRootShowsRoot() { + // Decoding a String where an object is expected produces a root-level error + let json = #""not-an-object""# + do { + _ = try decoder().decode(User.self, from: Data(json.utf8)) + Issue.record("Expected decoding to fail") + } catch let error as DecodingError { + let description = describeDecodingError(error) + #expect(description.contains("root")) + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/Tests/ForgejoKitTests/NormalizeServerURLTests.swift b/Tests/ForgejoKitTests/NormalizeServerURLTests.swift new file mode 100644 index 0000000..f201229 --- /dev/null +++ b/Tests/ForgejoKitTests/NormalizeServerURLTests.swift @@ -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") + } +} diff --git a/Tests/ForgejoKitTests/NotificationTests.swift b/Tests/ForgejoKitTests/NotificationTests.swift new file mode 100644 index 0000000..95238bf --- /dev/null +++ b/Tests/ForgejoKitTests/NotificationTests.swift @@ -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)) + } + } +} diff --git a/Tests/ForgejoKitTests/RepositoryServiceURLTests.swift b/Tests/ForgejoKitTests/RepositoryServiceURLTests.swift new file mode 100644 index 0000000..d2d4f87 --- /dev/null +++ b/Tests/ForgejoKitTests/RepositoryServiceURLTests.swift @@ -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") + } +} diff --git a/Tests/ForgejoKitTests/URLSessionManagerTests.swift b/Tests/ForgejoKitTests/URLSessionManagerTests.swift new file mode 100644 index 0000000..8e6bc82 --- /dev/null +++ b/Tests/ForgejoKitTests/URLSessionManagerTests.swift @@ -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") + } +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..26355ca --- /dev/null +++ b/flake.lock @@ -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 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..97fdc5b --- /dev/null +++ b/flake.nix @@ -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 + ]; + }; + } + ); +} diff --git a/justfile b/justfile new file mode 100644 index 0000000..6cecbcf --- /dev/null +++ b/justfile @@ -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}}"