mirror of
https://codeberg.org/secana/ForgejoKit.git
synced 2026-06-16 05:13:53 -07:00
feat: add new models and services
New model: - Token — id, name, sha1, tokenLastEight New services: - AdminService — createUser() - UserService — createToken() RepositoryService additions: - createRepository() - editRepository() - createLabel() - createMilestone() - addCollaborator() - createFile() remove MARKS
This commit is contained in:
parent
42c5ec0ea6
commit
0c2fc12ded
10 changed files with 453 additions and 6 deletions
25
Sources/ForgejoKit/Models/Token.swift
Normal file
25
Sources/ForgejoKit/Models/Token.swift
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
57
Sources/ForgejoKit/Services/AdminService.swift
Normal file
57
Sources/ForgejoKit/Services/AdminService.swift
Normal file
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 `/`).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
Sources/ForgejoKit/Services/UserService.swift
Normal file
27
Sources/ForgejoKit/Services/UserService.swift
Normal file
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
36
Tests/ForgejoKitTests/AdminServiceTests.swift
Normal file
36
Tests/ForgejoKitTests/AdminServiceTests.swift
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
30
Tests/ForgejoKitTests/UserServiceTests.swift
Normal file
30
Tests/ForgejoKitTests/UserServiceTests.swift
Normal file
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue