diff --git a/Forji/Forji.xcodeproj/project.pbxproj b/Forji/Forji.xcodeproj/project.pbxproj index ba27142..ae3b4d2 100644 --- a/Forji/Forji.xcodeproj/project.pbxproj +++ b/Forji/Forji.xcodeproj/project.pbxproj @@ -639,7 +639,7 @@ repositoryURL = "https://codeberg.org/secana/ForgejoKit.git"; requirement = { kind = exactVersion; - version = 0.1.0; + version = 0.2.0; }; }; DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */ = { diff --git a/Forji/Forji.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Forji/Forji.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 297298f..bd59681 100644 --- a/Forji/Forji.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Forji/Forji.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://codeberg.org/secana/ForgejoKit.git", "state" : { - "revision" : "897a8ebeddfc97444c27752df8e99eda589eeaba", - "version" : "0.1.0" + "revision" : "a73f04462372ec2976e0ef673dd1ee17c3a7239d", + "version" : "0.2.0" } }, { diff --git a/integration/forgejo-seed/Package.resolved b/integration/forgejo-seed/Package.resolved new file mode 100644 index 0000000..a45e0fc --- /dev/null +++ b/integration/forgejo-seed/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "6925739b095ee90912b50df5d7f72a20c934e3a08983bcca294a8831f90689cd", + "pins" : [ + { + "identity" : "forgejokit", + "kind" : "remoteSourceControl", + "location" : "https://codeberg.org/secana/ForgejoKit.git", + "state" : { + "revision" : "a73f04462372ec2976e0ef673dd1ee17c3a7239d", + "version" : "0.2.0" + } + } + ], + "version" : 3 +} diff --git a/integration/forgejo-seed/Package.swift b/integration/forgejo-seed/Package.swift new file mode 100644 index 0000000..ad72ae0 --- /dev/null +++ b/integration/forgejo-seed/Package.swift @@ -0,0 +1,20 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "forgejo-seed", + platforms: [ + .macOS(.v15), + ], + dependencies: [ + .package(url: "https://codeberg.org/secana/ForgejoKit.git", from: "0.2.0"), + ], + targets: [ + .executableTarget( + name: "forgejo-seed", + dependencies: ["ForgejoKit"], + path: "Sources" + ), + ] +) diff --git a/integration/forgejo-seed/Sources/DockerExec.swift b/integration/forgejo-seed/Sources/DockerExec.swift new file mode 100644 index 0000000..164760e --- /dev/null +++ b/integration/forgejo-seed/Sources/DockerExec.swift @@ -0,0 +1,32 @@ +import Foundation + +enum DockerExec { + static func run( + composeFile: String, service: String, + command: [String], + ) throws { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [ + "docker", "compose", "-f", composeFile, + "exec", "-T", "-u", "git", service, + ] + command + + let stderrPipe = Pipe() + process.standardError = stderrPipe + process.standardOutput = FileHandle.nullDevice + + try process.run() + process.waitUntilExit() + + if process.terminationStatus != 0 { + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + let stderr = String(data: stderrData, encoding: .utf8) ?? "" + throw SeedError.dockerExecFailed( + command: command.joined(separator: " "), + exitCode: process.terminationStatus, + stderr: stderr, + ) + } + } +} diff --git a/integration/forgejo-seed/Sources/Main.swift b/integration/forgejo-seed/Sources/Main.swift new file mode 100644 index 0000000..0b26c76 --- /dev/null +++ b/integration/forgejo-seed/Sources/Main.swift @@ -0,0 +1,30 @@ +import Foundation + +@main +struct ForgejeSeed { + static func main() async throws { + let args = CommandLine.arguments + guard args.count >= 3 else { + throw SeedError.missingArguments + } + + let baseURL = args[1] + let serviceName = args[2] + + // Compose file path: passed as 3rd arg, or derived from cwd + let composeFile: String = if args.count >= 4 { + args[3] + } else { + FileManager.default.currentDirectoryPath + + "/integration/docker-compose.yml" + } + + let seeder = Seeder( + baseURL: baseURL, + serviceName: serviceName, + composeFile: composeFile, + ) + + try await seeder.seed() + } +} diff --git a/integration/forgejo-seed/Sources/Retry.swift b/integration/forgejo-seed/Sources/Retry.swift new file mode 100644 index 0000000..2a8fdba --- /dev/null +++ b/integration/forgejo-seed/Sources/Retry.swift @@ -0,0 +1,20 @@ +import Foundation + +func withRetry( + maxAttempts: Int, delay: Duration, + operation: String, + body: () async throws -> T, +) async throws -> T { + var lastError: any Error = SeedError.retryExhausted(operation: operation, attempts: maxAttempts) + for attempt in 1 ... maxAttempts { + do { + return try await body() + } catch { + lastError = error + if attempt == maxAttempts { break } + print(" \(operation) attempt \(attempt) failed, retrying...") + try await Task.sleep(for: delay) + } + } + throw lastError +} diff --git a/integration/forgejo-seed/Sources/SeedError.swift b/integration/forgejo-seed/Sources/SeedError.swift new file mode 100644 index 0000000..54a9c8e --- /dev/null +++ b/integration/forgejo-seed/Sources/SeedError.swift @@ -0,0 +1,18 @@ +import Foundation + +enum SeedError: LocalizedError { + case missingArguments + case dockerExecFailed(command: String, exitCode: Int32, stderr: String) + case retryExhausted(operation: String, attempts: Int) + + var errorDescription: String? { + switch self { + case .missingArguments: + "Usage: forgejo-seed [compose-file]" + case let .dockerExecFailed(command, exitCode, stderr): + "Docker exec failed (\(exitCode)): \(command)\n\(stderr)" + case let .retryExhausted(operation, attempts): + "\(operation) failed after \(attempts) attempts" + } + } +} diff --git a/integration/forgejo-seed/Sources/Seeder.swift b/integration/forgejo-seed/Sources/Seeder.swift new file mode 100644 index 0000000..ca4cb88 --- /dev/null +++ b/integration/forgejo-seed/Sources/Seeder.swift @@ -0,0 +1,407 @@ +// swiftlint:disable file_length +import ForgejoKit +import Foundation + +// swiftlint:disable:next type_body_length +struct Seeder { + let baseURL: String + let serviceName: String + let composeFile: String + + private let adminUser = "testadmin" + private let adminPass = "admin1234" + private let testbotUser = "testbot" + private let testbotPass = "testbot1234" + private let readonlyUser = "readonlyuser" + private let readonlyPass = "readonly1234" + + func seed() async throws { + let adminClient = ForgejoClient( + serverURL: baseURL, username: adminUser, password: adminPass, + allowSelfSignedCertificates: true, + ) + let testbotClient = ForgejoClient( + serverURL: baseURL, username: testbotUser, password: testbotPass, + allowSelfSignedCertificates: true, + ) + + try createAdminUser() + try await createAPIUsers(adminClient: adminClient) + try await createRepositories(adminClient: adminClient) + try await createIssuesFilesMetadata(adminClient: adminClient, testbotClient: testbotClient) + try await createPullRequests(testbotClient: testbotClient) + try await setPRMetadata(adminClient: adminClient, testbotClient: testbotClient) + try await createAPIToken(adminClient: adminClient) + print("\nSeed data created successfully.") + } + + // MARK: - Phase 1: Create users + + private func createAdminUser() throws { + print("Creating admin user...") + try DockerExec.run( + composeFile: composeFile, service: serviceName, + command: [ + "forgejo", "admin", "user", "create", "--admin", + "--username", adminUser, + "--password", adminPass, + "--email", "testadmin@test.local", + "--must-change-password=false", + ], + ) + } + + private func createAPIUsers(adminClient: ForgejoClient) async throws { + let adminService = AdminService(client: adminClient) + + print("Creating testbot user...") + _ = try await adminService.createUser( + username: testbotUser, password: testbotPass, + email: "testbot@test.local", + ) + + print("Creating readonlyuser...") + _ = try await adminService.createUser( + username: readonlyUser, password: readonlyPass, + email: "readonlyuser@test.local", + ) + } + + // MARK: - Phase 2: Create repositories + + private func createRepositories(adminClient: ForgejoClient) async throws { + let repoService = RepositoryService(client: adminClient) + + print("Creating repositories...") + for (name, desc) in [ + ("test-repo", nil as String?), + ("test-repo-2", "A second test repository" as String?), + ("archived-repo", "An archived repository" as String?), + ("html-readme-repo", "Repo with HTML in README" as String?), + ] { + _ = try await repoService.createRepository( + name: name, description: desc, autoInit: true, + ) + } + print("Repositories created.") + + print("Archiving archived-repo...") + try await withRetry(maxAttempts: 5, delay: .seconds(2), operation: "archive repo") { + _ = try await repoService.editRepository( + owner: adminUser, repo: "archived-repo", archived: true, + ) + } + + print("Updating html-readme-repo README...") + let readmeSHA: String = try await withRetry(maxAttempts: 5, delay: .seconds(2), operation: "fetch README") { + let file = try await repoService.fetchFileContent( + owner: adminUser, repo: "html-readme-repo", path: "README.md", + ) + return file.sha + } + + _ = try await repoService.updateFile( + owner: adminUser, repo: "html-readme-repo", path: "README.md", + content: htmlReadmeContent, sha: readmeSHA, message: "Add HTML to README", + ) + } + + // MARK: - Phase 3: Issues, files, metadata + + // swiftlint:disable:next function_body_length + private func createIssuesFilesMetadata( + adminClient: ForgejoClient, testbotClient: ForgejoClient, + ) async throws { + let adminIssueService = IssueService(client: adminClient) + let testbotIssueService = IssueService(client: testbotClient) + let repoService = RepositoryService(client: adminClient) + + print("Creating pagination test issues...") + async let paginationTask: Void = { + for index in 1 ... 25 { + _ = try await adminIssueService.createIssue( + owner: adminUser, repo: "test-repo-2", + title: "Pagination test issue \(index)", body: nil, + ) + } + }() + + print("Creating issues as testbot...") + for index in 1 ... 3 { + _ = try await testbotIssueService.createIssue( + owner: adminUser, repo: "test-repo", + title: "Test issue \(index) from integration tests", body: nil, + ) + } + + print("Setting up test-repo content...") + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await withRetry(maxAttempts: 3, delay: .seconds(1), operation: "comment on issue #1") { + _ = try await testbotIssueService.createComment( + owner: adminUser, repo: "test-repo", + index: 1, body: "This is a test comment from testbot", + ) + } + } + + group.addTask { + try await withRetry(maxAttempts: 3, delay: .seconds(1), operation: "edit issue #3") { + _ = try await adminIssueService.editIssue( + owner: adminUser, repo: "test-repo", index: 3, + title: nil, + body: "This is the **markdown** body for issue 3.\n\nIt has multiple paragraphs.", + state: "closed", + ) + } + } + + group.addTask { + try await withRetry(maxAttempts: 3, delay: .seconds(1), operation: "edit issue #1") { + _ = try await adminIssueService.editIssue( + owner: adminUser, repo: "test-repo", index: 1, + title: nil, + body: "This is the **markdown** body for issue 1.\n\nIt has multiple paragraphs.", + state: nil, + ) + } + } + + group.addTask { + try await withRetry(maxAttempts: 3, delay: .seconds(1), operation: "edit issue #2") { + _ = try await adminIssueService.editIssue( + owner: adminUser, repo: "test-repo", index: 2, + title: nil, + body: "This is the **markdown** body for issue 2.\n\nIt has multiple paragraphs.", + state: nil, + ) + } + } + + group.addTask { + try await withRetry(maxAttempts: 3, delay: .seconds(1), operation: "add testbot collaborator") { + try await repoService.addCollaborator( + owner: adminUser, repo: "test-repo", + username: testbotUser, permission: "write", + ) + } + } + + group.addTask { + try await withRetry(maxAttempts: 3, delay: .seconds(1), operation: "add readonly collaborator") { + try await repoService.addCollaborator( + owner: adminUser, repo: "test-repo", + username: readonlyUser, permission: "read", + ) + } + } + + group.addTask { + try await withRetry(maxAttempts: 3, delay: .seconds(1), operation: "create label") { + _ = try await repoService.createLabel( + owner: adminUser, repo: "test-repo", + name: "bug", color: "#ee0701", + ) + } + } + + group.addTask { + try await withRetry(maxAttempts: 3, delay: .seconds(1), operation: "create milestone") { + _ = try await repoService.createMilestone( + owner: adminUser, repo: "test-repo", + title: "v1.0", description: "First release milestone", + dueOn: "2026-03-01T00:00:00Z", + ) + } + } + + try await group.waitForAll() + } + + _ = try await repoService.createFile( + owner: adminUser, repo: "test-repo", path: "hello.py", + content: "print(\"Hello from Forgejo!\")", + message: "Add hello.py", + ) + + _ = try await repoService.createFile( + owner: adminUser, repo: "test-repo", path: "src/main.py", + content: "def main():\n print(\"Main module\")", + message: "Add src/main.py", + ) + + try await paginationTask + + print("Phase 3 done.") + + print("Setting issue metadata...") + try await withRetry(maxAttempts: 3, delay: .seconds(1), operation: "add label to issue #1") { + _ = try await adminIssueService.replaceLabels( + owner: adminUser, repo: "test-repo", + index: 1, labelIDs: [1], + ) + } + try await withRetry(maxAttempts: 3, delay: .seconds(1), operation: "set milestone on issue #1") { + _ = try await adminIssueService.editIssue( + owner: adminUser, repo: "test-repo", index: 1, + title: nil, body: nil, state: nil, + milestone: 1, assignees: [adminUser], + ) + } + } + + // MARK: - Phase 4: PRs + + private func createPullRequests(testbotClient: ForgejoClient) async throws { + let repoService = RepositoryService(client: testbotClient) + let prService = PullRequestService(client: testbotClient) + + print("Creating feature branch...") + _ = try await repoService.createFile( + owner: adminUser, repo: "test-repo", path: "feature.txt", + content: "This file was added in a feature branch", + message: "Add feature.txt", + newBranch: "feature-branch", + ) + + print("Creating pull request #4...") + _ = try await prService.createPullRequest( + owner: adminUser, repo: "test-repo", + title: "Add feature file", head: "feature-branch", base: "main", + body: "This PR adds a new feature file.", + ) + + print("Creating merge branch...") + _ = try await repoService.createFile( + owner: adminUser, repo: "test-repo", path: "merge-test.txt", + content: "This file is for merge testing", + message: "Add merge-test.txt", + newBranch: "merge-branch", + ) + + print("Creating pull request #5...") + _ = try await prService.createPullRequest( + owner: adminUser, repo: "test-repo", + title: "Merge test PR", head: "merge-branch", base: "main", + body: "This PR is for testing the merge flow.", + ) + } + + // MARK: - Phase 5: PR metadata + + private func setPRMetadata( + adminClient: ForgejoClient, testbotClient: ForgejoClient, + ) async throws { + let testbotIssueService = IssueService(client: testbotClient) + let adminPRService = PullRequestService(client: adminClient) + let adminIssueService = IssueService(client: adminClient) + + print("Setting PR metadata...") + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await withRetry(maxAttempts: 3, delay: .seconds(1), operation: "comment on PR #4") { + _ = try await testbotIssueService.createComment( + owner: adminUser, repo: "test-repo", + index: 4, body: "Please review this PR when you get a chance.", + ) + } + } + + group.addTask { + try await withRetry(maxAttempts: 3, delay: .seconds(1), operation: "review PR #4") { + _ = try await adminPRService.createReview( + owner: adminUser, repo: "test-repo", index: 4, + body: "Looks good so far, just a few comments.", + event: "COMMENT", + comments: [ + CreateReviewComment( + body: "Consider a more descriptive filename.", + path: "feature.txt", + oldPosition: nil, + newPosition: 1, + ), + ], + ) + } + } + + group.addTask { + try await withRetry(maxAttempts: 3, delay: .seconds(1), operation: "set PR #4 metadata") { + _ = try await adminIssueService.editIssue( + owner: adminUser, repo: "test-repo", index: 4, + title: nil, body: nil, state: nil, + milestone: 1, assignees: [adminUser], + ) + } + } + + try await group.waitForAll() + } + } + + // MARK: - Phase 6: API token + + private func createAPIToken(adminClient: ForgejoClient) async throws { + let userService = UserService(client: adminClient) + + print("Creating API token for testadmin...") + let token = try await userService.createToken( + username: adminUser, + name: "integration-test-token", + scopes: [ + "read:user", "write:user", + "read:repository", "write:repository", + "read:issue", "write:issue", + "read:notification", "write:notification", + "read:organization", + ], + ) + + let tokenPath = "/tmp/forgejo_test_token.txt" + try token.sha1.write(toFile: tokenPath, atomically: true, encoding: .utf8) + print("API token written to \(tokenPath)") + } +} + +// MARK: - HTML README content + +private let htmlReadmeContent = """ +# HTML Readme Test + +Regular markdown paragraph. + +
+Click to expand + +This content is hidden by default. + +- Item A +- Item B +
+ +## Table + + + + + +
NameValue
Alpha1
Beta2
+ +## Keyboard Shortcuts + +Press Ctrl+C to copy. + +## Image + +placeholder + +## Security Test + + + + + + + +
Click me
+""" diff --git a/integration/setup.sh b/integration/setup.sh deleted file mode 100755 index b5a768e..0000000 --- a/integration/setup.sh +++ /dev/null @@ -1,310 +0,0 @@ -#!/usr/bin/env bash -set -eo pipefail - -SCRIPT_DIR="$(dirname "$0")" -COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml" -BASE_URL="${1:-http://localhost:13001}" -SERVICE_NAME="${2:-forgejo-1}" -ADMIN_USER="testadmin" -ADMIN_PASS="admin1234" -ADMIN_AUTH="$ADMIN_USER:$ADMIN_PASS" -TESTBOT_AUTH="testbot:testbot1234" - -# --- Phase 1: Create users (sequential — prerequisite for everything) --- -echo "Creating admin user..." -docker compose -f "$COMPOSE_FILE" exec -T -u git "$SERVICE_NAME" \ - forgejo admin user create --admin \ - --username "$ADMIN_USER" \ - --password "$ADMIN_PASS" \ - --email testadmin@test.local \ - --must-change-password=false - -echo "Creating testbot user..." -docker compose -f "$COMPOSE_FILE" exec -T -u git "$SERVICE_NAME" \ - forgejo admin user create \ - --username testbot \ - --password testbot1234 \ - --email testbot@test.local \ - --must-change-password=false - -echo "Creating readonlyuser..." -docker compose -f "$COMPOSE_FILE" exec -T -u git "$SERVICE_NAME" \ - forgejo admin user create \ - --username readonlyuser \ - --password readonly1234 \ - --email readonlyuser@test.local \ - --must-change-password=false - -# --- Phase 2: Create repositories (parallel) --- -echo "Creating repositories..." -curl -sf -X POST "$BASE_URL/api/v1/user/repos" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"name": "test-repo", "private": false, "auto_init": true}' & - -curl -sf -X POST "$BASE_URL/api/v1/user/repos" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"name": "test-repo-2", "description": "A second test repository", "private": false, "auto_init": true}' & - -curl -sf -X POST "$BASE_URL/api/v1/user/repos" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"name": "archived-repo", "description": "An archived repository", "private": false, "auto_init": true}' & - -curl -sf -X POST "$BASE_URL/api/v1/user/repos" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"name": "html-readme-repo", "description": "Repo with HTML in README", "private": false, "auto_init": true}' & - -wait -echo "Repositories created." - -# Archive the archived-repo (retry — the repo may not be ready immediately after creation) -echo "Archiving archived-repo..." -archived=false -for attempt in 1 2 3 4 5; do - http_code=$(curl -s -o /dev/null -w "%{http_code}" -X PATCH "$BASE_URL/api/v1/repos/$ADMIN_USER/archived-repo" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"archived": true}') - if [ "$http_code" = "200" ]; then - archived=true - break - fi - echo " Archive attempt $attempt failed (HTTP $http_code), retrying..." - sleep 2 -done -if [ "$archived" != "true" ]; then - echo "ERROR: Failed to archive repo after 5 attempts" >&2 - exit 1 -fi - -# Update html-readme-repo README with HTML content -echo "Updating html-readme-repo README..." -README_SHA="" -for attempt in 1 2 3 4 5; do - response=$(curl -sf "$BASE_URL/api/v1/repos/$ADMIN_USER/html-readme-repo/contents/README.md" \ - -u "$ADMIN_AUTH" 2>/dev/null) || { echo " README fetch attempt $attempt failed, retrying..."; sleep 2; continue; } - README_SHA=$(echo "$response" | python3 -c "import sys,json; print(json.load(sys.stdin)['sha'])" 2>/dev/null) && break - echo " README parse attempt $attempt failed, retrying..." - sleep 2 -done -if [ -z "$README_SHA" ]; then - echo "ERROR: Failed to fetch README SHA after 5 attempts" >&2 - exit 1 -fi -README_PAYLOAD=$(python3 -c " -import base64, json -content = '''# HTML Readme Test - -Regular markdown paragraph. - -
-Click to expand - -This content is hidden by default. - -- Item A -- Item B -
- -## Table - - - - - -
NameValue
Alpha1
Beta2
- -## Keyboard Shortcuts - -Press Ctrl+C to copy. - -## Image - -\"placeholder\" - -## Security Test - - - - - - - -
Click me
-''' -b64 = base64.b64encode(content.encode()).decode() -print(json.dumps({'content': b64, 'message': 'Add HTML to README', 'sha': '$README_SHA'})) -") -curl -sf -X PUT "$BASE_URL/api/v1/repos/$ADMIN_USER/html-readme-repo/contents/README.md" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d "$README_PAYLOAD" - -# --- Phase 3: Create issues + code files + pagination issues (parallel) --- - -# Create 25 pagination issues sequentially in a background subshell -# (sequential for deterministic issue numbering, background to overlap with other work) -echo "Creating pagination test issues..." -(for i in $(seq 1 25); do - curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo-2/issues" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d "{\"title\": \"Pagination test issue $i\"}" -done) & - -# Create issues 1-3 as testbot (generates notifications for testadmin) -echo "Creating issues as testbot..." -for i in 1 2 3; do - curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues" \ - -u "$TESTBOT_AUTH" \ - -H "Content-Type: application/json" \ - -d "{\"title\": \"Test issue $i from integration tests\"}" -done - -# Phase 3b: Independent operations on test-repo (parallel) -echo "Setting up test-repo content..." - -# Comment on issue #1 -curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/1/comments" \ - -u "$TESTBOT_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"body": "This is a test comment from testbot"}' & - -# Add body text + close issue #3 in one PATCH -curl -sf -X PATCH "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/3" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"body": "This is the **markdown** body for issue 3.\n\nIt has multiple paragraphs.", "state": "closed"}' & - -# Body for issue #1 -curl -sf -X PATCH "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/1" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"body": "This is the **markdown** body for issue 1.\n\nIt has multiple paragraphs."}' & - -# Body for issue #2 -curl -sf -X PATCH "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/2" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"body": "This is the **markdown** body for issue 2.\n\nIt has multiple paragraphs."}' & - -# Code files (sequential — both commit to same branch) -curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/contents/hello.py" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d "{\"content\": \"$(echo -n 'print(\"Hello from Forgejo!\")' | base64)\", \"message\": \"Add hello.py\"}" - -curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/contents/src/main.py" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d "{\"content\": \"$(echo -n 'def main():\n print(\"Main module\")' | base64)\", \"message\": \"Add src/main.py\"}" - -# Add testbot as collaborator -curl -sf -X PUT "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/collaborators/testbot" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"permission": "write"}' & - -# Add readonlyuser as read-only collaborator -curl -sf -X PUT "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/collaborators/readonlyuser" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"permission": "read"}' & - -# Create label -curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/labels" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"name": "bug", "color": "#ee0701"}' & - -# Create milestone -curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/milestones" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"title": "v1.0", "description": "First release milestone", "due_on": "2026-03-01T00:00:00Z"}' & - -wait -echo "Phase 3 done." - -# --- Phase 3c: Metadata that depends on label/milestone existing --- -# Label ID 1 and milestone ID 1 are deterministic because they're the first -# created on a fresh Forgejo instance (auto-increment starts at 1). -echo "Setting issue metadata..." - -# Add label to issue #1 (label ID 1 = "bug", created in Phase 3b) -curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/1/labels" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"labels": [1]}' & - -# Set milestone + assignee on issue #1 (milestone ID 1 = "v1.0", created in Phase 3b) -curl -sf -X PATCH "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/1" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"milestone": 1, "assignees": ["testadmin"]}' & - -wait - -# --- Phase 4: Create branches + PRs (sequential — numbers must be deterministic #4, #5) --- -echo "Creating feature branch..." -curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/contents/feature.txt" \ - -u "$TESTBOT_AUTH" \ - -H "Content-Type: application/json" \ - -d "{\"content\": \"$(echo -n 'This file was added in a feature branch' | base64)\", \"message\": \"Add feature.txt\", \"new_branch\": \"feature-branch\"}" - -echo "Creating pull request #4..." -curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/pulls" \ - -u "$TESTBOT_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"title": "Add feature file", "head": "feature-branch", "base": "main", "body": "This PR adds a new feature file."}' - -echo "Creating merge branch..." -curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/contents/merge-test.txt" \ - -u "$TESTBOT_AUTH" \ - -H "Content-Type: application/json" \ - -d "{\"content\": \"$(echo -n 'This file is for merge testing' | base64)\", \"message\": \"Add merge-test.txt\", \"new_branch\": \"merge-branch\"}" - -echo "Creating pull request #5..." -curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/pulls" \ - -u "$TESTBOT_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"title": "Merge test PR", "head": "merge-branch", "base": "main", "body": "This PR is for testing the merge flow."}' - -# --- Phase 5: Post-PR metadata (parallel) --- -echo "Setting PR metadata..." - -# Comment on PR #4 -curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/4/comments" \ - -u "$TESTBOT_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"body": "Please review this PR when you get a chance."}' & - -# Review on PR #4 -curl -sf -X POST "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/pulls/4/reviews" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"event": "COMMENT", "body": "Looks good so far, just a few comments.", "comments": [{"path": "feature.txt", "body": "Consider a more descriptive filename.", "new_position": 1}]}' & - -# Milestone + assignee on PR #4 -curl -sf -X PATCH "$BASE_URL/api/v1/repos/$ADMIN_USER/test-repo/issues/4" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"milestone": 1, "assignees": ["testadmin"]}' & - -wait - -# --- Phase 6: Create API token for token-based auth tests --- -echo "Creating API token for testadmin..." -TOKEN_RESPONSE=$(curl -sf -X POST "$BASE_URL/api/v1/users/$ADMIN_USER/tokens" \ - -u "$ADMIN_AUTH" \ - -H "Content-Type: application/json" \ - -d '{"name": "integration-test-token", "scopes": ["read:user", "write:user", "read:repository", "write:repository", "read:issue", "write:issue", "read:notification", "write:notification", "read:organization"]}') -API_TOKEN=$(echo "$TOKEN_RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['sha1'])") -echo "$API_TOKEN" > /tmp/forgejo_test_token.txt -echo "API token written to /tmp/forgejo_test_token.txt" - -echo "" -echo "Seed data created successfully." diff --git a/justfile b/justfile index 1dadd35..f2e2e6b 100644 --- a/justfile +++ b/justfile @@ -75,12 +75,14 @@ docker-up: docker-down: docker compose -f {{compose_file}} down -v 2>/dev/null || true rm -f /tmp/forgejo_test_url.txt /tmp/forgejo_test_url_*.txt + rm -f /tmp/forgejo_test_token.txt /tmp/forgejo_test_token_*.txt # Clear integration test caches (seed snapshots, temp files) clean-integration: rm -f integration/.forgejo-seed-snapshot.tar.gz integration/.forgejo-seed-hash rm -f /tmp/forgejo_test_url.txt /tmp/forgejo_test_url_*.txt rm -f /tmp/forgejo_test_token.txt /tmp/forgejo_test_token_*.txt + rm -rf integration/forgejo-seed/.build @echo "Integration test caches cleared." # Seed test data into Forgejo instances (with snapshot caching) @@ -92,7 +94,7 @@ seed: BASE_URL_2="{{base_url_2}}" SNAPSHOT_FILE="integration/.forgejo-seed-snapshot.tar.gz" HASH_FILE="integration/.forgejo-seed-hash" - SETUP_HASH=$(shasum -a 256 integration/setup.sh | awk '{print $1}') + SETUP_HASH=$(find integration/forgejo-seed/Sources -name '*.swift' -print0 | sort -z | xargs -0 shasum -a 256 | shasum -a 256 | awk '{print $1}') wait_for_instance() { local url="$1" elapsed=0 until curl -sf "$url/api/v1/version" > /dev/null 2>&1; do @@ -122,7 +124,7 @@ seed: echo "Snapshot restored to both instances." else echo "Seeding test data..." - bash integration/setup.sh "$BASE_URL_1" forgejo-1 + swift run --package-path integration/forgejo-seed forgejo-seed "$BASE_URL_1" forgejo-1 echo "Snapshotting seed data..." CONTAINER_1=$(docker compose -f "$COMPOSE_FILE" ps -q forgejo-1) docker exec "$CONTAINER_1" sh -c "cd / && tar czf /tmp/snapshot.tar.gz data" @@ -227,17 +229,12 @@ test-one filter="" destination=default_destination: just docker-down } trap cleanup EXIT - SIM_NAME="$(echo '{{destination}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" - xcrun simctl boot "$SIM_NAME" 2>/dev/null || true + just sim-boot '{{destination}}' '{{destination}}' & just docker-up & - xcodebuild build-for-testing \ - -project Forji/Forji.xcodeproj \ - -scheme Forji \ - -destination '{{destination}}' 2>&1 | xcbeautify & + just build-for-testing '{{destination}}' '{{destination}}' & wait just seed - SIM_SAFE="${SIM_NAME// /_}" - echo "{{base_url_1}}" > "/tmp/forgejo_test_url_${SIM_SAFE}.txt" + just _write-url-file '{{destination}}' '{{base_url_1}}' echo "{{base_url_1}}" > /tmp/forgejo_test_url.txt xcodebuild test-without-building \ -project Forji/Forji.xcodeproj \ @@ -264,13 +261,15 @@ build-for-testing destination_a=default_destination destination_b=default_destin -destination '{{destination_b}}' 2>&1 | xcbeautify [private] -_write-url-files destination_a=default_destination destination_b=default_destination_b: +_write-url-file destination url: #!/usr/bin/env bash set -eo pipefail - SIM_A="$(echo '{{destination_a}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" - SIM_B="$(echo '{{destination_b}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" - SIM_A_SAFE="${SIM_A// /_}" - SIM_B_SAFE="${SIM_B// /_}" - echo "{{base_url_1}}" > "/tmp/forgejo_test_url_${SIM_A_SAFE}.txt" - echo "{{base_url_2}}" > "/tmp/forgejo_test_url_${SIM_B_SAFE}.txt" + SIM="$(echo '{{destination}}' | sed -n 's/.*name=\([^,]*\).*/\1/p')" + SIM_SAFE="${SIM// /_}" + echo "{{url}}" > "/tmp/forgejo_test_url_${SIM_SAFE}.txt" + +[private] +_write-url-files destination_a=default_destination destination_b=default_destination_b: + just _write-url-file '{{destination_a}}' '{{base_url_1}}' + just _write-url-file '{{destination_b}}' '{{base_url_2}}' echo "{{base_url_1}}" > /tmp/forgejo_test_url.txt