commit 897a8ebeddfc97444c27752df8e99eda589eeaba Author: Stefan Hausotte Date: Sat Feb 28 20:10:51 2026 +0100 feat: initial commit Intitial commit for ForgejoKit, a native Swift library to interact with the Frogejo API 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}}"