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:
Stefan Hausotte 2026-03-11 21:53:55 +01:00
parent 42c5ec0ea6
commit 0c2fc12ded
10 changed files with 453 additions and 6 deletions

View 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"
}
}

View 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,
)
}
}

View file

@ -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 `/`).

View file

@ -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,

View file

@ -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
}
}

View 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,
)
}
}

View 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")
}
}

View file

@ -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() {

View file

@ -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")
}
}

View 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"))
}
}