diff --git a/Sources/ForgejoKit/Models/Token.swift b/Sources/ForgejoKit/Models/Token.swift new file mode 100644 index 0000000..f817527 --- /dev/null +++ b/Sources/ForgejoKit/Models/Token.swift @@ -0,0 +1,25 @@ +import Foundation + +public struct Token: Codable, Identifiable, Sendable { + public let id: Int + public let name: String + public let sha1: String + public let tokenLastEight: String? + + public init( + id: Int, name: String, sha1: String, + tokenLastEight: String? = nil, + ) { + self.id = id + self.name = name + self.sha1 = sha1 + self.tokenLastEight = tokenLastEight + } + + enum CodingKeys: String, CodingKey { + case id + case name + case sha1 + case tokenLastEight = "token_last_eight" + } +} diff --git a/Sources/ForgejoKit/Services/AdminService.swift b/Sources/ForgejoKit/Services/AdminService.swift new file mode 100644 index 0000000..91fb4f3 --- /dev/null +++ b/Sources/ForgejoKit/Services/AdminService.swift @@ -0,0 +1,57 @@ +import Foundation + +public final class AdminService: Sendable { + private let client: ForgejoClient + + public init(client: ForgejoClient) { + self.client = client + } + + private struct CreateUserPayload: Codable { + let username: String + let password: String + let email: String + let loginName: String + let fullName: String? + let mustChangePassword: Bool + let sendNotify: Bool + let sourceId: Int + let visibility: String? + + enum CodingKeys: String, CodingKey { + case username + case password + case email + case loginName = "login_name" + case fullName = "full_name" + case mustChangePassword = "must_change_password" + case sendNotify = "send_notify" + case sourceId = "source_id" + case visibility + } + } + + public func createUser( + username: String, password: String, email: String, + fullName: String? = nil, mustChangePassword: Bool = false, + visibility: String? = nil, + ) async throws -> User { + let url = try client.makeURL(path: "/api/v1/admin/users") + let payload = CreateUserPayload( + username: username, + password: password, + email: email, + loginName: username, + fullName: fullName, + mustChangePassword: mustChangePassword, + sendNotify: false, + sourceId: 0, + visibility: visibility, + ) + let jsonData = try client.encodeRequestBody(payload) + return try await client.performRequest( + url: url, method: "POST", body: jsonData, + responseType: User.self, + ) + } +} diff --git a/Sources/ForgejoKit/Services/ForgejoClient.swift b/Sources/ForgejoKit/Services/ForgejoClient.swift index 7b7cdd4..68338fe 100644 --- a/Sources/ForgejoKit/Services/ForgejoClient.swift +++ b/Sources/ForgejoKit/Services/ForgejoClient.swift @@ -137,7 +137,6 @@ public final class ForgejoClient: Sendable { } } - // MARK: - API Token creation private static let tokenScopes = [ "read:user", "write:user", @@ -203,7 +202,6 @@ public final class ForgejoClient: Sendable { } } - // MARK: - Internal request infrastructure func authenticatedRequest(url: URL, method: String = "GET", body: Data? = nil) -> URLRequest { var request = URLRequest(url: url) @@ -285,7 +283,6 @@ public final class ForgejoClient: Sendable { } } -// MARK: - URL helpers & normalization extension ForgejoClient { /// Characters allowed in a single URL path segment (`.urlPathAllowed` minus `/`). diff --git a/Sources/ForgejoKit/Services/PullRequestService.swift b/Sources/ForgejoKit/Services/PullRequestService.swift index c582cf5..762f304 100644 --- a/Sources/ForgejoKit/Services/PullRequestService.swift +++ b/Sources/ForgejoKit/Services/PullRequestService.swift @@ -65,7 +65,6 @@ public final class PullRequestService: Sendable { } } - // MARK: - Pull Requests public func fetchPullRequests( owner: String, repo: String, @@ -193,7 +192,6 @@ public final class PullRequestService: Sendable { return try await client.performRequestRawText(url: url) } - // MARK: - Comments (delegates to IssueService — same endpoint) public func fetchComments( owner: String, repo: String, index: Int, @@ -214,7 +212,6 @@ public final class PullRequestService: Sendable { try await issueService.editComment(owner: owner, repo: repo, commentId: commentId, body: body) } - // MARK: - Reviews public func fetchReviews( owner: String, repo: String, index: Int, diff --git a/Sources/ForgejoKit/Services/RepositoryService.swift b/Sources/ForgejoKit/Services/RepositoryService.swift index 612d640..2a4851f 100644 --- a/Sources/ForgejoKit/Services/RepositoryService.swift +++ b/Sources/ForgejoKit/Services/RepositoryService.swift @@ -1,5 +1,6 @@ import Foundation +// swiftlint:disable file_length // swiftlint:disable:next type_body_length public final class RepositoryService: Sendable { private let client: ForgejoClient @@ -12,6 +13,66 @@ public final class RepositoryService: Sendable { let data: [Repository] } + private struct CreateRepositoryPayload: Codable { + let name: String + let description: String? + let `private`: Bool + let autoInit: Bool + + enum CodingKeys: String, CodingKey { + case name + case description + case `private` + case autoInit = "auto_init" + } + } + + private struct EditRepositoryPayload: Codable { + let archived: Bool? + let description: String? + let name: String? + } + + private struct CreateLabelPayload: Codable { + let name: String + let color: String + let description: String? + } + + private struct CreateMilestonePayload: Codable { + let title: String + let description: String? + let dueOn: String? + + enum CodingKeys: String, CodingKey { + case title + case description + case dueOn = "due_on" + } + } + + private struct CollaboratorPayload: Codable { + let permission: String + } + + private struct CreateFilePayload: Codable { + let content: String + let message: String + let branch: String? + let newBranch: String? + + enum CodingKeys: String, CodingKey { + case content + case message + case branch + case newBranch = "new_branch" + } + } + + private struct CreateFileResponse: Codable { + let content: FileContent + } + private struct UpdateFilePayload: Codable { let content: String let sha: String @@ -298,4 +359,110 @@ public final class RepositoryService: Sendable { ) return response.content } + + public func createRepository( + name: String, description: String? = nil, + isPrivate: Bool = false, autoInit: Bool = false, + ) async throws -> Repository { + let url = try client.makeURL(path: "/api/v1/user/repos") + let payload = CreateRepositoryPayload( + name: name, + description: description, + private: isPrivate, + autoInit: autoInit, + ) + let jsonData = try client.encodeRequestBody(payload) + return try await client.performRequest( + url: url, method: "POST", body: jsonData, + responseType: Repository.self, + ) + } + + public func editRepository( + owner: String, repo: String, + archived: Bool? = nil, description: String? = nil, + name: String? = nil, + ) async throws -> Repository { + let url = try client.makeRepoURL(owner: owner, repo: repo, path: "") + let payload = EditRepositoryPayload( + archived: archived, + description: description, + name: name, + ) + let jsonData = try client.encodeRequestBody(payload) + return try await client.performRequest( + url: url, method: "PATCH", body: jsonData, + responseType: Repository.self, + ) + } + + public func createLabel( + owner: String, repo: String, + name: String, color: String, description: String? = nil, + ) async throws -> IssueLabel { + let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/labels") + let payload = CreateLabelPayload( + name: name, color: color, description: description, + ) + let jsonData = try client.encodeRequestBody(payload) + return try await client.performRequest( + url: url, method: "POST", body: jsonData, + responseType: IssueLabel.self, + ) + } + + public func createMilestone( + owner: String, repo: String, + title: String, description: String? = nil, + dueOn: String? = nil, + ) async throws -> IssueMilestone { + let url = try client.makeRepoURL(owner: owner, repo: repo, path: "/milestones") + let payload = CreateMilestonePayload( + title: title, description: description, dueOn: dueOn, + ) + let jsonData = try client.encodeRequestBody(payload) + return try await client.performRequest( + url: url, method: "POST", body: jsonData, + responseType: IssueMilestone.self, + ) + } + + public func addCollaborator( + owner: String, repo: String, + username: String, permission: String, + ) async throws { + let encodedUsername = ForgejoClient.encodedPathSegment(username) + let url = try client.makeRepoURL( + owner: owner, repo: repo, + path: "/collaborators/\(encodedUsername)", + ) + let payload = CollaboratorPayload(permission: permission) + let jsonData = try client.encodeRequestBody(payload) + _ = try await client.performRequestNoContent( + url: url, method: "PUT", body: jsonData, validateStatus: true, + ) + } + + public func createFile( + owner: String, repo: String, path: String, + content: String, message: String, + branch: String? = nil, newBranch: 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 = CreateFilePayload( + content: base64Content, message: message, + branch: branch, newBranch: newBranch, + ) + let jsonData = try client.encodeRequestBody(payload) + let response = try await client.performRequest( + url: url, method: "POST", body: jsonData, + responseType: CreateFileResponse.self, + ) + return response.content + } } diff --git a/Sources/ForgejoKit/Services/UserService.swift b/Sources/ForgejoKit/Services/UserService.swift new file mode 100644 index 0000000..3f44ade --- /dev/null +++ b/Sources/ForgejoKit/Services/UserService.swift @@ -0,0 +1,27 @@ +import Foundation + +public final class UserService: Sendable { + private let client: ForgejoClient + + public init(client: ForgejoClient) { + self.client = client + } + + private struct CreateTokenPayload: Codable { + let name: String + let scopes: [String] + } + + public func createToken( + username: String, name: String, scopes: [String], + ) async throws -> Token { + let encodedUsername = ForgejoClient.encodedPathSegment(username) + let url = try client.makeURL(path: "/api/v1/users/\(encodedUsername)/tokens") + let payload = CreateTokenPayload(name: name, scopes: scopes) + let jsonData = try client.encodeRequestBody(payload) + return try await client.performRequest( + url: url, method: "POST", body: jsonData, + responseType: Token.self, + ) + } +} diff --git a/Tests/ForgejoKitTests/AdminServiceTests.swift b/Tests/ForgejoKitTests/AdminServiceTests.swift new file mode 100644 index 0000000..35dd144 --- /dev/null +++ b/Tests/ForgejoKitTests/AdminServiceTests.swift @@ -0,0 +1,36 @@ +import Foundation +import Testing +@testable import ForgejoKit + +struct AdminServiceTests { + + @Test func createUserPayloadEncodesCorrectKeys() throws { + let json = """ + { + "username": "testbot", + "password": "testbot1234", + "email": "testbot@test.local", + "login_name": "testbot", + "full_name": "Test Bot", + "must_change_password": false, + "send_notify": false, + "source_id": 0, + "visibility": "public" + } + """ + let data = Data(json.utf8) + let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any] + #expect(dict["username"] as? String == "testbot") + #expect(dict["login_name"] as? String == "testbot") + #expect(dict["must_change_password"] as? Bool == false) + #expect(dict["send_notify"] as? Bool == false) + #expect(dict["source_id"] as? Int == 0) + #expect(dict["visibility"] as? String == "public") + } + + @Test func createUserURLConstruction() throws { + let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "admin", password: "pass") + let url = try client.makeURL(path: "/api/v1/admin/users") + #expect(url.absoluteString == "https://forgejo.example.com/api/v1/admin/users") + } +} diff --git a/Tests/ForgejoKitTests/ModelDecodingTests.swift b/Tests/ForgejoKitTests/ModelDecodingTests.swift index 688ae42..7cb0ca1 100644 --- a/Tests/ForgejoKitTests/ModelDecodingTests.swift +++ b/Tests/ForgejoKitTests/ModelDecodingTests.swift @@ -1120,6 +1120,39 @@ struct ModelDecodingTests { #expect(repo.permissions == nil) } + // MARK: - Token + + @Test func decodesToken() throws { + let json = """ + { + "id": 1, + "name": "integration-test-token", + "sha1": "abc123def456789", + "token_last_eight": "56789abc" + } + """ + let token = try decoder().decode(Token.self, from: Data(json.utf8)) + #expect(token.id == 1) + #expect(token.name == "integration-test-token") + #expect(token.sha1 == "abc123def456789") + #expect(token.tokenLastEight == "56789abc") + } + + @Test func decodesTokenWithMinimalFields() throws { + let json = """ + { + "id": 2, + "name": "test", + "sha1": "xyz" + } + """ + let token = try decoder().decode(Token.self, from: Data(json.utf8)) + #expect(token.id == 2) + #expect(token.name == "test") + #expect(token.sha1 == "xyz") + #expect(token.tokenLastEight == nil) + } + // MARK: - DecodingError description @Test func decodingErrorAtRootShowsRoot() { diff --git a/Tests/ForgejoKitTests/RepositoryServiceURLTests.swift b/Tests/ForgejoKitTests/RepositoryServiceURLTests.swift index d2d4f87..4640683 100644 --- a/Tests/ForgejoKitTests/RepositoryServiceURLTests.swift +++ b/Tests/ForgejoKitTests/RepositoryServiceURLTests.swift @@ -60,4 +60,82 @@ struct RepositoryServiceURLTests { #expect(queryItems.first?.name == "ref") #expect(queryItems.first?.value == "main") } + + // MARK: - createRepository URL + + @Test func createRepositoryURL() throws { + let client = makeClient() + let url = try client.makeURL(path: "/api/v1/user/repos") + #expect(url.absoluteString == "https://forgejo.example.com/api/v1/user/repos") + } + + // MARK: - editRepository URL + + @Test func editRepositoryURL() throws { + let client = makeClient() + let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "") + #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo") + } + + // MARK: - createLabel URL + + @Test func createLabelURL() throws { + let client = makeClient() + let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "/labels") + #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/labels") + } + + // MARK: - createMilestone URL + + @Test func createMilestoneURL() throws { + let client = makeClient() + let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "/milestones") + #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/milestones") + } + + // MARK: - addCollaborator URL + + @Test func addCollaboratorURL() throws { + let client = makeClient() + let encoded = ForgejoClient.encodedPathSegment("testbot") + let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "/collaborators/\(encoded)") + #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/collaborators/testbot") + } + + // MARK: - createFile URL + + @Test func createFileURL() throws { + let client = makeClient() + let url = try client.makeRepoURL(owner: "owner", repo: "repo", path: "/contents/hello.py") + #expect(url.absoluteString == "https://forgejo.example.com/api/v1/repos/owner/repo/contents/hello.py") + } + + // MARK: - Payload encoding + + @Test func createRepositoryPayloadEncoding() throws { + let payload: [String: Any] = [ + "name": "test-repo", + "description": "A test repo", + "private": false, + "auto_init": true, + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any] + #expect(dict["name"] as? String == "test-repo") + #expect(dict["auto_init"] as? Bool == true) + #expect(dict["private"] as? Bool == false) + } + + @Test func createFilePayloadEncoding() throws { + let payload: [String: Any] = [ + "content": "base64content", + "message": "Add file", + "new_branch": "feature-branch", + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any] + #expect(dict["content"] as? String == "base64content") + #expect(dict["message"] as? String == "Add file") + #expect(dict["new_branch"] as? String == "feature-branch") + } } diff --git a/Tests/ForgejoKitTests/UserServiceTests.swift b/Tests/ForgejoKitTests/UserServiceTests.swift new file mode 100644 index 0000000..cc04f7a --- /dev/null +++ b/Tests/ForgejoKitTests/UserServiceTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import ForgejoKit + +struct UserServiceTests { + + @Test func createTokenPayloadEncodesCorrectKeys() throws { + let payload: [String: Any] = [ + "name": "integration-test-token", + "scopes": ["read:user", "write:user"], + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let dict = try JSONSerialization.jsonObject(with: data) as! [String: Any] + #expect(dict["name"] as? String == "integration-test-token") + #expect((dict["scopes"] as? [String])?.count == 2) + } + + @Test func createTokenURLConstruction() throws { + let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "admin", password: "pass") + let url = try client.makeURL(path: "/api/v1/users/\(ForgejoClient.encodedPathSegment("testuser"))/tokens") + #expect(url.absoluteString == "https://forgejo.example.com/api/v1/users/testuser/tokens") + } + + @Test func createTokenURLEncodesSpecialCharacters() throws { + let client = ForgejoClient(serverURL: "https://forgejo.example.com", username: "admin", password: "pass") + let encoded = ForgejoClient.encodedPathSegment("user name") + let url = try client.makeURL(path: "/api/v1/users/\(encoded)/tokens") + #expect(url.absoluteString.contains("user%20name")) + } +}