diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..4b3c31e4 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,23 @@ +## 해결하려는 문제 + +> 어떤 문제를 해결하는지 적습니다. 필요에 따라 사진이나 링크를 첨부하고, 어떤 문제를 마주했는지에 집중해서 설명합니다. + +## 필요성 + +> 문제를 해결해야 하는 이유를 적습니다. 어떤 불편이 생기는지, 왜 문제로 다뤄야 하는지 설명합니다. + +## 해결 방법 + +> 문제를 어떤 방식으로 해결하는지 작성합니다. 설계부터 코드, 테스트까지 엔지니어링 영역에서 고려한 내용을 편안하게 설명합니다. + +## 한계와 트레이드오프 + +> 해결하지 못해 추가 작업이 필요하거나 함께 고려했지만 선택하지 않은 내용을 공유합니다. 리뷰 과정에서 선택지와 그에 따른 영향을 폭넓게 검토할 수 있도록 적습니다. + +## 기존 기능에 미치는 영향 + +> 기존 기능과 달라지는 부분이나 영향을 줄 수 있는 변경을 적습니다. + +## 예외 및 실패 사례 + +> 아직 해결하지 못한 예외나 실패 사례가 있다면 작성합니다. 해결하지 못한 사항도 드러내어 이후 개선할 목표로 이어질 수 있게 합니다. diff --git a/tools/workflow/MANUAL.md b/tools/workflow/MANUAL.md new file mode 100644 index 00000000..ddba7b24 --- /dev/null +++ b/tools/workflow/MANUAL.md @@ -0,0 +1,46 @@ +# Workflow 명령줄 도구 + +Workflow CLI(Command line interface, 명령줄 인터페이스)는 저장소 루트에서 실행합니다. + +```sh +tools/workflow/bin/workflow <명령> [인자와 옵션] +``` + +## 명령 + +| 명령 | 필수 입력 | 주요 옵션과 동작 | +| --- | --- | --- | +| `start` | `--task `, `--request-id `, `--title ` | `--requires `로 직접 선행 작업을 지정합니다. | +| `check` | 없음 | 현재 Worktree 변경을 검증합니다. | +| `review open` | `--body-file <경로>`, `--risk` (`normal` 또는 `high`) | 변경을 게시하고 초안 PR(Pull request)을 엽니다. | +| `review show` | 없음 | `--diff`, `--threads` (`open` 또는 `all`)를 지원합니다. 기본 조회 범위는 `open`입니다. | +| `review update` | `--revision ` | 변경 커밋을 다시 게시합니다. `--body-file <경로>`를 지정하면 PR 설명도 갱신합니다. | +| `review comment` | `--revision `, `--level` (`R`, `C` 또는 `A`), `--body <내용>` 또는 `--body-file <경로>` | 본문 입력은 하나만 지정합니다. 인라인 댓글에는 `--path <파일>`과 `--line <번호>`를 함께 지정합니다. | +| `review reply` | `--revision `, `--thread `, `--body <내용>` 또는 `--body-file <경로>` | 본문 입력은 하나만 지정합니다. | +| `review resolve` | `--revision `, `--thread ` | 지정한 리뷰 대화를 해결 상태로 바꿉니다. | +| `stack` | `--requires ` 또는 `--clear` | 두 옵션 중 하나만 지정합니다. | +| `sync` | 없음 | `--continue`로 동기화를 계속하거나 `--abort`로 중단합니다. 두 옵션은 함께 쓸 수 없습니다. | +| `status` | 없음 | 대상 조회에는 `--subtask`, `--task`, `--candidate`, `--release` 중 하나를 지정합니다. | +| `gate ready ` | `--review-revision ` | PR을 사람 검토 대상으로 전환합니다. | +| `gate approve ` | `--change-revision ` | 코드 변경을 승인합니다. | +| `gate deploy ` | `candidate-id` 인자 | Production Canary 배포를 승인합니다. | +| `gate release ` | `release-id` 인자 | 외부 공개를 승인합니다. | + +각 작업 명령에 `--json`을 붙이면 결과를 구조화된 JSON으로 출력합니다. + +`--allow-account-mismatch`는 `review update`, `review comment`, `review reply`, `review resolve`, `gate ready`, `gate approve`, `stack`, `sync`에서 사용할 수 있습니다. PR 변경 전에 현재 `gh` 사용자와 PR 작성자를 비교하며, 다르면 기본적으로 중단합니다. 이 옵션으로 계정 차이만 허용하면 작업을 계속하고 경고를 표준 오류(stderr)에 출력합니다. `--json` 결과는 표준 출력(stdout)에 유지됩니다. + +```sh +tools/workflow/bin/workflow start --task SK-33 --request-id sk-33-start --title '문서 추가' +tools/workflow/bin/workflow review open --body-file /tmp/pr-body.md --risk normal +tools/workflow/bin/workflow review show --diff --threads all --json +tools/workflow/bin/workflow review comment --revision abc123 --level C --body '[Agent] 검토 의견입니다.' +tools/workflow/bin/workflow review update --revision rv-4 --allow-account-mismatch --json +tools/workflow/bin/workflow status --subtask sk-33 +``` + +## 댓글과 인증 + +에이전트가 `review comment` 또는 `review reply`로 작성하는 댓글 본문은 `[Agent]`로 시작해야 합니다. `--body`와 `--body-file` 모두 같은 규칙을 적용합니다. + +Workflow CLI는 설정된 인증을 사용하며 로그인이나 인증 절차를 다시 실행하지 않습니다. diff --git a/tools/workflow/bin/workflow b/tools/workflow/bin/workflow new file mode 100755 index 00000000..d4e66738 Binary files /dev/null and b/tools/workflow/bin/workflow differ diff --git a/tools/workflow/build.gradle.kts b/tools/workflow/build.gradle.kts new file mode 100644 index 00000000..3ed7e685 --- /dev/null +++ b/tools/workflow/build.gradle.kts @@ -0,0 +1,84 @@ +import io.springkit.workflow.build.WorkflowNativeSmokeTest + +plugins { + kotlin("jvm") version "2.4.20" + kotlin("plugin.serialization") version "2.4.20" + application + id("com.diffplug.spotless") version "8.10.2" + id("org.graalvm.buildtools.native") version "1.1.14" +} + +group = "io.springkit" + +version = "0.1.0-SNAPSHOT" + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +application { + mainClass.set("io.springkit.workflow.MainKt") +} + +dependencies { + implementation("com.github.ajalt.clikt:clikt:5.0.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") + implementation("com.squareup.okio:okio:3.18.1") + + testImplementation("io.kotest:kotest-assertions-core:6.2.4") + testImplementation("io.kotest:kotest-runner-junit5:6.2.4") + testImplementation("com.squareup.okio:okio-fakefilesystem:3.18.1") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +kotlin { + jvmToolchain(21) +} + +spotless { + isEnforceCheck = false + + kotlin { + target("src/*/kotlin/**/*.kt", "buildSrc/src/**/*.kt") + ktfmt("0.63").metaStyle() + } + + kotlinGradle { + target("*.gradle.kts", "buildSrc/*.gradle.kts") + ktfmt("0.63").metaStyle() + } +} + +tasks.withType().configureEach { + useJUnitPlatform() +} + +graalvmNative { + binaries { + named("main") { + imageName = "workflow" + buildArgs.add("--initialize-at-build-time=kotlin.DeprecationLevel") + } + } +} + +val installWorkflowNative = + tasks.register("installWorkflowNative") { + dependsOn("nativeCompile") + from(layout.buildDirectory.dir("native/nativeCompile")) { + include("workflow") + } + into(layout.projectDirectory.dir("bin")) + } + +tasks.register("workflowNativeSmokeTest") { + dependsOn(installWorkflowNative) + repositoryRoot.set(layout.projectDirectory.dir("../..")) + executable.set(layout.projectDirectory.file("bin/workflow")) +} diff --git a/tools/workflow/buildSrc/build.gradle.kts b/tools/workflow/buildSrc/build.gradle.kts new file mode 100644 index 00000000..a6217e0b --- /dev/null +++ b/tools/workflow/buildSrc/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() +} diff --git a/tools/workflow/buildSrc/src/main/kotlin/io/springkit/workflow/build/WorkflowNativeSmokeTest.kt b/tools/workflow/buildSrc/src/main/kotlin/io/springkit/workflow/build/WorkflowNativeSmokeTest.kt new file mode 100644 index 00000000..c5498130 --- /dev/null +++ b/tools/workflow/buildSrc/src/main/kotlin/io/springkit/workflow/build/WorkflowNativeSmokeTest.kt @@ -0,0 +1,189 @@ +package io.springkit.workflow.build + +import groovy.json.JsonSlurper +import java.io.ByteArrayOutputStream +import java.io.File +import java.nio.charset.StandardCharsets +import javax.inject.Inject +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.process.ExecOperations + +/* + * Native 바이너리의 시작과 JSON 결과 계약을 확인하는 최소 실행 검증입니다. + */ +abstract class WorkflowNativeSmokeTest : DefaultTask() { + @get:Inject abstract val execOperations: ExecOperations + + /* + * 실행 위치와 환경 변수에만 사용하므로 입력 fingerprint에서 제외한 저장소 루트입니다. + */ + @get:Internal abstract val repositoryRoot: DirectoryProperty + + /* + * 시작과 JSON 결과 계약을 검증할 Native 실행 파일입니다. + */ + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val executable: RegularFileProperty + + @TaskAction + fun verifyNativeBinary() { + val repositoryRootFile = repositoryRoot.get().asFile + val executableFile = executable.get().asFile + val stateFile = temporaryDir.resolve("state.json") + stateFile.writeText( + """ + { + "schema_version": 1, + "store_revision": 0, + "tasks": { + "smoke-task": { + "id": "smoke-task", + "external_id": {"value": "SMOKE-1"}, + "title": "Native smoke task", + "state": "OPEN", + "sub_task_ids": [] + } + } + } + """ + .trimIndent() + ) + + val help = invoke(executableFile, repositoryRootFile, stateFile, "--help") + check(help.exitCode == 0) { + "Native 바이너리의 --help 실행이 실패했습니다. 종료 코드: ${help.exitCode}\n${help.stderr}" + } + check(help.stdout.contains("Usage: workflow")) { + "Native 바이너리가 도움말을 출력하지 않았습니다.\n${help.stdout}" + } + + val success = + invoke( + executableFile, + repositoryRootFile, + stateFile, + "status", + "--task", + "smoke-task", + "--json", + ) + requireJsonResult(success, expectedType = "success", expectedExitCode = 0) + + val failure = + invoke( + executableFile, + repositoryRootFile, + stateFile, + "status", + "--subtask", + "missing-subtask", + "--json", + ) + val failureJson = requireJsonResult(failure, expectedType = "failure", expectedExitCode = 1) + val failureData = failureJson["data"] as? Map<*, *> + check(failureData?.get("code") == "SUBTASK_NOT_FOUND") { + "대표 JSON 실패 코드가 예상과 다릅니다.\n${failure.stdout}" + } + + val configurationFailure = + invoke( + executableFile, + repositoryRootFile, + stateFile, + "status", + "--json", + extraEnvironment = + mapOf( + "WORKFLOW_DEPLOYMENT_COMMAND" to "[\"deploy\"]", + "WORKFLOW_RELEASE_COMMAND" to "", + ), + ) + val configurationJson = + requireJsonResult(configurationFailure, expectedType = "failure", expectedExitCode = 1) + val configurationData = configurationJson["data"] as? Map<*, *> + check(configurationData?.get("code") == "INVALID_ARGUMENT") { + "구성 오류가 공통 JSON 실패로 변환되지 않았습니다.\n${configurationFailure.stdout}" + } + + val actorConfigurationFailure = + invoke( + executableFile, + repositoryRootFile, + stateFile, + "status", + "--json", + extraEnvironment = mapOf("WORKFLOW_ACTOR_KIND" to "HUMAN"), + ) + val actorConfigurationJson = + requireJsonResult(actorConfigurationFailure, expectedType = "failure", expectedExitCode = 1) + val actorConfigurationData = actorConfigurationJson["data"] as? Map<*, *> + check(actorConfigurationData?.get("code") == "INVALID_ARGUMENT") { + "주체 종류 구성 오류가 공통 JSON 실패로 변환되지 않았습니다.\n${actorConfigurationFailure.stdout}" + } + } + + private fun invoke( + executable: File, + repositoryRoot: File, + stateFile: File, + vararg arguments: String, + extraEnvironment: Map = emptyMap(), + ): NativeInvocation { + val stdout = ByteArrayOutputStream() + val stderr = ByteArrayOutputStream() + val result = execOperations.exec { + commandLine(executable.absolutePath, *arguments) + workingDir(repositoryRoot) + environment("WORKFLOW_REPO_ROOT", repositoryRoot.absolutePath) + environment("WORKFLOW_STATE_FILE", stateFile.absolutePath) + extraEnvironment.forEach { (name, value) -> environment(name, value) } + standardOutput = stdout + errorOutput = stderr + isIgnoreExitValue = true + } + return NativeInvocation( + exitCode = result.exitValue, + stdout = stdout.toString(StandardCharsets.UTF_8.name()), + stderr = stderr.toString(StandardCharsets.UTF_8.name()), + ) + } + + private fun requireJsonResult( + invocation: NativeInvocation, + expectedType: String, + expectedExitCode: Int, + ): Map<*, *> { + check(invocation.exitCode == expectedExitCode) { + "JSON 경로의 종료 코드가 다릅니다. " + + "기대값: $expectedExitCode, 실제값: ${invocation.exitCode}\n${invocation.stderr}" + } + val lines = invocation.stdout.lineSequence().filter(String::isNotBlank).toList() + check(lines.size == 1) { + "JSON 경로는 표준 출력에 JSON 객체 하나만 기록해야 합니다.\n${invocation.stdout}" + } + val json = lines.single().trim() + val parsed = JsonSlurper().parseText(json) as? Map<*, *> + check(parsed != null) { "JSON 경로가 객체를 출력하지 않았습니다.\n$json" } + check(parsed["type"] == expectedType) { + "JSON 결과 type이 다릅니다. 기대값: $expectedType\n$json" + } + check(parsed["data"] is Map<*, *>) { + "JSON 결과에 data가 없습니다.\n$json" + } + return parsed + } + + private data class NativeInvocation( + val exitCode: Int, + val stdout: String, + val stderr: String, + ) +} diff --git a/tools/workflow/gradle.properties b/tools/workflow/gradle.properties new file mode 100644 index 00000000..b50a22a0 --- /dev/null +++ b/tools/workflow/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.configuration-cache=true +org.gradle.parallel=true +kotlin.code.style=official diff --git a/tools/workflow/gradlew b/tools/workflow/gradlew new file mode 100755 index 00000000..41c3d488 --- /dev/null +++ b/tools/workflow/gradlew @@ -0,0 +1,7 @@ +#!/bin/sh + +# Delegate to the repository's shared Gradle wrapper while selecting this +# directory as the project. Keeping one wrapper binary avoids copying it into +# each small SpringKit tool project. +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || exit 1 +exec "$SCRIPT_DIR/../../template/spring-boot-kotlin/gradlew" -p "$SCRIPT_DIR" "$@" diff --git a/tools/workflow/gradlew.bat b/tools/workflow/gradlew.bat new file mode 100644 index 00000000..98d1b830 --- /dev/null +++ b/tools/workflow/gradlew.bat @@ -0,0 +1,6 @@ +@echo off + +rem Delegate to the repository's shared Gradle wrapper and select this project. +set "SCRIPT_DIR=%~dp0" +call "%SCRIPT_DIR%..\template\spring-boot-kotlin\gradlew.bat" -p "%SCRIPT_DIR%" %* +exit /b %ERRORLEVEL% diff --git a/tools/workflow/settings.gradle.kts b/tools/workflow/settings.gradle.kts new file mode 100644 index 00000000..a83127f4 --- /dev/null +++ b/tools/workflow/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "workflow" diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/Main.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/Main.kt new file mode 100644 index 00000000..e7a11820 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/Main.kt @@ -0,0 +1,50 @@ +package io.springkit.workflow + +import com.github.ajalt.clikt.core.main +import io.springkit.workflow.adapter.cli.WorkflowCli +import io.springkit.workflow.adapter.cli.WorkflowCommandGateway +import io.springkit.workflow.adapter.json.encodeToString +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.runtime.DefaultRuntimeFactory +import io.springkit.workflow.runtime.WorkflowConfigurationException +import java.io.PrintStream +import kotlin.system.exitProcess + +/* + * Workflow CLI의 실행 진입점입니다. + */ +fun main(args: Array): Unit = + runWorkflow( + args = args, + stdout = System.out, + stderr = System.err, + runtimeFactory = { DefaultRuntimeFactory.create() }, + ) + +/* + * 실행 환경을 준비한 뒤 Clikt 명령을 시작하고 구성 오류를 공통 결과로 변환합니다. + */ +internal fun runWorkflow( + args: Array, + stdout: PrintStream, + stderr: PrintStream, + runtimeFactory: () -> WorkflowCommandGateway, + exit: (Int) -> Nothing = ::exitProcess, +) { + try { + WorkflowCli(runtimeFactory(), stdout, stderr).main(args) + } catch (failure: WorkflowConfigurationException) { + val result = + WorkflowResult.Failure( + FailureData( + code = FailureCode.INVALID_ARGUMENT, + message = failure.message ?: "Workflow 실행 환경 설정이 올바르지 않습니다.", + ) + ) + if (args.any { it == "--json" }) stdout.println(result.encodeToString()) + stderr.println("workflow 실행 환경 설정을 확인하지 못했습니다: ${result.data.message}") + exit(1) + } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/cli/WorkflowCli.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/cli/WorkflowCli.kt new file mode 100644 index 00000000..e62cc9ba --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/cli/WorkflowCli.kt @@ -0,0 +1,552 @@ +package io.springkit.workflow.adapter.cli + +import com.github.ajalt.clikt.core.CliktCommand +import com.github.ajalt.clikt.core.Context +import com.github.ajalt.clikt.core.ProgramResult +import com.github.ajalt.clikt.core.subcommands +import com.github.ajalt.clikt.parameters.arguments.argument +import com.github.ajalt.clikt.parameters.options.flag +import com.github.ajalt.clikt.parameters.options.option +import com.github.ajalt.clikt.parameters.options.required +import com.github.ajalt.clikt.parameters.types.int +import io.springkit.workflow.adapter.json.encodeToString +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.WorkflowResult +import java.io.PrintStream +import kotlinx.serialization.json.JsonObject + +/* + * CLI 어댑터가 이해하는 요청입니다. Application과 도메인 코드는 Clikt에 의존하지 않습니다. + */ +sealed interface WorkflowCommandRequest { + data class Start( + val task: String, + val requestId: String, + val title: String, + val requires: String? = null, + ) : WorkflowCommandRequest + + data object Check : WorkflowCommandRequest + + data class ReviewOpen( + val bodyFile: String, + val risk: String, + ) : WorkflowCommandRequest + + data class ReviewShow( + val diff: Boolean = false, + val threads: String = "open", + ) : WorkflowCommandRequest + + data class ReviewUpdate( + val revision: String, + val bodyFile: String? = null, + val allowAccountMismatch: Boolean = false, + ) : WorkflowCommandRequest + + data class ReviewComment( + val revision: String, + val level: String, + val body: String? = null, + val bodyFile: String? = null, + val path: String? = null, + val line: Int? = null, + val allowAccountMismatch: Boolean = false, + ) : WorkflowCommandRequest + + data class ReviewReply( + val revision: String, + val thread: String, + val body: String? = null, + val bodyFile: String? = null, + val allowAccountMismatch: Boolean = false, + ) : WorkflowCommandRequest + + data class ReviewResolve( + val revision: String, + val thread: String, + val allowAccountMismatch: Boolean = false, + ) : WorkflowCommandRequest + + data class Stack( + val requires: String? = null, + val clear: Boolean = false, + val allowAccountMismatch: Boolean = false, + ) : WorkflowCommandRequest + + data class Sync( + val continueSync: Boolean = false, + val abort: Boolean = false, + val allowAccountMismatch: Boolean = false, + ) : WorkflowCommandRequest + + data class Status( + val subtask: String? = null, + val task: String? = null, + val candidate: String? = null, + val release: String? = null, + ) : WorkflowCommandRequest + + data class GateReady( + val target: String, + val reviewRevision: String, + val allowAccountMismatch: Boolean = false, + ) : WorkflowCommandRequest + + data class GateApprove( + val target: String, + val changeRevision: String, + val allowAccountMismatch: Boolean = false, + ) : WorkflowCommandRequest + + data class GateDeploy(val target: String) : WorkflowCommandRequest + + data class GateRelease(val target: String) : WorkflowCommandRequest +} + +/* + * 어댑터 사용자가 요청 모델을 간편하게 사용하도록 짧은 이름을 제공하며 Clikt 타입은 노출하지 않습니다. + */ +typealias StartCommandRequest = WorkflowCommandRequest.Start + +typealias CheckCommandRequest = WorkflowCommandRequest.Check + +typealias ReviewOpenCommandRequest = WorkflowCommandRequest.ReviewOpen + +typealias ReviewShowCommandRequest = WorkflowCommandRequest.ReviewShow + +typealias ReviewUpdateCommandRequest = WorkflowCommandRequest.ReviewUpdate + +typealias ReviewCommentCommandRequest = WorkflowCommandRequest.ReviewComment + +typealias ReviewReplyCommandRequest = WorkflowCommandRequest.ReviewReply + +typealias ReviewResolveCommandRequest = WorkflowCommandRequest.ReviewResolve + +typealias StackCommandRequest = WorkflowCommandRequest.Stack + +typealias SyncCommandRequest = WorkflowCommandRequest.Sync + +typealias StatusCommandRequest = WorkflowCommandRequest.Status + +typealias GateReadyCommandRequest = WorkflowCommandRequest.GateReady + +typealias GateApproveCommandRequest = WorkflowCommandRequest.GateApprove + +typealias GateDeployCommandRequest = WorkflowCommandRequest.GateDeploy + +typealias GateReleaseCommandRequest = WorkflowCommandRequest.GateRelease + +/* + * CLI의 경계입니다. 런타임 조합에서 Application 기반 구현을 제공합니다. + */ +fun interface WorkflowCommandGateway { + fun execute(request: WorkflowCommandRequest): WorkflowResult +} + +/* + * 공개 Workflow 명령 계약을 위한 Clikt 인바운드 어댑터입니다. + */ +class WorkflowCli( + private val gateway: WorkflowCommandGateway, + private val stdout: PrintStream = System.out, + private val stderr: PrintStream = System.err, +) : CliktCommand(name = "workflow") { + private var jsonOutputRequested = false + + init { + configureContext { + transformToken = { _, token -> + if (token == "--json") jsonOutputRequested = true + token + } + echoMessage = { context: Context, message: Any?, trailingNewline: Boolean, error: Boolean -> + if (error && jsonOutputRequested) { + stdout.println( + WorkflowResult.Failure( + FailureData( + code = FailureCode.INVALID_ARGUMENT, + message = message.toString(), + ) + ) + .encodeToString() + ) + } else { + val output = if (error) stderr else stdout + if (trailingNewline) output.println(message) else output.print(message) + } + } + } + subcommands( + StartCommand(gateway, stdout, stderr), + CheckCommand(gateway, stdout, stderr), + ReviewCommand(gateway, stdout, stderr), + StackCommand(gateway, stdout, stderr), + SyncCommand(gateway, stdout, stderr), + StatusCommand(gateway, stdout, stderr), + GateCommand(gateway, stdout, stderr), + ) + } + + override fun run() = Unit +} + +private abstract class WorkflowLeafCommand( + name: String, + private val helpText: String, + private val gateway: WorkflowCommandGateway, + private val stdout: PrintStream, + private val stderr: PrintStream, +) : CliktCommand(name = name) { + override fun help(context: Context): String = helpText + + protected val json by option("--json", help = "구조화된 JSON 결과를 출력합니다.").flag() + + protected fun execute(request: WorkflowCommandRequest) { + when (val result = gateway.execute(request)) { + is WorkflowResult.Success -> { + if (json) stdout.println(result.encodeToString()) + else stderr.println("workflow 작업이 완료되었습니다.") + } + is WorkflowResult.Failure -> { + if (json) stdout.println(result.encodeToString()) + stderr.println("workflow 작업을 완료하지 못했습니다: ${result.data.message}") + throw ProgramResult(1) + } + } + } + + protected fun invalid(message: String): Nothing = currentContext.fail(message) + + protected fun requireExactlyOne( + firstName: String, + first: String?, + secondName: String, + second: String?, + ): String { + if ((first == null) == (second == null)) { + invalid("$firstName 또는 $secondName 중 하나만 지정해야 합니다.") + } + return first ?: second!! + } +} + +private class StartCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("start", "새 SubTask와 Worktree를 시작합니다.", gateway, stdout, stderr) { + private val task by option("--task", help = "외부 Task ID.").required() + private val requestId by option("--request-id", help = "재시도를 식별하는 요청 ID.").required() + private val title by option("--title", help = "SubTask 변경 의도.").required() + private val requires by option("--requires", help = "직접 필요한 선행 SubTask ID.") + + override fun run() { + execute(WorkflowCommandRequest.Start(task, requestId, title, requires)) + } +} + +private class CheckCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("check", "현재 Worktree의 변경을 검증합니다.", gateway, stdout, stderr) { + override fun run() = execute(WorkflowCommandRequest.Check) +} + +private class ReviewCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : CliktCommand(name = "review") { + init { + subcommands( + ReviewOpenCommand(gateway, stdout, stderr), + ReviewShowCommand(gateway, stdout, stderr), + ReviewUpdateCommand(gateway, stdout, stderr), + ReviewCommentCommand(gateway, stdout, stderr), + ReviewReplyCommand(gateway, stdout, stderr), + ReviewResolveCommand(gateway, stdout, stderr), + ) + } + + override fun help(context: Context): String = "PR Review lifecycle을 관리합니다." + + override fun run() = Unit +} + +private class ReviewOpenCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("open", "현재 변경을 게시하고 Draft PR을 생성합니다.", gateway, stdout, stderr) { + private val bodyFile by option("--body-file").required() + private val risk by option("--risk").required() + + override fun run() { + if (risk != "normal" && risk != "high") { + invalid("--risk는 normal 또는 high여야 합니다.") + } + execute(WorkflowCommandRequest.ReviewOpen(bodyFile, risk)) + } +} + +private class ReviewShowCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("show", "현재 Review 상태를 조회합니다.", gateway, stdout, stderr) { + private val diff by option("--diff").flag() + private val threads by option("--threads") + + override fun run() { + val selectedThreads = threads ?: "open" + if (selectedThreads != "open" && selectedThreads != "all") { + invalid("--threads는 open 또는 all이어야 합니다.") + } + execute(WorkflowCommandRequest.ReviewShow(diff, selectedThreads)) + } +} + +private class ReviewUpdateCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("update", "Review 변경을 다시 게시합니다.", gateway, stdout, stderr) { + private val revision by option("--revision").required() + private val bodyFile by option("--body-file") + private val allowAccountMismatch by + option( + "--allow-account-mismatch", + help = "현재 gh 사용자와 PR 작성자가 다를 때 해당 불일치만 허용합니다.", + ) + .flag() + + override fun run() { + execute(WorkflowCommandRequest.ReviewUpdate(revision, bodyFile, allowAccountMismatch)) + } +} + +private class ReviewCommentCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("comment", "Review 의견을 추가합니다.", gateway, stdout, stderr) { + private val revision by option("--revision").required() + private val level by option("--level").required() + private val body by option("--body") + private val bodyFile by option("--body-file") + private val path by option("--path") + private val line by option("--line").int() + private val allowAccountMismatch by + option( + "--allow-account-mismatch", + help = "현재 gh 사용자와 PR 작성자가 다를 때 해당 불일치만 허용합니다.", + ) + .flag() + + override fun run() { + requireExactlyOne("--body", body, "--body-file", bodyFile) + if ((path == null) != (line == null)) invalid("--path와 --line은 함께 지정해야 합니다.") + if (level !in setOf("R", "C", "A")) invalid("--level은 R, C 또는 A여야 합니다.") + execute( + WorkflowCommandRequest.ReviewComment( + revision, + level, + body, + bodyFile, + path, + line, + allowAccountMismatch, + ) + ) + } +} + +private class ReviewReplyCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("reply", "Review thread에 답변합니다.", gateway, stdout, stderr) { + private val revision by option("--revision").required() + private val thread by option("--thread").required() + private val body by option("--body") + private val bodyFile by option("--body-file") + private val allowAccountMismatch by + option( + "--allow-account-mismatch", + help = "현재 gh 사용자와 PR 작성자가 다를 때 해당 불일치만 허용합니다.", + ) + .flag() + + override fun run() { + requireExactlyOne("--body", body, "--body-file", bodyFile) + execute( + WorkflowCommandRequest.ReviewReply( + revision, + thread, + body, + bodyFile, + allowAccountMismatch, + ) + ) + } +} + +private class ReviewResolveCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("resolve", "Review thread를 해결 상태로 변경합니다.", gateway, stdout, stderr) { + private val revision by option("--revision").required() + private val thread by option("--thread").required() + private val allowAccountMismatch by + option( + "--allow-account-mismatch", + help = "현재 gh 사용자와 PR 작성자가 다를 때 해당 불일치만 허용합니다.", + ) + .flag() + + override fun run() { + execute(WorkflowCommandRequest.ReviewResolve(revision, thread, allowAccountMismatch)) + } +} + +private class StackCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("stack", "직접 코드 의존성을 변경합니다.", gateway, stdout, stderr) { + private val requires by option("--requires") + private val clear by option("--clear").flag() + private val allowAccountMismatch by + option( + "--allow-account-mismatch", + help = "현재 gh 사용자와 PR 작성자가 다를 때 해당 불일치만 허용합니다.", + ) + .flag() + + override fun run() { + if ((requires == null) == !clear) invalid("--requires 또는 --clear 중 하나만 지정해야 합니다.") + execute(WorkflowCommandRequest.Stack(requires, clear, allowAccountMismatch)) + } +} + +private class SyncCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("sync", "Worktree와 원격 상태를 동기화합니다.", gateway, stdout, stderr) { + private val continueSync by option("--continue").flag() + private val abort by option("--abort").flag() + private val allowAccountMismatch by + option( + "--allow-account-mismatch", + help = "현재 gh 사용자와 PR 작성자가 다를 때 해당 불일치만 허용합니다.", + ) + .flag() + + override fun run() { + if (continueSync && abort) invalid("--continue와 --abort 중 하나만 지정해야 합니다.") + execute(WorkflowCommandRequest.Sync(continueSync, abort, allowAccountMismatch)) + } +} + +private class StatusCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("status", "Workflow 상태와 다음 행동을 조회합니다.", gateway, stdout, stderr) { + private val subtask by option("--subtask") + private val task by option("--task") + private val candidate by option("--candidate") + private val release by option("--release") + + override fun run() { + val selected = listOfNotNull(subtask, task, candidate, release) + if (selected.size > 1) invalid("대상 선택 인자는 최대 하나만 사용할 수 있습니다.") + execute(WorkflowCommandRequest.Status(subtask, task, candidate, release)) + } +} + +private class GateCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : CliktCommand(name = "gate") { + init { + subcommands( + GateReadyCommand(gateway, stdout, stderr), + GateApproveCommand(gateway, stdout, stderr), + GateDeployCommand(gateway, stdout, stderr), + GateReleaseCommand(gateway, stdout, stderr), + ) + } + + override fun help(context: Context): String = "사람의 Workflow 의사결정을 기록합니다." + + override fun run() = Unit +} + +private class GateReadyCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("ready", "PR을 사람 리뷰 대상으로 전환합니다.", gateway, stdout, stderr) { + private val target by argument("subtask") + private val reviewRevision by option("--review-revision").required() + private val allowAccountMismatch by + option( + "--allow-account-mismatch", + help = "현재 gh 사용자와 PR 작성자가 다를 때 해당 불일치만 허용합니다.", + ) + .flag() + + override fun run() { + execute(WorkflowCommandRequest.GateReady(target, reviewRevision, allowAccountMismatch)) + } +} + +private class GateApproveCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("approve", "코드 변경을 승인합니다.", gateway, stdout, stderr) { + private val target by argument("subtask") + private val changeRevision by option("--change-revision").required() + private val allowAccountMismatch by + option( + "--allow-account-mismatch", + help = "현재 gh 사용자와 PR 작성자가 다를 때 해당 불일치만 허용합니다.", + ) + .flag() + + override fun run() { + execute(WorkflowCommandRequest.GateApprove(target, changeRevision, allowAccountMismatch)) + } +} + +private class GateDeployCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("deploy", "Production Canary 배포를 승인합니다.", gateway, stdout, stderr) { + private val target by argument("candidate") + + override fun run() { + execute(WorkflowCommandRequest.GateDeploy(target)) + } +} + +private class GateReleaseCommand( + gateway: WorkflowCommandGateway, + stdout: PrintStream, + stderr: PrintStream, +) : WorkflowLeafCommand("release", "외부 공개 시작을 승인합니다.", gateway, stdout, stderr) { + private val target by argument("release") + + override fun run() { + execute(WorkflowCommandRequest.GateRelease(target)) + } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/git/LocalGitAdapter.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/git/LocalGitAdapter.kt new file mode 100644 index 00000000..29434b9d --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/git/LocalGitAdapter.kt @@ -0,0 +1,831 @@ +package io.springkit.workflow.adapter.git + +import io.springkit.workflow.application.AbortRestackRequest +import io.springkit.workflow.application.AbortRestackResponse +import io.springkit.workflow.application.ChangeReceipt +import io.springkit.workflow.application.CheckRemotePushRequest +import io.springkit.workflow.application.CheckRemotePushResponse +import io.springkit.workflow.application.ContinueRestackRequest +import io.springkit.workflow.application.ContinueRestackResponse +import io.springkit.workflow.application.CreateBranchRequest +import io.springkit.workflow.application.CreateBranchResponse +import io.springkit.workflow.application.CreateWorktreeRequest +import io.springkit.workflow.application.CreateWorktreeResponse +import io.springkit.workflow.application.GitInspectRequest +import io.springkit.workflow.application.GitInspectResponse +import io.springkit.workflow.application.GitPort +import io.springkit.workflow.application.GitPublishPort +import io.springkit.workflow.application.GitStatus +import io.springkit.workflow.application.ListRemoteBranchesRequest +import io.springkit.workflow.application.ListRemoteBranchesResponse +import io.springkit.workflow.application.MainRevisionRequest +import io.springkit.workflow.application.MainRevisionResponse +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.PublishBranchRequest +import io.springkit.workflow.application.PublishBranchResponse +import io.springkit.workflow.application.RemoteBranch +import io.springkit.workflow.application.RemoveBranchRequest +import io.springkit.workflow.application.RemoveBranchResponse +import io.springkit.workflow.application.RemoveRemoteBranchRequest +import io.springkit.workflow.application.RemoveRemoteBranchResponse +import io.springkit.workflow.application.RemoveWorktreeRequest +import io.springkit.workflow.application.RemoveWorktreeResponse +import io.springkit.workflow.application.RestackRequest +import io.springkit.workflow.application.RestackResponse +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.common.LocalCommandRunner +import io.springkit.workflow.common.ManagedPathResolver +import io.springkit.workflow.domain.WorkspaceId +import java.nio.file.Path +import java.security.MessageDigest + +/* + * 설치된 git 실행 파일에 Git 동작을 위임하는 [GitPort] 구현입니다. + */ +class LocalGitAdapter( + private val repositoryRoot: Path, + private val workspacePath: (WorkspaceId) -> Path, + private val commandRunner: CommandRunner = LocalCommandRunner(), + managedWorkspaceRoot: Path? = null, +) : GitPort, GitPublishPort { + private val managedWorkspaceRoot: Path? = + managedWorkspaceRoot?.let(ManagedPathResolver::canonicalize) + + override fun refreshMain(request: MainRevisionRequest): PortResult { + validateRemote(request.remote)?.let { + return it + } + validateBranch(request.branch)?.let { + return it + } + val fetch = run(listOf("git", "fetch", request.remote, request.branch), repositoryRoot) + if (fetch is Execution.Failure) return fetch.result + + return findRevision(request)?.let { PortResult.Success(MainRevisionResponse(it)) } + ?: failure( + code = "MAIN_REVISION_UNAVAILABLE", + message = "기준 revision을 확인할 수 없습니다: ${request.remote}/${request.branch}", + target = request.branch, + ) + } + + override fun inspect(request: GitInspectRequest): PortResult { + val path = resolveWorkspace(request.workspaceId) ?: return workspaceFailure(request.workspaceId) + val revision = run(listOf("git", "rev-parse", "HEAD"), path) + if (revision is Execution.Failure) return workspaceInspectFailure(revision.result) + val revisionResult = (revision as Execution.Success).result + val status = run(listOf("git", "status", "--porcelain=v1", "--untracked-files=all"), path) + if (status is Execution.Failure) return status.result + val statusResult = (status as Execution.Success).result + val diff = run(listOf("git", "diff", "--binary", "HEAD"), path) + if (diff is Execution.Failure) return diff.result + val diffResult = (diff as Execution.Success).result + val untracked = run(listOf("git", "ls-files", "--others", "--exclude-standard", "-z"), path) + if (untracked is Execution.Failure) return untracked.result + val untrackedPaths = + (untracked as Execution.Success).result.stdout.split('\u0000').filter(String::isNotBlank) + val untrackedHashes = buildString { + untrackedPaths.forEach { untrackedPath -> + val hash = run(listOf("git", "hash-object", "--", untrackedPath), path) + if (hash is Execution.Failure) return hash.result + append(untrackedPath).append('\u0000').append((hash as Execution.Success).result.stdout) + } + } + + val conflicts = + if ( + statusResult.stdout.lineSequence().any { + it.take(2) in setOf("DD", "AU", "UD", "UA", "DU", "AA", "UU") + } + ) { + val result = run(listOf("git", "diff", "--name-only", "--diff-filter=U"), path) + if (result is Execution.Failure) return result.result + (result as Execution.Success).result.stdout.lines().filter(String::isNotBlank) + } else { + emptyList() + } + val revisionValue = revisionResult.stdout.trim() + if (revisionValue.isBlank()) { + return failure("GIT_INSPECT_FAILED", "HEAD revision이 비어 있습니다.", request.workspaceId) + } + val statusOutput = statusResult.stdout + return PortResult.Success( + GitInspectResponse( + GitStatus( + revision = revisionValue, + fingerprint = fingerprint(revisionValue, diffResult.stdout, untrackedHashes), + dirty = statusOutput.isNotBlank(), + conflicts = conflicts, + ) + ) + ) + } + + override fun checkRemotePush( + request: CheckRemotePushRequest + ): PortResult { + validateRemote(request.remote)?.let { + return it + } + validateBranch(request.branch)?.let { + return it + } + val path = resolveWorkspace(request.workspaceId) ?: return workspaceFailure(request.workspaceId) + /* + * 원격 ref는 변경하지 않으며, 로컬 pre-push hook은 실행하지 않습니다. + */ + val result = + run( + listOf( + "git", + "push", + "--dry-run", + "--no-verify", + request.remote, + "HEAD:refs/heads/${request.branch}", + ), + path, + ) + return when (result) { + is Execution.Success -> PortResult.Success(CheckRemotePushResponse(publishable = true)) + is Execution.Failure -> + PortResult.Success( + CheckRemotePushResponse(publishable = false, message = result.result.error.message) + ) + } + } + + override fun createBranch(request: CreateBranchRequest): PortResult { + validateBranch(request.branch)?.let { + return it + } + validateBranch(request.baseBranch)?.let { + return it + } + validateRevision(request.baseRevision, "기준 revision")?.let { + return it + } + val result = run(listOf("git", "branch", request.branch, request.baseRevision), repositoryRoot) + if (result is Execution.Failure) return result.result + return PortResult.Success( + CreateBranchResponse( + branch = request.branch, + revision = request.baseRevision, + change = receipt("git-create-branch-${request.branch}", "create-branch"), + ) + ) + } + + override fun createWorktree(request: CreateWorktreeRequest): PortResult { + validateBranch(request.branch)?.let { + return it + } + val path = managedWorkspacePath(request.path) + if (managedWorkspaceRoot != null && path == null) { + return invalidWorkspacePath(request.path.value) + } + val result = + run( + listOf( + "git", + "worktree", + "add", + path?.toString() ?: request.path.value, + request.branch, + ), + repositoryRoot, + ) + if (result is Execution.Failure) return result.result + return PortResult.Success( + CreateWorktreeResponse( + workspaceId = request.workspaceId, + branch = request.branch, + path = request.path, + change = receipt("git-create-worktree-${request.workspaceId}", "create-worktree"), + ) + ) + } + + override fun restack(request: RestackRequest): PortResult { + validateBranch(request.branch)?.let { + return it + } + validateBranch(request.baseBranch)?.let { + return it + } + validateRevision(request.baseRevision, "동기화 기준 revision")?.let { + return it + } + validateRevision(request.expectedRevision, "예상 revision")?.let { + return it + } + val path = resolveWorkspace(request.workspaceId) ?: return workspaceFailure(request.workspaceId) + val current = run(listOf("git", "rev-parse", "HEAD"), path) + if (current is Execution.Failure) return current.result + val currentResult = (current as Execution.Success).result + val currentRevision = currentResult.stdout.trim() + if (currentRevision != request.expectedRevision) { + return failure( + code = "STALE_REVISION", + message = + "예상 revision과 현재 revision이 다릅니다: expected=${request.expectedRevision}, current=$currentRevision", + retryable = true, + target = request.workspaceId, + ) + } + + val beforeDiff = run(diffCommand(request.baseRevision), path) + if (beforeDiff is Execution.Failure) return beforeDiff.result + val beforeDiffResult = (beforeDiff as Execution.Success).result + val rebase = run(listOf("git", "rebase", request.baseRevision), path) + if (rebase is Execution.Failure) return restackFailure(request.workspaceId, rebase.result, path) + val revision = run(listOf("git", "rev-parse", "HEAD"), path) + if (revision is Execution.Failure) return revision.result + val revisionResult = (revision as Execution.Success).result + val afterDiff = run(diffCommand(request.baseRevision), path) + if (afterDiff is Execution.Failure) return afterDiff.result + val afterDiffResult = (afterDiff as Execution.Success).result + + val revisionValue = revisionResult.stdout.trim() + if (revisionValue.isBlank()) { + return failure("RESTACK_FAILED", "rebase 이후 revision이 비어 있습니다.", request.workspaceId) + } + return PortResult.Success( + RestackResponse( + revision = revisionValue, + fingerprint = fingerprint(revisionValue, afterDiffResult.stdout, ""), + diffChanged = beforeDiffResult.stdout != afterDiffResult.stdout, + change = + ChangeReceipt( + id = "git-restack-${request.workspaceId}", + operation = "restack", + beforeRevision = request.expectedRevision, + afterRevision = revisionValue, + ), + ) + ) + } + + override fun continueRestack( + request: ContinueRestackRequest + ): PortResult { + val path = resolveWorkspace(request.workspaceId) ?: return workspaceFailure(request.workspaceId) + val result = run(listOf("git", "-c", "core.editor=true", "rebase", "--continue"), path) + if (result is Execution.Failure) { + val conflicts = unresolvedPaths(path) + return failure( + code = if (conflicts.isEmpty()) "RESTACK_CONTINUE_FAILED" else "SYNC_CONFLICT", + message = + buildString { + append("git rebase --continue에 실패했습니다") + if (result.result.error.message.isNotBlank()) { + append(": ").append(result.result.error.message) + } + if (conflicts.isNotEmpty()) { + append("; 충돌 파일: ").append(conflicts.joinToString(", ")) + } + }, + target = request.workspaceId, + ) + } + return PortResult.Success( + ContinueRestackResponse( + change = + ChangeReceipt( + id = "git-restack-continue-${request.workspaceId}", + operation = "restack-continue", + ) + ) + ) + } + + override fun abortRestack(request: AbortRestackRequest): PortResult { + val path = resolveWorkspace(request.workspaceId) ?: return workspaceFailure(request.workspaceId) + val result = run(listOf("git", "rebase", "--abort"), path) + if (result is Execution.Failure) { + return failure( + code = "RESTACK_ABORT_FAILED", + message = "git rebase --abort에 실패했습니다: ${result.result.error.message}", + target = request.workspaceId, + ) + } + return PortResult.Success( + AbortRestackResponse( + change = + ChangeReceipt( + id = "git-restack-abort-${request.workspaceId}", + operation = "restack-abort", + ) + ) + ) + } + + override fun removeBranch(request: RemoveBranchRequest): PortResult { + validateBranch(request.branch)?.let { + return it + } + request.expectedRevision?.let { + validateRevision(it, "예상 branch revision")?.let { failure -> + return failure + } + } + if (request.expectedRevision != null) { + val revision = run(listOf("git", "rev-parse", "refs/heads/${request.branch}"), repositoryRoot) + if (revision is Execution.Failure) { + if (branchExists(request.branch) == false) { + return PortResult.Success( + RemoveBranchResponse( + request.branch, + receipt("git-remove-branch-${request.branch}", "remove-branch"), + ) + ) + } + return revision.result + } + val currentRevision = (revision as Execution.Success).result.stdout.trim() + if (currentRevision != request.expectedRevision) { + return failure( + code = "STALE_REVISION", + message = + "예상 branch revision과 현재 revision이 다릅니다: expected=${request.expectedRevision}, current=$currentRevision", + retryable = true, + target = request.branch, + ) + } + } + val result = run(listOf("git", "branch", "-D", request.branch), repositoryRoot) + if (result is Execution.Failure) { + if (branchExists(request.branch) == false) { + return PortResult.Success( + RemoveBranchResponse( + request.branch, + receipt("git-remove-branch-${request.branch}", "remove-branch"), + ) + ) + } + return result.result + } + return PortResult.Success( + RemoveBranchResponse( + request.branch, + receipt("git-remove-branch-${request.branch}", "remove-branch"), + ) + ) + } + + override fun listRemoteBranches( + request: ListRemoteBranchesRequest + ): PortResult { + validateRemote(request.remote)?.let { + return it + } + return when (val result = readRemoteBranches(request.remote)) { + is PortResult.Failure -> result + is PortResult.Success -> PortResult.Success(ListRemoteBranchesResponse(result.value)) + } + } + + override fun removeRemoteBranch( + request: RemoveRemoteBranchRequest + ): PortResult { + validateRemote(request.remote)?.let { + return it + } + validateBranch(request.branch)?.let { + return it + } + request.expectedRevision?.let { + if (!isSafeToken(it)) { + return failure( + code = "INVALID_ARGUMENT", + message = "원격 Branch expected revision이 안전한 토큰이 아닙니다.", + target = request.branch, + ) + } + } + if (request.expectedRevision != null) { + val branches = + when (val remoteBranches = readRemoteBranches(request.remote)) { + is PortResult.Failure -> return remoteBranches + is PortResult.Success -> remoteBranches.value + } + val currentRevision = + branches.singleOrNull { it.branch == request.branch }?.revision + ?: return failure( + code = "REMOTE_BRANCH_NOT_FOUND", + message = "삭제할 원격 Branch를 찾을 수 없습니다: ${request.branch}", + target = request.branch, + ) + if (currentRevision != request.expectedRevision) { + return failure( + code = "STALE_REVISION", + message = + "예상 원격 Branch revision과 현재 revision이 다릅니다: expected=${request.expectedRevision}, current=$currentRevision", + retryable = true, + target = request.branch, + ) + } + } + val result = + run( + buildList { + add("git") + add("push") + request.expectedRevision?.let { + add("--force-with-lease=refs/heads/${request.branch}:$it") + } + add(request.remote) + add("--delete") + add(request.branch) + }, + repositoryRoot, + ) + if (result is Execution.Failure) return result.result + return PortResult.Success( + RemoveRemoteBranchResponse( + remote = request.remote, + branch = request.branch, + change = + receipt( + "git-remove-remote-branch-${request.remote}-${request.branch}", + "remove-remote-branch", + ), + ) + ) + } + + private fun readRemoteBranches(remote: String): PortResult> { + val result = run(listOf("git", "ls-remote", "--heads", remote), repositoryRoot) + if (result is Execution.Failure) return result.result + val output = (result as Execution.Success).result.stdout + val branches = mutableListOf() + output.lineSequence().filter(String::isNotBlank).forEach { line -> + val fields = line.split('\t', limit = 2) + val revision = fields.getOrNull(0).orEmpty() + val ref = fields.getOrNull(1).orEmpty() + val branch = ref.removePrefix("refs/heads/") + if ( + fields.size != 2 || + !isSafeToken(revision) || + !ref.startsWith("refs/heads/") || + branch.isBlank() || + !isSafeBranch(branch) + ) { + return failure( + code = "REMOTE_BRANCH_LIST_FAILED", + message = "git ls-remote 응답의 원격 Branch 형식이 올바르지 않습니다.", + target = remote, + ) + } + branches += RemoteBranch(remote, branch, revision) + } + return PortResult.Success(branches) + } + + private fun validateRemote(remote: String): PortResult.Failure? = + if (remote.isBlank() || !isSafeToken(remote)) { + failure( + code = "INVALID_ARGUMENT", + message = "원격 저장소 이름이 안전한 토큰이 아닙니다.", + target = remote, + ) + } else { + null + } + + private fun validateBranch(branch: String): PortResult.Failure? = + if (!isSafeBranch(branch)) { + failure( + code = "INVALID_ARGUMENT", + message = "원격 Branch 이름이 안전한 Git ref가 아닙니다.", + target = branch, + ) + } else { + null + } + + /* + * Git start-point가 명령 옵션으로 해석되지 않도록 안전한 revision 토큰인지 확인합니다. + */ + private fun validateRevision(revision: String, label: String): PortResult.Failure? = + if (!isSafeToken(revision)) { + failure( + code = "INVALID_ARGUMENT", + message = "${label}이 안전한 Git revision 토큰이 아닙니다.", + target = revision, + ) + } else { + null + } + + private fun isSafeToken(value: String): Boolean = + value.isNotBlank() && + !value.startsWith("-") && + value.none { it.isWhitespace() || it.isISOControl() } + + private fun isSafeBranch(branch: String): Boolean = + isSafeToken(branch) && + branch != "@" && + !branch.startsWith(".") && + !branch.startsWith("/") && + !branch.endsWith(".") && + !branch.endsWith("/") && + !branch.contains("//") && + !branch.contains("/.") && + !branch.contains("..") && + !branch.contains("@{") && + !branch.contains("\\") && + !branch.any { it in setOf('~', '^', ':', '?', '*', '[') } && + branch.split('/').none { it.endsWith(".lock") } + + override fun removeWorktree(request: RemoveWorktreeRequest): PortResult { + val path = managedWorkspacePath(request.path) + if (managedWorkspaceRoot != null && path == null) { + return invalidWorkspacePath(request.path.value) + } + val result = + run( + listOf("git", "worktree", "remove", path?.toString() ?: request.path.value), + repositoryRoot, + ) + if (result is Execution.Failure) return result.result + return PortResult.Success( + RemoveWorktreeResponse( + workspaceId = request.workspaceId, + path = request.path, + change = receipt("git-remove-worktree-${request.workspaceId}", "remove-worktree"), + ) + ) + } + + /* + * 변경을 한 번 커밋한 뒤 원격 Branch 게시를 설치된 Git CLI에 위임합니다. + */ + override fun publish(request: PublishBranchRequest): PortResult { + validateRemote(request.remote)?.let { + return it + } + validateBranch(request.branch)?.let { + return it + } + request.expectedRevision?.let { + validateRevision(it, "예상 revision")?.let { failure -> + return failure + } + } + val path = resolveWorkspace(request.workspaceId) ?: return workspaceFailure(request.workspaceId) + val before = + when (val result = inspect(GitInspectRequest(request.workspaceId))) { + is PortResult.Failure -> return result + is PortResult.Success -> result.value.status + } + if (request.expectedRevision != null && before.revision != request.expectedRevision) { + return failure( + code = "STALE_REVISION", + message = + "예상 revision과 현재 revision이 다릅니다: expected=${request.expectedRevision}, current=${before.revision}", + retryable = true, + target = request.workspaceId, + ) + } + if (request.expectedFingerprint != null && before.fingerprint != request.expectedFingerprint) { + return failure( + code = "STALE_DIFF_IDENTITY", + message = + "예상 fingerprint와 현재 fingerprint가 다릅니다: expected=${request.expectedFingerprint}, current=${before.fingerprint}", + retryable = true, + target = request.workspaceId, + ) + } + if (before.conflicts.isNotEmpty()) { + return failure( + code = "SYNC_CONFLICT", + message = "게시할 Worktree에 해결되지 않은 충돌이 있습니다.", + target = request.workspaceId, + ) + } + val plannedChange = publishReceipt(request) + + val add = run(listOf("git", "add", "--all"), path) + if (add is Execution.Failure) return add.result.withChange(plannedChange) + val staged = run(listOf("git", "diff", "--cached", "--name-only"), path) + if (staged is Execution.Failure) return staged.result.withChange(plannedChange) + val hasStagedChanges = + (staged as Execution.Success).result.stdout.lineSequence().any { it.isNotBlank() } + if (hasStagedChanges) { + val commit = run(listOf("git", "commit", "-m", request.commitTitle), path) + if (commit is Execution.Failure) return commit.result.withChange(plannedChange) + } + val push = + run( + listOf("git", "push", "--set-upstream", request.remote, request.branch), + path, + ) + if (push is Execution.Failure) return push.result.withChange(plannedChange) + val afterRevision = run(listOf("git", "rev-parse", "HEAD"), path) + if (afterRevision is Execution.Failure) return afterRevision.result.withChange(plannedChange) + val revision = (afterRevision as Execution.Success).result.stdout.trim() + if (revision.isBlank()) { + return PortResult.Failure( + PortError("GIT_PUBLISH_FAILED", "게시 이후 revision이 비어 있습니다.", target = request.workspaceId), + change = plannedChange, + ) + } + val after = + when (val result = inspect(GitInspectRequest(request.workspaceId))) { + is PortResult.Failure -> return result.copy(change = plannedChange) + is PortResult.Success -> result.value.status + } + return PortResult.Success( + PublishBranchResponse( + branch = request.branch, + revision = revision, + fingerprint = after.fingerprint, + committed = hasStagedChanges, + change = + ChangeReceipt( + id = plannedChange.id, + operation = plannedChange.operation, + compensation = plannedChange.compensation, + beforeRevision = before.revision, + afterRevision = revision, + ), + ) + ) + } + + private fun findRevision(request: MainRevisionRequest): String? { + val refs = listOf("refs/remotes/${request.remote}/${request.branch}", "FETCH_HEAD") + refs.forEach { ref -> + val result = run(listOf("git", "rev-parse", ref), repositoryRoot) + if (result is Execution.Success && result.result.stdout.trim().isNotBlank()) { + return result.result.stdout.trim() + } + } + return null + } + + private fun resolveWorkspace(workspaceId: WorkspaceId): Path? = + try { + workspacePath(workspaceId) + } catch (_: Exception) { + null + } + + private fun branchExists(branch: String): Boolean? = + try { + val result = + commandRunner.run( + listOf("git", "show-ref", "--verify", "--quiet", "refs/heads/$branch"), + repositoryRoot, + ) + when (result.exitCode) { + 0 -> true + 1 -> false + else -> null + } + } catch (_: Exception) { + null + } + + private fun workspaceInspectFailure(result: PortResult.Failure): PortResult.Failure = + if ( + result.error.message.contains("not a git repository", ignoreCase = true) || + result.error.message.contains("no such file or directory", ignoreCase = true) + ) { + failure( + code = "WORKSPACE_NOT_FOUND", + message = result.error.message, + target = result.error.target, + ) + } else { + result + } + + private fun restackFailure( + workspaceId: WorkspaceId, + rebase: PortResult.Failure, + path: Path, + ): PortResult.Failure { + val conflicts = unresolvedPaths(path) + val detail = rebase.error.message + return failure( + code = if (conflicts.isEmpty()) "RESTACK_FAILED" else "SYNC_CONFLICT", + message = + buildString { + append("git rebase에 실패했습니다") + if (detail.isNotBlank()) append(": ").append(detail) + if (conflicts.isNotEmpty()) append("; 충돌 파일: ").append(conflicts.joinToString(", ")) + }, + target = workspaceId, + ) + } + + private fun unresolvedPaths(path: Path): List = + run(listOf("git", "diff", "--name-only", "--diff-filter=U"), path).let { result -> + if (result is Execution.Success) result.result.stdout.lines().filter(String::isNotBlank) + else emptyList() + } + + private fun run(command: List, workingDirectory: Path): Execution { + return try { + val result = commandRunner.run(command, workingDirectory) + if (result.exitCode == 0) { + Execution.Success(result) + } else { + Execution.Failure( + failure( + code = commandErrorCode(command), + message = commandMessage(result), + target = workingDirectory.toString(), + ) + ) + } + } catch (failure: Exception) { + Execution.Failure( + failure( + code = commandErrorCode(command), + message = "명령을 실행할 수 없습니다: ${failure.message ?: failure.javaClass.simpleName}", + target = workingDirectory.toString(), + ) + ) + } + } + + private fun commandErrorCode(command: List): String = + when { + command.contains("fetch") -> "MAIN_REVISION_REFRESH_FAILED" + command.contains("push") && command.contains("--delete") -> "REMOTE_BRANCH_REMOVE_FAILED" + command.contains("status") || command.contains("rev-parse") -> "GIT_INSPECT_FAILED" + command.contains("worktree") && command.contains("remove") -> "WORKTREE_REMOVE_FAILED" + command.contains("worktree") -> "WORKTREE_CREATE_FAILED" + command.contains("ls-remote") -> "REMOTE_BRANCH_LIST_FAILED" + command.contains("branch") && command.contains("-D") -> "BRANCH_REMOVE_FAILED" + command.contains("branch") -> "BRANCH_CREATE_FAILED" + command.contains("rebase") -> "RESTACK_FAILED" + else -> "GIT_COMMAND_FAILED" + } + + private fun commandMessage(result: CommandResult): String = + result.stderr.trim().ifBlank { result.stdout.trim() }.ifBlank { "종료 코드 ${result.exitCode}" } + + private fun managedWorkspacePath(path: io.springkit.workflow.domain.WorkspacePath): Path? { + val root = managedWorkspaceRoot ?: return null + return ManagedPathResolver.resolveWithin(root, Path.of(path.value)) + } + + private fun invalidWorkspacePath(path: String): PortResult.Failure = + failure( + code = "WORKSPACE_PATH_INVALID", + message = "Worktree 경로가 관리 root 밖에 있거나 심볼릭 링크로 탈출합니다: $path", + target = path, + ) + + private fun diffCommand(baseRevision: String): List = + listOf("git", "diff", "--binary", "$baseRevision...HEAD") + + private fun fingerprint(revision: String, diff: String, untrackedHashes: String): String { + val digest = MessageDigest.getInstance("SHA-256") + return digest.digest("$revision\u0000$diff\u0000$untrackedHashes".toByteArray()).joinToString( + "" + ) { + "%02x".format(it) + } + } + + private fun receipt(id: String, operation: String): ChangeReceipt = + ChangeReceipt(id = id, operation = operation) + + private fun publishReceipt(request: PublishBranchRequest): ChangeReceipt = + ChangeReceipt( + id = "git-publish-${request.workspaceId}-${request.branch}", + operation = "publish-branch", + compensation = + io.springkit.workflow.application.Compensation( + id = "git-publish-compensation-${request.workspaceId}-${request.branch}", + operation = "compensate-publish-branch", + idempotencyKey = "git-publish:${request.workspaceId}:${request.branch}", + ), + ) + + private fun PortResult.Failure.withChange(change: ChangeReceipt): PortResult.Failure = + copy(change = change) + + private fun workspaceFailure(workspaceId: WorkspaceId): PortResult.Failure = + failure("WORKSPACE_NOT_FOUND", "workspace 경로를 확인할 수 없습니다: $workspaceId", workspaceId) + + private fun failure( + code: String, + message: String, + target: String? = null, + retryable: Boolean = false, + ): PortResult.Failure = PortResult.Failure(PortError(code, message, retryable, target)) + + private sealed interface Execution { + data class Success(val result: CommandResult) : Execution + + data class Failure(val result: PortResult.Failure) : Execution + } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubCheckStatus.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubCheckStatus.kt new file mode 100644 index 00000000..06ff5d08 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubCheckStatus.kt @@ -0,0 +1,24 @@ +package io.springkit.workflow.adapter.github + +import io.springkit.workflow.domain.CiStatus + +/* + * GitHub Check Run 또는 status context의 상태를 Workflow CI 상태로 변환합니다. + */ +internal fun githubCheckStatus( + status: String?, + state: String?, + conclusion: String?, +): CiStatus { + val statusValue = (status ?: state).orEmpty().uppercase() + val conclusionValue = conclusion.orEmpty().uppercase() + return when { + conclusionValue == "SUCCESS" || conclusionValue == "NEUTRAL" || conclusionValue == "SKIPPED" -> + CiStatus.PASSED + conclusionValue.isNotBlank() -> CiStatus.FAILED + statusValue == "SUCCESS" -> CiStatus.PASSED + statusValue == "FAILURE" || statusValue == "ERROR" -> CiStatus.FAILED + statusValue == "IN_PROGRESS" || statusValue == "EXPECTED" -> CiStatus.RUNNING + else -> CiStatus.PENDING + } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubCiAdapter.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubCiAdapter.kt new file mode 100644 index 00000000..a37a6339 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubCiAdapter.kt @@ -0,0 +1,172 @@ +package io.springkit.workflow.adapter.github + +import io.springkit.workflow.application.ChangeReceipt +import io.springkit.workflow.application.CiPort +import io.springkit.workflow.application.GetCiRequest +import io.springkit.workflow.application.GetCiResponse +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.StartCiRequest +import io.springkit.workflow.application.StartCiResponse +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.common.LocalCommandRunner +import io.springkit.workflow.domain.CiRun +import io.springkit.workflow.domain.CiStatus +import java.nio.file.Path +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + +/* + * `gh pr view`의 statusCheckRollup을 Workflow CI 상태로 변환하는 응답입니다. + */ +@Serializable +data class GithubCiPullRequest( + val headRefOid: String? = null, + val statusCheckRollup: List? = null, +) + +@Serializable +data class GithubStatusCheck( + val name: String = "", + val context: String? = null, + val status: String? = null, + val state: String? = null, + val conclusion: String? = null, + val workflowName: String? = null, + val detailsUrl: String? = null, + val databaseId: Long? = null, +) + +/* + * 설치된 `gh` 실행 파일에 pull request CI 조회를 위임하는 [CiPort] 구현입니다. + */ +class GithubCiAdapter( + private val repositoryRoot: Path, + private val commandRunner: CommandRunner = LocalCommandRunner(), +) : CiPort { + override fun start(request: StartCiRequest): PortResult { + val response = get(GetCiRequest(request.pullRequestId, request.revision)) + if (response is PortResult.Failure) return response + val result = response as PortResult.Success + val run = + result.value.runs.firstOrNull() + ?: CiRun( + id = "github-${request.pullRequestId}", + status = result.value.status, + revision = request.revision, + message = result.value.status.messageForEmptyRun(), + ) + return PortResult.Success( + StartCiResponse( + run, + ChangeReceipt("github-ci-start-${request.pullRequestId}", "github-ci-start"), + ) + ) + } + + override fun get(request: GetCiRequest): PortResult { + val command = + listOf( + "gh", + "pr", + "view", + request.pullRequestId, + "--json", + "headRefOid,statusCheckRollup", + ) + val result = + try { + commandRunner.run(command, repositoryRoot) + } catch (failure: Exception) { + return PortResult.Failure( + PortError( + code = "GITHUB_CI_FAILED", + message = "gh 명령을 실행할 수 없습니다: ${failure.message ?: failure::class.simpleName}", + retryable = true, + target = request.pullRequestId, + ) + ) + } + if (result.exitCode != 0) { + return PortResult.Failure( + PortError( + code = "GITHUB_CI_FAILED", + message = + result.stderr + .trim() + .ifBlank { result.stdout.trim() } + .ifBlank { + "gh 명령이 종료 코드 ${result.exitCode}로 실패했습니다." + }, + retryable = result.exitCode == 2, + target = request.pullRequestId, + ) + ) + } + val payload = + try { + json.decodeFromString(result.stdout) + } catch (_: SerializationException) { + return PortResult.Failure( + PortError( + code = "GITHUB_CI_RESPONSE_INVALID", + message = "GitHub CI 응답 JSON을 해석할 수 없습니다.", + target = request.pullRequestId, + ) + ) + } + if (request.revision != null && request.revision != payload.headRefOid) { + return PortResult.Failure( + PortError( + code = "STALE_REVISION", + message = + if (payload.headRefOid == null) { + "GitHub pull request revision을 확인할 수 없습니다." + } else { + "요청한 revision과 GitHub pull request revision이 다릅니다." + }, + target = request.pullRequestId, + ) + ) + } + val runs = + payload.statusCheckRollup.orEmpty().mapIndexed { index, check -> + check.toCiRun(index, payload.headRefOid) + } + return PortResult.Success(GetCiResponse(runs, runs.toCiStatus())) + } + + private companion object { + val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + } +} + +private fun GithubStatusCheck.toCiRun(index: Int, revision: String?): CiRun { + val name = name.ifBlank { context ?: "check-$index" } + val conclusionValue = conclusion.orEmpty().uppercase() + val ciStatus = githubCheckStatus(status, state, conclusion) + return CiRun( + id = databaseId?.toString() ?: "github-check-$index", + status = ciStatus, + revision = revision, + message = + if (ciStatus == CiStatus.FAILED) conclusionValue.ifBlank { "GitHub check failed" } + else null, + ) +} + +private fun List.toCiStatus(): CiStatus = + when { + isEmpty() -> CiStatus.PENDING + any { it.status == CiStatus.FAILED } -> CiStatus.FAILED + any { it.status == CiStatus.RUNNING } -> CiStatus.RUNNING + any { it.status == CiStatus.PENDING } -> CiStatus.PENDING + else -> CiStatus.PASSED + } + +private fun CiStatus.messageForEmptyRun(): String? = + if (this == CiStatus.FAILED) "GitHub CI가 실패했습니다." else null diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubIssueTaskAdapter.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubIssueTaskAdapter.kt new file mode 100644 index 00000000..2211466a --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubIssueTaskAdapter.kt @@ -0,0 +1,439 @@ +package io.springkit.workflow.adapter.github + +import io.springkit.workflow.application.ChangeReceipt +import io.springkit.workflow.application.Compensation +import io.springkit.workflow.application.CreateSubTaskRequest +import io.springkit.workflow.application.CreateSubTaskResponse +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.SubTaskLookupRequest +import io.springkit.workflow.application.SubTaskLookupResponse +import io.springkit.workflow.application.TaskLookupRequest +import io.springkit.workflow.application.TaskLookupResponse +import io.springkit.workflow.application.TaskPort +import io.springkit.workflow.application.UpdateExternalSubTaskRequest +import io.springkit.workflow.application.UpdateExternalSubTaskResponse +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.common.LocalCommandRunner +import io.springkit.workflow.domain.ExternalTaskId +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.Task +import io.springkit.workflow.domain.TaskState +import java.nio.file.Path +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + +/* + * `gh issue` 응답을 Workflow 외부 Task 모델로 변환하는 최소 응답 모델입니다. + */ +@Serializable +data class GithubIssue( + val number: Long = 0, + val title: String = "", + val state: String = "", + val stateReason: String? = null, + val body: String = "", + val url: String? = null, +) + +/* + * 설치된 `gh` CLI로 GitHub Issue를 외부 Task와 SubTask로 연결하는 [TaskPort]입니다. + * + * 외부 Task ID는 GitHub Issue 번호 또는 URL이어야 합니다. SubTask ID와 부모 관계는 Issue 본문의 Workflow metadata marker로 + * 보존합니다. + */ +class GithubIssueTaskAdapter( + private val repositoryRoot: Path, + private val commandRunner: CommandRunner = LocalCommandRunner(), + private val issueLimit: Int = 100, +) : TaskPort { + init { + require(issueLimit > 0) { "issue limit must be positive" } + } + + override fun get(request: TaskLookupRequest): PortResult { + val reference = request.externalId?.value ?: request.taskId + if (reference.isNullOrBlank()) { + return failure("TASK_LOOKUP_INVALID", "Task ID 또는 외부 Task ID가 필요합니다.") + } + val issue = viewIssue(reference) ?: return lastFailure + val task = issue.toTask(reference) + if ( + request.taskId != null && + request.taskId != task.id && + request.taskId != task.externalId.value + ) { + return failure("TASK_NOT_FOUND", "GitHub Issue가 요청한 Task ID와 일치하지 않습니다.", reference) + } + return PortResult.Success(TaskLookupResponse(task)) + } + + override fun getSubTask(request: SubTaskLookupRequest): PortResult { + val issue = findSubTask(request.subTaskId) ?: return lastFailure + val subTask = + issue.toSubTask(request.subTaskId) + ?: return failure( + "GITHUB_TASK_RESPONSE_INVALID", + "GitHub Issue의 SubTask metadata가 올바르지 않습니다.", + request.subTaskId, + ) + return PortResult.Success(SubTaskLookupResponse(subTask)) + } + + override fun createSubTask(request: CreateSubTaskRequest): PortResult { + val taskResult = get(TaskLookupRequest(externalId = request.externalTaskId)) + val task = + when (taskResult) { + is PortResult.Failure -> return taskResult + is PortResult.Success -> taskResult.value.task + } + val existing = findSubTask(request.subTaskId) + if (existing == null && lastFailure.error.code != "SUBTASK_NOT_FOUND") { + return lastFailure + } + if (existing != null) { + val subTask = + existing.toSubTask(request.subTaskId) + ?: return failure( + "GITHUB_TASK_RESPONSE_INVALID", + "GitHub Issue의 SubTask metadata가 올바르지 않습니다.", + request.subTaskId, + ) + if ( + subTask.taskId != task.id || + subTask.title != request.title || + subTask.requires != request.requires + ) { + return failure( + "SUBTASK_ID_CONFLICT", + "이미 다른 메타데이터로 사용 중인 SubTask ID입니다.", + request.subTaskId, + ) + } + return PortResult.Success( + CreateSubTaskResponse( + task = task.withSubTask(subTask.id), + subTask = subTask, + change = existingReceipt(request), + ) + ) + } + + val body = request.metadataBody(task) + val created = + runGh( + listOf("gh", "issue", "create", "--title", request.title, "--body", body), + request.externalTaskId.value, + ) ?: return lastFailure + val reference = created.stdout.trim().lineSequence().lastOrNull()?.trim() + if (reference.isNullOrBlank()) { + return failure( + "GITHUB_TASK_RESPONSE_INVALID", + "GitHub Issue 생성 결과에서 Issue 참조를 찾을 수 없습니다.", + request.subTaskId, + ) + } + val issue = viewIssue(reference) ?: return lastFailure + val subTask = + issue.toSubTask(request.subTaskId) + ?: return failure( + "GITHUB_TASK_RESPONSE_INVALID", + "GitHub Issue 생성 결과의 SubTask metadata가 올바르지 않습니다.", + request.subTaskId, + ) + if ( + subTask.taskId != task.id || + subTask.title != request.title || + subTask.requires != request.requires + ) { + return failure( + "GITHUB_TASK_RESPONSE_INVALID", + "GitHub Issue 생성 결과의 SubTask metadata가 요청과 다릅니다.", + request.subTaskId, + ) + } + return PortResult.Success( + CreateSubTaskResponse( + task = task.withSubTask(subTask.id), + subTask = subTask, + change = + ChangeReceipt( + id = "github-task-create-${request.requestId}", + operation = "github-task-create-subtask", + compensation = + Compensation( + id = "github-task-close-${request.requestId}", + operation = "github-task-close-subtask", + idempotencyKey = request.requestId, + ), + ), + ) + ) + } + + override fun updateSubTask( + request: UpdateExternalSubTaskRequest + ): PortResult { + val issue = findSubTask(request.subTaskId) ?: return lastFailure + val subTask = + issue.toSubTask(request.subTaskId) + ?: return failure( + "GITHUB_TASK_RESPONSE_INVALID", + "GitHub Issue의 SubTask metadata가 올바르지 않습니다.", + request.subTaskId, + ) + val expectedTaskId = + issue.parentTaskId() + ?: return failure( + "GITHUB_TASK_RESPONSE_INVALID", + "GitHub Issue에 부모 Task metadata가 없습니다.", + request.subTaskId, + ) + if (issue.parentExternalTaskId() != request.externalTaskId.value) { + return failure("TASK_MISMATCH", "SubTask가 요청한 외부 Task에 속하지 않습니다.", request.subTaskId) + } + val task = get(TaskLookupRequest(externalId = request.externalTaskId)) + if (task is PortResult.Failure) return task + val taskId = (task as PortResult.Success).value.task.id + if (expectedTaskId != taskId || subTask.taskId != taskId) { + return failure("TASK_MISMATCH", "SubTask가 요청한 외부 Task에 속하지 않습니다.", request.subTaskId) + } + val updatedBody = issue.replaceState(request.state) + runGh( + listOf("gh", "issue", "edit", issue.number.toString(), "--body", updatedBody), + request.subTaskId, + ) ?: return lastFailure + val stateCommand = request.state.issueStateCommand(issue) + if (stateCommand != null && runGh(stateCommand, request.subTaskId) == null) { + return lastFailure + } + return PortResult.Success( + UpdateExternalSubTaskResponse( + externalTaskId = request.externalTaskId, + subTaskId = request.subTaskId, + state = request.state, + change = + ChangeReceipt( + id = "github-task-update-${request.requestId}", + operation = "github-task-update-subtask", + ), + ) + ) + } + + private fun viewIssue(reference: String): GithubIssue? { + val result = + runGh( + listOf("gh", "issue", "view", reference, "--json", ISSUE_FIELDS), + reference, + ) ?: return null + return decode(result.stdout, reference) + } + + private fun findSubTask(subTaskId: String): GithubIssue? { + val result = + runGh( + listOf( + "gh", + "issue", + "list", + "--state", + "all", + "--limit", + issueLimit.toString(), + "--json", + ISSUE_FIELDS, + ), + subTaskId, + ) ?: return null + val issues = + try { + json.decodeFromString>(result.stdout) + } catch (_: SerializationException) { + lastFailure = + PortResult.Failure( + PortError( + "GITHUB_TASK_RESPONSE_INVALID", + "GitHub Issue 목록 응답 JSON을 해석할 수 없습니다.", + target = subTaskId, + ) + ) + return null + } + val issue = issues.firstOrNull { it.subTaskId() == subTaskId } + if (issue == null) { + lastFailure = + PortResult.Failure( + PortError( + "SUBTASK_NOT_FOUND", + "GitHub Issue에서 SubTask를 찾을 수 없습니다.", + target = subTaskId, + ) + ) + } + return issue + } + + private fun decode(output: String, target: String): GithubIssue? = + try { + json.decodeFromString(output) + } catch (_: SerializationException) { + lastFailure = + PortResult.Failure( + PortError( + "GITHUB_TASK_RESPONSE_INVALID", + "GitHub Issue 응답 JSON을 해석할 수 없습니다.", + target = target, + ) + ) + null + } + + private fun runGh(command: List, target: String): CommandResult? { + val result = + try { + commandRunner.run(command, repositoryRoot) + } catch (failure: Exception) { + lastFailure = + PortResult.Failure( + PortError( + "GITHUB_TASK_COMMAND_FAILED", + "gh 명령을 실행할 수 없습니다: ${failure.message ?: failure::class.simpleName}", + retryable = true, + target = target, + ) + ) + return null + } + if (result.exitCode != 0) { + lastFailure = + PortResult.Failure( + PortError( + "GITHUB_TASK_COMMAND_FAILED", + result.stderr + .trim() + .ifBlank { result.stdout.trim() } + .ifBlank { + "gh 명령이 종료 코드 ${result.exitCode}로 실패했습니다." + }, + retryable = result.exitCode == 2, + target = target, + ) + ) + return null + } + return result + } + + private var lastFailure: PortResult.Failure = + PortResult.Failure(PortError("GITHUB_TASK_COMMAND_FAILED", "gh 명령을 실행할 수 없습니다.")) + + private fun failure(code: String, message: String, target: String? = null): PortResult { + val result = PortResult.Failure(PortError(code, message, target = target)) + lastFailure = result + return result + } + + private companion object { + const val ISSUE_FIELDS = "number,title,state,stateReason,body,url" + val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + } +} + +private fun GithubIssue.toTask(reference: String): Task { + val id = number.takeIf { it > 0 }?.toString() ?: reference + return Task( + id = id, + externalId = ExternalTaskId(reference), + title = title.ifBlank { reference }, + state = + when { + state.equals("CLOSED", ignoreCase = true) && + stateReason.equals("NOT_PLANNED", ignoreCase = true) -> TaskState.CANCELLED + state.equals("CLOSED", ignoreCase = true) -> TaskState.COMPLETED + else -> TaskState.OPEN + }, + ) +} + +private fun GithubIssue.toSubTask(requestedId: String): SubTask? { + val parentId = parentTaskId() ?: return null + val subTaskId = subTaskId() + if (subTaskId != requestedId) { + return null + } + val titleValue = title.takeIf(String::isNotBlank) ?: return null + return SubTask( + id = requestedId, + taskId = parentId, + title = titleValue, + state = subTaskState(), + requires = marker("requires"), + ) +} + +private fun GithubIssue.subTaskId(): String? = marker("subtask-id") + +private fun GithubIssue.parentTaskId(): String? = marker("parent-task") + +private fun GithubIssue.parentExternalTaskId(): String? = marker("parent-external-task") + +private fun GithubIssue.subTaskState(): SubTaskState = + marker("subtask-state")?.let { value -> + runCatching { SubTaskState.valueOf(value) }.getOrNull() + } + ?: if (state.equals("CLOSED", ignoreCase = true)) SubTaskState.MERGED + else SubTaskState.DEVELOPMENT + +private fun GithubIssue.marker(name: String): String? = + body + .lineSequence() + .map(String::trim) + .firstOrNull { it.startsWith("") } + ?.removePrefix("") + ?.trim() + ?.takeIf(String::isNotBlank) + +private fun CreateSubTaskRequest.metadataBody(task: Task): String = + buildList { + add("") + add("") + add("") + add("") + requires?.let { add("") } + } + .joinToString("\n") + +private fun GithubIssue.replaceState(state: SubTaskState): String { + val marker = "" + val lines = body.lineSequence().toMutableList() + val index = lines.indexOfFirst { it.trim().startsWith(marker) } + if (index >= 0) lines[index] = replacement else lines.add(replacement) + return lines.joinToString("\n") +} + +private fun SubTaskState.issueStateCommand(issue: GithubIssue): List? = + when { + this == SubTaskState.MERGED && !issue.state.equals("CLOSED", ignoreCase = true) -> + listOf("gh", "issue", "close", issue.number.toString()) + this != SubTaskState.MERGED && issue.state.equals("CLOSED", ignoreCase = true) -> + listOf("gh", "issue", "reopen", issue.number.toString()) + else -> null + } + +private fun Task.withSubTask(subTaskId: String): Task = + if (subTaskId in subTaskIds) this else copy(subTaskIds = subTaskIds + subTaskId) + +private fun GithubIssueTaskAdapter.existingReceipt(request: CreateSubTaskRequest): ChangeReceipt = + ChangeReceipt( + id = "github-task-existing-${request.requestId}", + operation = "github-task-create-subtask", + ) diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubMergeQueueAdapter.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubMergeQueueAdapter.kt new file mode 100644 index 00000000..b75ccb1a --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubMergeQueueAdapter.kt @@ -0,0 +1,573 @@ +package io.springkit.workflow.adapter.github + +import io.springkit.workflow.application.ChangeReceipt +import io.springkit.workflow.application.EnqueueMergeRequest +import io.springkit.workflow.application.EnqueueMergeResponse +import io.springkit.workflow.application.GetMergeQueueRequest +import io.springkit.workflow.application.GetMergeQueueResponse +import io.springkit.workflow.application.MergeQueueMergeRequest +import io.springkit.workflow.application.MergeQueueMergeResponse +import io.springkit.workflow.application.MergeQueuePort +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.common.LocalCommandRunner +import io.springkit.workflow.domain.CiStatus +import io.springkit.workflow.domain.Integration +import io.springkit.workflow.domain.IntegrationState +import io.springkit.workflow.domain.MergeQueueEntry +import io.springkit.workflow.domain.MergeQueueState +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.ValidationStatus +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.nio.file.Path +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + +/* + * GitHub pull request 응답에서 Workflow Merge Queue 항목을 복원합니다. + */ +fun interface GithubMergeQueueEntryResolver { + fun resolve(provider: GithubMergeQueuePullRequest, fallback: MergeQueueEntry): MergeQueueEntry +} + +/* + * `gh pr view`와 `gh pr list`가 반환하는 Merge Queue 관련 정보입니다. + */ +@Serializable +data class GithubMergeQueuePullRequest( + val number: Long = 0, + val state: String = "", + val isDraft: Boolean = false, + val mergeStateStatus: String? = null, + val reviewDecision: String? = null, + val baseRefName: String? = null, + val headRefName: String? = null, + val headRefOid: String? = null, + val statusCheckRollup: List? = null, + val mergedAt: String? = null, + val mergeCommit: GithubMergeQueueCommit? = null, +) + +@Serializable +data class GithubMergeQueueCheck( + val name: String = "", + val context: String? = null, + val status: String? = null, + val state: String? = null, + val conclusion: String? = null, + val databaseId: Long? = null, +) + +@Serializable data class GithubMergeQueueCommit(val oid: String? = null) + +/* + * GitHub active branch rule response의 필요한 부분입니다. + */ +@Serializable data class GithubBranchRule(val type: String = "") + +/* + * `gh` 실행 결과를 [MergeQueuePort] 계약으로 변환하는 GitHub outbound adapter입니다. + * + * [entryLookup]과 [entryPersister]는 프로세스 외부의 Workflow Store에 연결해야 하며, 어댑터는 Merge Queue 항목을 메모리에 보관하지 + * 않습니다. + */ +class GithubMergeQueueAdapter( + private val repositoryRoot: Path, + private val commandRunner: CommandRunner = LocalCommandRunner(), + private val entryResolver: GithubMergeQueueEntryResolver = DefaultGithubMergeQueueEntryResolver, + private val entryLookup: (String) -> MergeQueueEntry? = { null }, + private val entryPersister: (MergeQueueEntry) -> Unit = {}, +) : MergeQueuePort { + override fun enqueue(request: EnqueueMergeRequest): PortResult { + val viewResult = + run( + listOf("gh", "pr", "view", request.pullRequestId, "--json", JSON_FIELDS), + request.pullRequestId, + ) ?: return lastFailure(request.pullRequestId) + val provider = + decode(viewResult.stdout, request.pullRequestId) + ?: return lastFailure(request.pullRequestId) + val providerRevision = provider.headRefOid?.takeIf { it.isNotBlank() } + if (providerRevision == null) { + return PortResult.Failure( + PortError( + code = "GITHUB_MERGE_QUEUE_RESPONSE_INVALID", + message = "GitHub pull request의 queue 시점 revision이 없습니다.", + target = request.pullRequestId, + ), + ChangeReceipt( + id = "github-merge-queue-enqueue-${request.pullRequestId}", + operation = "github-merge-queue-enqueue", + status = io.springkit.workflow.application.ChangeStatus.PENDING, + ), + ) + } + val baseBranch = provider.baseRefName?.takeIf { it.isNotBlank() } + if (baseBranch == null) { + return invalidPullRequestResponse(request.pullRequestId, "base branch") + } + when (val rule = requireMergeQueueRule(baseBranch, request.pullRequestId)) { + is PortResult.Failure -> return rule + is PortResult.Success -> Unit + } + val command = + listOf( + "gh", + "pr", + "merge", + request.pullRequestId, + "--squash", + "--auto", + "--match-head-commit", + providerRevision, + ) + run(command, request.pullRequestId, staleRevision = true) + ?: return lastFailure(request.pullRequestId) + val fallback = + MergeQueueEntry( + id = entryId(request.pullRequestId), + subTaskId = request.subTaskId, + pullRequestId = request.pullRequestId, + changeRevisionId = request.changeRevisionId, + providerRevision = providerRevision, + state = MergeQueueState.QUEUED, + ) + val entry = + resolvedEntry(provider, fallback) + .copy( + state = MergeQueueState.QUEUED, + providerRevision = providerRevision, + ) + entryPersister(entry) + return PortResult.Success( + EnqueueMergeResponse( + entry = entry, + change = + ChangeReceipt( + id = "github-merge-queue-enqueue-${request.pullRequestId}", + operation = "github-merge-queue-enqueue", + ), + ) + ) + } + + override fun get(request: GetMergeQueueRequest): PortResult { + val reference = request.pullRequestId ?: request.subTaskId ?: "all" + val command = + if (request.pullRequestId != null) { + listOf("gh", "pr", "view", reference, "--json", JSON_FIELDS) + } else if (request.subTaskId != null) { + listOf( + "gh", + "pr", + "list", + "--head", + reference, + "--state", + "all", + "--json", + JSON_FIELDS, + ) + } else { + listOf("gh", "pr", "list", "--state", "all", "--json", JSON_FIELDS) + } + val result = run(command, reference) ?: return lastFailure(reference) + return try { + val providers = + if (request.pullRequestId != null) { + listOf(json.decodeFromString(result.stdout)) + } else { + json.decodeFromString>(result.stdout) + } + val entries = + providers + .filter { provider -> + (request.pullRequestId == null || + provider.number.toString() == request.pullRequestId) && + (request.subTaskId == null || provider.headRefName == request.subTaskId) + } + .map { provider -> + val pullRequestId = provider.number.toString() + val fallback = fallbackEntry(provider, request, pullRequestId) + val entry = resolvedEntry(provider, fallback) + entry + } + PortResult.Success(GetMergeQueueResponse(entries)) + } catch (_: SerializationException) { + PortResult.Failure( + PortError( + code = "GITHUB_MERGE_QUEUE_RESPONSE_INVALID", + message = "GitHub Merge Queue 응답 JSON을 해석할 수 없습니다.", + target = reference, + ) + ) + } + } + + override fun merge(request: MergeQueueMergeRequest): PortResult { + val persistedEntry = + entryLookup(request.entryId) ?: entryLookup(pullRequestIdFromEntry(request.entryId)) + val reference = persistedEntry?.pullRequestId ?: pullRequestIdFromEntry(request.entryId) + val beforeView = + run(listOf("gh", "pr", "view", reference, "--json", JSON_FIELDS), reference) + ?: return lastFailure(reference) + val beforeProvider = decode(beforeView.stdout, reference) ?: return lastFailure(reference) + val pullRequestId = beforeProvider.number.takeIf { it > 0 }?.toString() ?: reference + val previous = persistedEntry ?: entryLookup(pullRequestId) + if (previous == null) { + return PortResult.Failure( + PortError( + code = "MERGE_QUEUE_STATE_REQUIRED", + message = "Merge Queue 항목의 영속 상태를 확인할 수 없습니다.", + target = pullRequestId, + ) + ) + } + val headRefName = beforeProvider.headRefName?.takeIf { it.isNotBlank() } + val headRefOid = beforeProvider.headRefOid?.takeIf { it.isNotBlank() } + if (headRefName == null || headRefOid == null) { + return PortResult.Failure( + PortError( + code = "GITHUB_MERGE_QUEUE_RESPONSE_INVALID", + message = "GitHub pull request의 branch 또는 revision이 없습니다.", + target = pullRequestId, + ) + ) + } + val beforeFallback = + previous.copy( + id = request.entryId, + subTaskId = headRefName, + pullRequestId = pullRequestId, + state = beforeProvider.toMergeQueueState(), + validations = beforeProvider.validations(), + ) + val beforeEntry = resolvedEntry(beforeProvider, beforeFallback) + if (beforeEntry.changeRevisionId != request.expectedChangeRevisionId) { + return PortResult.Failure( + PortError( + code = "STALE_REVISION", + message = "요청한 change revision과 GitHub pull request revision이 다릅니다.", + target = pullRequestId, + ) + ) + } + val queuedProviderRevision = previous.providerRevision ?: beforeEntry.providerRevision + if (queuedProviderRevision == null || queuedProviderRevision != headRefOid) { + return PortResult.Failure( + PortError( + code = "STALE_REVISION", + message = "Merge Queue 등록 이후 GitHub pull request revision이 변경되었습니다.", + target = pullRequestId, + ) + ) + } + val baseBranch = beforeProvider.baseRefName?.takeIf { it.isNotBlank() } + if (baseBranch == null) { + return invalidPullRequestResponse(pullRequestId, "base branch") + } + when (val rule = requireMergeQueueRule(baseBranch, pullRequestId)) { + is PortResult.Failure -> return rule + is PortResult.Success -> Unit + } + val mergeResult = + run( + listOf( + "gh", + "pr", + "merge", + pullRequestId, + "--squash", + "--match-head-commit", + headRefOid, + ), + request.entryId, + staleRevision = true, + ) ?: return lastFailure(request.entryId) + val viewResult = + run(listOf("gh", "pr", "view", pullRequestId, "--json", JSON_FIELDS), pullRequestId) + ?: return lastFailure(pullRequestId, mergeResult) + val provider = + decode(viewResult.stdout, pullRequestId) + ?: return PortResult.Failure( + PortError( + code = "GITHUB_MERGE_QUEUE_RESPONSE_INVALID", + message = "GitHub squash merge 응답 JSON을 해석할 수 없습니다.", + target = pullRequestId, + ), + pendingMergeReceipt(request.entryId), + ) + val commit = provider.mergeCommit?.oid?.takeIf { it.isNotBlank() } + if (!provider.state.equals("MERGED", ignoreCase = true) || commit == null) { + return PortResult.Failure( + PortError( + code = "GITHUB_MERGE_QUEUE_NOT_MERGED", + message = "GitHub pull request가 squash merge되지 않았습니다.", + target = pullRequestId, + ), + ChangeReceipt( + id = "github-merge-queue-merge-${request.entryId}", + operation = "github-merge-queue-merge", + status = io.springkit.workflow.application.ChangeStatus.PENDING, + ), + ) + } + val fallback = + beforeEntry.copy( + pullRequestId = pullRequestId, + changeRevisionId = request.expectedChangeRevisionId, + state = MergeQueueState.MERGED, + ) + val entry = resolvedEntry(provider, fallback).copy(state = MergeQueueState.MERGED) + entryPersister(entry) + val integration = + Integration( + subTaskId = entry.subTaskId, + state = IntegrationState.MERGED, + mergeQueue = entry, + mainRevision = commit, + squashCommit = commit, + ) + return PortResult.Success( + MergeQueueMergeResponse( + integration = integration, + change = + ChangeReceipt( + id = "github-merge-queue-merge-${request.entryId}", + operation = "github-merge-queue-merge", + beforeRevision = provider.headRefOid, + afterRevision = commit, + ), + ) + ) + } + + private fun fallbackEntry( + provider: GithubMergeQueuePullRequest, + request: GetMergeQueueRequest, + pullRequestId: String, + ): MergeQueueEntry { + val previous = entryLookup(pullRequestId) + return (previous + ?: MergeQueueEntry( + id = entryId(pullRequestId), + subTaskId = provider.headRefName ?: request.subTaskId ?: "github-$pullRequestId", + pullRequestId = pullRequestId, + changeRevisionId = provider.headRefOid ?: "github-change-$pullRequestId", + state = MergeQueueState.QUEUED, + providerRevision = provider.headRefOid, + )) + .copy( + state = provider.toMergeQueueState(), + validations = provider.validations(), + ) + } + + private fun resolvedEntry( + provider: GithubMergeQueuePullRequest, + fallback: MergeQueueEntry, + ): MergeQueueEntry { + val resolved = entryResolver.resolve(provider, fallback) + return resolved.copy( + providerRevision = + provider.headRefOid?.takeIf { it.isNotBlank() } ?: resolved.providerRevision + ) + } + + private fun run( + command: List, + target: String, + staleRevision: Boolean = false, + ): io.springkit.workflow.common.CommandResult? { + val result = + try { + commandRunner.run(command, repositoryRoot) + } catch (failure: Exception) { + lastFailure = + PortResult.Failure( + PortError( + code = "GITHUB_MERGE_QUEUE_FAILED", + message = + "gh 명령을 실행할 수 없습니다: ${failure.message ?: failure::class.simpleName}", + retryable = true, + target = target, + ) + ) + return null + } + if (result.exitCode == 0) { + lastFailure = null + return result + } + val output = result.stderr.trim().ifBlank { result.stdout.trim() } + val code = + if ( + staleRevision && + output.contains("head", ignoreCase = true) && + (output.contains("match", ignoreCase = true) || + output.contains("stale", ignoreCase = true)) + ) { + "STALE_REVISION" + } else { + "GITHUB_MERGE_QUEUE_FAILED" + } + lastFailure = + PortResult.Failure( + PortError( + code = code, + message = output.ifBlank { "gh 명령이 종료 코드 ${result.exitCode}로 실패했습니다." }, + retryable = result.exitCode == 2, + target = target, + ) + ) + return null + } + + private fun lastFailure( + target: String, + result: io.springkit.workflow.common.CommandResult? = null, + ): PortResult = + lastFailure + ?: PortResult.Failure( + PortError( + code = "GITHUB_MERGE_QUEUE_FAILED", + message = + result?.stderr?.trim().orEmpty().ifBlank { + "gh 명령을 실행할 수 없습니다." + }, + target = target, + ) + ) + + private var lastFailure: PortResult.Failure? = null + + private fun decode(stdout: String, target: String): GithubMergeQueuePullRequest? = + try { + json.decodeFromString(stdout) + } catch (_: SerializationException) { + lastFailure = + PortResult.Failure( + PortError( + code = "GITHUB_MERGE_QUEUE_RESPONSE_INVALID", + message = "GitHub Merge Queue 응답 JSON을 해석할 수 없습니다.", + target = target, + ) + ) + null + } + + private fun requireMergeQueueRule(branch: String, target: String): PortResult { + val encodedBranch = URLEncoder.encode(branch, StandardCharsets.UTF_8).replace("+", "%20") + val result = + run( + listOf( + "gh", + "api", + "--paginate", + "--slurp", + "repos/{owner}/{repo}/rules/branches/$encodedBranch", + ), + target, + ) ?: return lastFailure(target) + val pages = + try { + json.decodeFromString>>(result.stdout) + } catch (_: SerializationException) { + return PortResult.Failure( + PortError( + code = "GITHUB_MERGE_QUEUE_RULE_RESPONSE_INVALID", + message = "GitHub branch rule 응답 JSON을 해석할 수 없습니다.", + target = target, + ) + ) + } + return if (pages.flatten().any { it.type == "merge_queue" }) { + PortResult.Success(Unit) + } else { + PortResult.Failure( + PortError( + code = "GITHUB_MERGE_QUEUE_REQUIRED", + message = "대상 branch에 활성 Merge Queue rule이 없습니다.", + target = target, + ) + ) + } + } + + private fun invalidPullRequestResponse(target: String, field: String): PortResult.Failure = + PortResult.Failure( + PortError( + code = "GITHUB_MERGE_QUEUE_RESPONSE_INVALID", + message = "GitHub pull request의 $field 정보를 확인할 수 없습니다.", + target = target, + ) + ) + + private fun pendingMergeReceipt(entryId: String): ChangeReceipt = + ChangeReceipt( + id = "github-merge-queue-merge-$entryId", + operation = "github-merge-queue-merge", + status = io.springkit.workflow.application.ChangeStatus.PENDING, + ) + + private companion object { + const val JSON_FIELDS = + "number,state,isDraft,mergeStateStatus,reviewDecision,baseRefName,headRefName,headRefOid,statusCheckRollup,mergedAt,mergeCommit" + + val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + + fun entryId(pullRequestId: String): String = "github-merge-queue-$pullRequestId" + + fun number(reference: String): Long = reference.toLongOrNull() ?: 0 + + fun pullRequestIdFromEntry(entryId: String): String = + entryId.removePrefix("github-merge-queue-").ifBlank { entryId } + } +} + +private object DefaultGithubMergeQueueEntryResolver : GithubMergeQueueEntryResolver { + override fun resolve( + provider: GithubMergeQueuePullRequest, + fallback: MergeQueueEntry, + ): MergeQueueEntry = fallback +} + +private fun GithubMergeQueuePullRequest.toMergeQueueState(): MergeQueueState = + when { + state.equals("MERGED", ignoreCase = true) -> MergeQueueState.MERGED + state.equals("CLOSED", ignoreCase = true) -> MergeQueueState.FAILED + statusCheckRollup.orEmpty().any { it.toValidationStatus() == ValidationStatus.FAILED } -> + MergeQueueState.FAILED + mergeStateStatus.equals("CLEAN", ignoreCase = true) -> MergeQueueState.PASSED + else -> MergeQueueState.QUEUED + } + +private fun GithubMergeQueuePullRequest.validations(): List = + statusCheckRollup.orEmpty().mapIndexed { index, check -> + val status = check.toValidationStatus() + Validation( + id = check.databaseId?.toString() ?: "github-validation-$index", + name = check.name.ifBlank { check.context ?: "check-$index" }, + status = status, + revision = headRefOid, + message = if (status == ValidationStatus.FAILED) check.failureMessage() else null, + ) + } + +private fun GithubMergeQueueCheck.toValidationStatus(): ValidationStatus { + return when (githubCheckStatus(status, state, conclusion)) { + CiStatus.PASSED -> ValidationStatus.PASSED + CiStatus.FAILED -> ValidationStatus.FAILED + CiStatus.RUNNING -> ValidationStatus.RUNNING + CiStatus.PENDING -> ValidationStatus.PENDING + } +} + +private fun GithubMergeQueueCheck.failureMessage(): String = + conclusion.orEmpty().ifBlank { state.orEmpty() }.ifBlank { "GitHub 검증이 실패했습니다." } diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubReviewAdapter.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubReviewAdapter.kt new file mode 100644 index 00000000..3ee15b59 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/github/GithubReviewAdapter.kt @@ -0,0 +1,1562 @@ +package io.springkit.workflow.adapter.github + +import io.springkit.workflow.application.AddReviewCommentRequest +import io.springkit.workflow.application.AddReviewCommentResponse +import io.springkit.workflow.application.ApproveReviewRequest +import io.springkit.workflow.application.ApproveReviewResponse +import io.springkit.workflow.application.ChangeReceipt +import io.springkit.workflow.application.GetReviewRequest +import io.springkit.workflow.application.GetReviewResponse +import io.springkit.workflow.application.OpenReviewRequest +import io.springkit.workflow.application.OpenReviewResponse +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.ReadyReviewRequest +import io.springkit.workflow.application.ReadyReviewResponse +import io.springkit.workflow.application.ReplyReviewThreadRequest +import io.springkit.workflow.application.ReplyReviewThreadResponse +import io.springkit.workflow.application.ResolveReviewThreadRequest +import io.springkit.workflow.application.ResolveReviewThreadResponse +import io.springkit.workflow.application.ReviewPort +import io.springkit.workflow.application.UpdateReviewRequest +import io.springkit.workflow.application.UpdateReviewResponse +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.common.LocalCommandRunner +import io.springkit.workflow.domain.AiReviewStatus +import io.springkit.workflow.domain.Approval +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.CiStatus +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.ReviewLevel +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.ReviewThread +import java.nio.file.Path +import java.time.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.decodeFromJsonElement + +/* + * GitHub pull request 응답에서 Workflow 도메인 객체를 복원합니다. + */ +fun interface GithubPullRequestResolver { + fun resolve(provider: GithubPullRequest, previous: PullRequest?): PullRequest +} + +/* + * `gh pr view`가 반환하는 Workflow에 필요한 pull request 정보입니다. + */ +@Serializable +data class GithubPullRequest( + val number: Long = 0, + val title: String = "", + val body: String = "", + val state: String = "", + val isDraft: Boolean = false, + val baseRefName: String = "", + val headRefName: String = "", + val headRefOid: String? = null, + val reviewDecision: String? = null, + val author: GithubUser? = null, + val comments: List = emptyList(), + val reviews: List = emptyList(), + val reviewThreads: List = emptyList(), + val reviewThreadsLoaded: Boolean = false, + val commentsLoaded: Boolean = false, +) + +@Serializable data class GithubUser(val login: String = "", val name: String? = null) + +@Serializable +data class GithubComment( + val id: String = "", + val databaseId: Long? = null, + @SerialName("node_id") val nodeId: String? = null, + val body: String = "", + val author: GithubUser? = null, + val authorAssociation: String? = null, + val createdAt: String? = null, + val path: String? = null, + val line: Int? = null, +) + +@Serializable +data class GithubReviewThread( + val id: String = "", + val isResolved: Boolean = false, + val comments: List = emptyList(), +) + +@Serializable +private data class GithubCreatedReviewComment( + val id: Long? = null, + @SerialName("node_id") val nodeId: String? = null, +) + +@Serializable +private data class GithubCreatedIssueComment( + val id: Long? = null, + @SerialName("node_id") val nodeId: String? = null, +) + +private data class GithubCreatedCommentIdentifier(val id: Long?, val nodeId: String?) + +@Serializable +private data class GithubReviewThreadsResponse( + val data: GithubReviewThreadsData? = null, + val errors: List = emptyList(), +) + +@Serializable private data class GithubReviewThreadsData(val repository: GithubRepository? = null) + +@Serializable +private data class GithubRepository(val pullRequest: GithubReviewThreadsPullRequest? = null) + +@Serializable +private data class GithubReviewThreadsPullRequest( + val reviewThreads: GithubReviewThreadConnection? = null, +) + +@Serializable +private data class GithubReviewThreadConnection( + val nodes: List = emptyList(), + val pageInfo: GithubPageInfo = GithubPageInfo(), +) + +@Serializable +private data class GithubPageInfo( + val hasNextPage: Boolean = false, + val endCursor: String? = null, +) + +@Serializable private data class GithubGraphQlError(val message: String = "") + +@Serializable +private data class GithubGraphQlReviewThread( + val id: String = "", + val isResolved: Boolean = false, + val comments: GithubGraphQlCommentConnection = GithubGraphQlCommentConnection(), +) + +@Serializable +private data class GithubGraphQlCommentConnection( + val nodes: List = emptyList(), +) + +@Serializable +data class GithubReview( + val id: String = "", + val body: String = "", + val state: String = "", + val author: GithubUser? = null, + val submittedAt: String? = null, +) + +/* + * 설치된 `gh` 실행 파일에 pull request와 Review 동작을 위임하는 [ReviewPort] 구현입니다. + */ +class GithubReviewAdapter( + private val repositoryRoot: Path, + private val commandRunner: CommandRunner = LocalCommandRunner(), + private val pullRequestResolver: GithubPullRequestResolver? = null, + private val currentPullRequest: (String) -> PullRequest? = { null }, + private val nowEpochMillis: () -> Long = { System.currentTimeMillis() }, + private val humanActorIds: Set = configuredHumanActorIds(), +) : ReviewPort { + private val effectivePullRequestResolver: GithubPullRequestResolver = + pullRequestResolver ?: DefaultGithubPullRequestResolver(humanActorIds) + + /* + * PR을 변경하기 전에 현재 gh 인증 사용자와 작성자를 비교합니다. 인증 설정은 변경하지 않습니다. + */ + fun authorizeAccountMismatch( + pullRequestId: String, + allowAccountMismatch: Boolean = false, + ): PortResult { + val authenticated = + execute(listOf("gh", "api", "user", "--jq", ".login"), pullRequestId) + ?: return failure( + "GITHUB_ACCOUNT_UNAVAILABLE", + "현재 gh 로그인 사용자를 확인할 수 없습니다.", + pullRequestId, + ) + val currentLogin = authenticated.stdout.trim() + if (currentLogin.isBlank()) { + return failure( + "GITHUB_ACCOUNT_UNAVAILABLE", + "현재 gh 로그인 사용자를 확인할 수 없습니다.", + pullRequestId, + ) + } + val authorResponse = + execute( + listOf("gh", "pr", "view", pullRequestId, "--json", "author"), + pullRequestId, + ) + ?: return failure( + "GITHUB_PULL_REQUEST_AUTHOR_UNAVAILABLE", + "GitHub pull request 작성자를 확인할 수 없습니다.", + pullRequestId, + ) + val authorLogin = + try { + json.decodeFromString(authorResponse.stdout).author?.login + } catch (_: SerializationException) { + null + } + ?.takeIf(String::isNotBlank) + ?: return failure( + "GITHUB_PULL_REQUEST_AUTHOR_UNAVAILABLE", + "GitHub pull request 작성자를 확인할 수 없습니다.", + pullRequestId, + ) + if (currentLogin.equals(authorLogin, ignoreCase = true)) { + return PortResult.Success(null) + } + if (allowAccountMismatch) { + return PortResult.Success( + "현재 gh 사용자 '$currentLogin'와 PR 작성자 '$authorLogin'가 다릅니다. " + + "--allow-account-mismatch로 해당 불일치를 허용해 PR 변경을 계속합니다." + ) + } + return PortResult.Failure( + PortError( + code = "GITHUB_ACCOUNT_MISMATCH", + message = + "현재 gh 사용자 '$currentLogin'와 PR 작성자 '$authorLogin'가 다릅니다. " + + "PR 변경을 중단했습니다. 계속하려면 --allow-account-mismatch를 지정하세요.", + target = pullRequestId, + ), + ) + } + + override fun open(request: OpenReviewRequest): PortResult { + val created = + execute( + listOf( + "gh", + "pr", + "create", + "--draft", + "--title", + request.title, + "--body", + request.body, + "--base", + request.base, + "--head", + request.branch, + ), + request.subTaskId, + ) + ?: return failure( + "GITHUB_CREATE_FAILED", + "GitHub pull request를 생성할 수 없습니다.", + request.subTaskId, + ) + val reference = + pullRequestReference(created.stdout) + ?: return failure( + "GITHUB_RESPONSE_INVALID", + "pull request 생성 결과에 번호가 없습니다.", + request.subTaskId, + ) + val provider = + fetch(reference) + ?: return failure("GITHUB_RESPONSE_INVALID", "생성된 pull request를 조회할 수 없습니다.", reference) + val pullRequest = + effectivePullRequestResolver.resolve(provider, null).let { + it.copy( + id = provider.number.toString(), + subTaskId = request.subTaskId, + title = request.title, + body = request.body, + base = request.base, + state = PullRequestState.DRAFT, + risk = request.risk, + reviewRevision = request.reviewRevision, + changeRevision = + request.changeRevision.copy( + providerRevision = + provider.headRefOid ?: request.changeRevision.providerRevision + ), + ) + } + return PortResult.Success( + OpenReviewResponse(pullRequest, receipt("open", pullRequest.id)), + ) + } + + override fun get(request: GetReviewRequest): PortResult { + val provider = + fetch(request.pullRequestId, includeReviewThreads = true) + ?: return failure( + "GITHUB_GET_FAILED", + "GitHub pull request를 조회할 수 없습니다.", + request.pullRequestId, + ) + return PortResult.Success(GetReviewResponse(resolve(provider))) + } + + override fun update(request: UpdateReviewRequest): PortResult { + val persisted = currentPullRequest(request.pullRequestId) + if (persisted != null && persisted.reviewRevision.id != request.expectedReviewRevisionId) { + return failure( + "STALE_REVISION", + "요청한 review revision이 현재 상태와 다릅니다.", + request.pullRequestId, + ) + } + if (request.body != null || request.base != null) { + val command = buildList { + addAll(listOf("gh", "pr", "edit", request.pullRequestId)) + request.body?.let { + add("--body") + add(it) + } + request.base?.let { + add("--base") + add(it) + } + } + if ( + execute( + command, + request.pullRequestId, + ) == null + ) { + return failure( + "GITHUB_UPDATE_FAILED", + "GitHub pull request를 갱신할 수 없습니다.", + request.pullRequestId, + ) + } + } + val provider = + fetch(request.pullRequestId) + ?: return failure( + "GITHUB_UPDATE_FAILED", + "갱신된 GitHub pull request를 조회할 수 없습니다.", + request.pullRequestId, + ) + val pullRequest = + resolve(provider).let { current -> + current.copy( + body = request.body ?: current.body, + base = request.base ?: current.base, + reviewRevision = request.reviewRevision ?: current.reviewRevision, + changeRevision = + request.changeRevision?.copy( + providerRevision = + provider.headRefOid ?: request.changeRevision.providerRevision + ) ?: current.changeRevision, + ) + } + return PortResult.Success( + UpdateReviewResponse( + pullRequest = pullRequest, + codeChanged = request.changeRevision != null, + change = receipt("update", request.pullRequestId), + ) + ) + } + + override fun comment(request: AddReviewCommentRequest): PortResult { + val persisted = currentPullRequest(request.pullRequestId) + if (persisted != null) { + if (persisted.reviewRevision.id != request.reviewRevisionId) { + return failure( + "STALE_REVISION", + "요청한 review revision이 현재 상태와 다릅니다.", + request.pullRequestId, + ) + } + if ( + persisted.reviewRevision.threads.any { thread -> + thread.comments.any { comment -> comment.id == request.comment.id } + } + ) { + return failure( + "REVIEW_COMMENT_CONFLICT", + "같은 review comment ID가 이미 존재합니다.", + request.comment.id, + ) + } + } + val body = request.comment.body.withReviewLevel(request.level) + val command = + if (request.comment.path == null) { + listOf( + "gh", + "api", + "--method", + "POST", + "repos/{owner}/{repo}/issues/${request.pullRequestId}/comments", + "-f", + "body=$body", + ) + } else { + val current = + currentPullRequest(request.pullRequestId) + ?: return failure( + "GITHUB_REVIEW_STATE_REQUIRED", + "코드 줄 코멘트에 필요한 현재 변경 revision이 없습니다.", + request.pullRequestId, + ) + val providerRevision = + current.changeRevision.providerRevision + ?: return failure( + "GITHUB_REVIEW_REVISION_UNAVAILABLE", + "코드 줄 코멘트에 필요한 GitHub head SHA가 없습니다.", + request.pullRequestId, + ) + listOf( + "gh", + "api", + "--method", + "POST", + "repos/{owner}/{repo}/pulls/${request.pullRequestId}/comments", + "-f", + "body=$body", + "-f", + "commit_id=$providerRevision", + "-f", + "path=${request.comment.path}", + "-F", + "line=${request.comment.line}", + "-f", + "side=RIGHT", + ) + } + val created = + execute(command, request.pullRequestId) + ?: return failure( + "GITHUB_COMMENT_FAILED", + "GitHub pull request에 코멘트를 추가할 수 없습니다.", + request.pullRequestId, + ) + val createdComment = + try { + if (request.comment.path == null) { + json.decodeFromString(created.stdout).let { + GithubCreatedCommentIdentifier(it.id, it.nodeId) + } + } else { + json.decodeFromString(created.stdout).let { + GithubCreatedCommentIdentifier(it.id, it.nodeId) + } + } + } catch (_: SerializationException) { + return failure( + "GITHUB_RESPONSE_INVALID", + "GitHub 코멘트 생성 결과를 해석할 수 없습니다.", + request.pullRequestId, + ) + } + if (createdComment.id == null && createdComment.nodeId.isNullOrBlank()) { + return failure( + "GITHUB_RESPONSE_INVALID", + "GitHub 코멘트 생성 결과에 provider 식별자가 없습니다.", + request.pullRequestId, + ) + } + val provider = + fetch(request.pullRequestId, includeReviewThreads = true) + ?: return failure( + "GITHUB_COMMENT_FAILED", + "추가된 GitHub 코멘트를 조회할 수 없습니다.", + request.pullRequestId, + ) + if (request.comment.path == null) { + val issueComment = + provider.comments.firstOrNull { comment -> + (createdComment.id != null && + (comment.databaseId == createdComment.id || + comment.id == createdComment.id.toString())) || + (!createdComment.nodeId.isNullOrBlank() && comment.id == createdComment.nodeId) + } + ?: return failure( + "GITHUB_ISSUE_COMMENT_NOT_FOUND", + "추가된 GitHub 일반 코멘트를 조회할 수 없습니다.", + request.pullRequestId, + ) + val pullRequest = resolve(provider) + val remoteThread = + pullRequest.reviewRevision.threads.firstOrNull { + it.id == issueCommentThreadId(issueComment) + } + ?: return failure( + "GITHUB_ISSUE_COMMENT_NOT_FOUND", + "GitHub 일반 코멘트를 Workflow 상태로 변환할 수 없습니다.", + request.pullRequestId, + ) + val revision = nextReviewRevision(pullRequest, pullRequest.reviewRevision.threads) + return PortResult.Success( + AddReviewCommentResponse( + revision, + remoteThread.id, + receipt("comment", request.pullRequestId), + ), + ) + } + val providerThread = + provider.reviewThreads.firstOrNull { thread -> + thread.comments.any { comment -> + (createdComment.id != null && + (comment.databaseId == createdComment.id || + comment.id == createdComment.id.toString())) || + (!createdComment.nodeId.isNullOrBlank() && comment.id == createdComment.nodeId) + } + } + ?: return failure( + "GITHUB_REVIEW_THREAD_NOT_FOUND", + "추가된 GitHub 코드 줄 코멘트의 review thread를 조회할 수 없습니다.", + request.pullRequestId, + ) + val pullRequest = resolve(provider) + val remoteThread = + pullRequest.reviewRevision.threads.firstOrNull { it.id == providerThread.id } + ?: return failure( + "GITHUB_REVIEW_THREAD_NOT_FOUND", + "GitHub review thread를 Workflow 상태로 변환할 수 없습니다.", + providerThread.id, + ) + val revision = nextReviewRevision(pullRequest, pullRequest.reviewRevision.threads) + return PortResult.Success( + AddReviewCommentResponse( + revision, + remoteThread.id, + receipt("comment", request.pullRequestId), + ), + ) + } + + override fun reply(request: ReplyReviewThreadRequest): PortResult { + val persisted = currentPullRequest(request.pullRequestId) + val currentThread = + persisted?.reviewRevision?.threads?.firstOrNull { thread -> + thread.id == request.threadId + } + if (persisted == null || currentThread == null) { + return failure( + "GITHUB_REVIEW_STATE_REQUIRED", + "review thread의 영속 상태를 확인할 수 없습니다.", + request.threadId, + ) + } + if (persisted.reviewRevision.id != request.reviewRevisionId) { + return failure( + "STALE_REVISION", + "요청한 review revision이 현재 상태와 다릅니다.", + request.pullRequestId, + ) + } + if (isGithubIssueCommentThreadId(currentThread.id)) { + val provider = + fetch(request.pullRequestId, includeReviewThreads = true) + ?: return failure( + "GITHUB_REPLY_FAILED", + "답변할 GitHub 일반 코멘트를 조회할 수 없습니다.", + request.threadId, + ) + val rootComment = + provider.comments.firstOrNull { comment -> + issueCommentThreadId(comment) == currentThread.id && !comment.isWorkflowReply() + } + ?: return failure( + "GITHUB_ISSUE_COMMENT_NOT_FOUND", + "답변할 GitHub 일반 코멘트를 찾을 수 없습니다.", + request.threadId, + ) + val rootCommentId = issueCommentRootReference(rootComment) + val markedComment = + request.comment.copy( + body = + request.comment.body + .withReviewLevel(currentThread.level) + .withIssueCommentRoot(rootCommentId) + .withIssueCommentReplyId(request.comment.id), + ) + val previousReply = + provider.comments.firstOrNull { it.issueCommentReplyId() == request.comment.id } + if (previousReply != null && previousReply.issueCommentRootId() != rootCommentId) { + return failure( + "GITHUB_REPLY_IDEMPOTENCY_CONFLICT", + "같은 답글 식별자가 다른 GitHub 코멘트에서 사용되었습니다.", + request.comment.id, + ) + } + if (previousReply != null && previousReply.body != markedComment.body) { + return failure( + "GITHUB_REPLY_IDEMPOTENCY_CONFLICT", + "같은 답글 식별자에 다른 본문이 이미 게시되었습니다.", + request.comment.id, + ) + } + if ( + previousReply == null && + execute( + listOf( + "gh", + "api", + "--method", + "POST", + "repos/{owner}/{repo}/issues/${request.pullRequestId}/comments", + "-f", + "body=${markedComment.body}", + ), + request.pullRequestId, + ) == null + ) { + return failure("GITHUB_REPLY_FAILED", "GitHub 일반 코멘트에 답변할 수 없습니다.", request.threadId) + } + val updatedProvider = + (if (previousReply != null) provider + else fetch(request.pullRequestId, includeReviewThreads = true)) + ?: return failure( + "GITHUB_REPLY_FAILED", + "답변된 GitHub 일반 코멘트를 조회할 수 없습니다.", + request.threadId, + ) + val pullRequest = resolve(updatedProvider) + val remoteThread = + pullRequest.reviewRevision.threads.firstOrNull { it.id == currentThread.id } + ?: return failure( + "GITHUB_REPLY_FAILED", + "답변된 GitHub 일반 코멘트를 조회할 수 없습니다.", + request.threadId, + ) + if (remoteThread.comments.none { it.body == markedComment.body.withoutWorkflowMarkers() }) { + return failure( + "GITHUB_REPLY_FAILED", + "GitHub 일반 코멘트에 추가된 답변을 조회할 수 없습니다.", + request.threadId, + ) + } + val revision = nextReviewRevision(pullRequest, pullRequest.reviewRevision.threads) + return PortResult.Success( + ReplyReviewThreadResponse(revision, receipt("reply", request.threadId)), + ) + } + if (!isGithubReviewThreadId(currentThread.id)) { + return failure( + "GITHUB_GENERAL_COMMENT_UNSUPPORTED", + "일반 pull request 코멘트에는 review thread 답변을 적용할 수 없습니다.", + request.threadId, + ) + } + val markedComment = + request.comment.copy( + body = + request.comment.body + .withReviewLevel(currentThread.level) + .withReviewThreadReplyId(request.comment.id), + ) + val query = + "mutation(${'$'}subjectId:ID!,${'$'}body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:${'$'}subjectId,body:${'$'}body}){comment{id}}}" + if ( + execute( + listOf( + "gh", + "api", + "graphql", + "-f", + "query=$query", + "-f", + "subjectId=${currentThread.id}", + "-f", + "body=${markedComment.body}", + ), + request.pullRequestId, + ) == null + ) { + return failure("GITHUB_REPLY_FAILED", "GitHub review thread에 답변할 수 없습니다.", request.threadId) + } + val provider = + fetch(request.pullRequestId, includeReviewThreads = true) + ?: return failure( + "GITHUB_REPLY_FAILED", + "답변된 GitHub review thread를 조회할 수 없습니다.", + request.threadId, + ) + val pullRequest = resolve(provider) + val remoteThread = + pullRequest.reviewRevision.threads.firstOrNull { it.id == currentThread.id } + ?: return failure( + "GITHUB_REPLY_FAILED", + "답변된 GitHub review thread를 조회할 수 없습니다.", + request.threadId, + ) + if (remoteThread.comments.none { it.body == markedComment.body.withoutWorkflowMarkers() }) { + return failure( + "GITHUB_REPLY_FAILED", + "GitHub review thread에 추가된 답변을 조회할 수 없습니다.", + request.threadId, + ) + } + val revision = nextReviewRevision(pullRequest, pullRequest.reviewRevision.threads) + return PortResult.Success( + ReplyReviewThreadResponse(revision, receipt("reply", request.threadId)), + ) + } + + override fun recoverReply( + request: ReplyReviewThreadRequest + ): PortResult { + val persisted = currentPullRequest(request.pullRequestId) + if (persisted == null || persisted.reviewRevision.id != request.reviewRevisionId) { + return PortResult.Success(null) + } + val baselineThread = + persisted.reviewRevision.threads.firstOrNull { it.id == request.threadId } + ?: return PortResult.Success(null) + if (!baselineThread.isOpen) return PortResult.Success(null) + val provider = + fetch(request.pullRequestId, includeReviewThreads = true) + ?: return failure( + "GITHUB_REPLY_FAILED", + "답글 재시도 상태를 GitHub에서 확인할 수 없습니다.", + request.threadId, + ) + val candidates = + if (isGithubIssueCommentThreadId(request.threadId)) { + provider.comments.filter { it.issueCommentReplyId() == request.comment.id } + } else { + provider.reviewThreads + .flatMap { thread -> + thread.comments + .filter { it.reviewThreadReplyId() == request.comment.id } + .map { comment -> thread to comment } + } + .map { it.second } + } + if (candidates.isEmpty()) return PortResult.Success(null) + if (candidates.size != 1) { + return failure( + "GITHUB_REPLY_IDEMPOTENCY_CONFLICT", + "같은 답글 식별자가 GitHub에서 여러 번 사용되었습니다.", + request.comment.id, + ) + } + val candidate = candidates.single() + val expectedBody = + if (isGithubIssueCommentThreadId(request.threadId)) { + val root = + provider.comments.firstOrNull { + issueCommentThreadId(it) == request.threadId && !it.isWorkflowReply() + } ?: return PortResult.Success(null) + request.comment.body + .withReviewLevel(baselineThread.level) + .withIssueCommentRoot(issueCommentRootReference(root)) + .withIssueCommentReplyId(request.comment.id) + } else { + request.comment.body + .withReviewLevel(baselineThread.level) + .withReviewThreadReplyId(request.comment.id) + } + if (candidate.body != expectedBody) { + return failure( + "GITHUB_REPLY_IDEMPOTENCY_CONFLICT", + "같은 답글 식별자에 다른 본문이 이미 게시되었습니다.", + request.comment.id, + ) + } + val providerThreadId = + if (isGithubIssueCommentThreadId(request.threadId)) { + issueCommentThreadId( + provider.comments.firstOrNull { + issueCommentThreadId(it) == request.threadId && !it.isWorkflowReply() + } ?: return PortResult.Success(null) + ) + } else { + provider.reviewThreads.firstOrNull { thread -> candidate in thread.comments }?.id + } + if (providerThreadId != request.threadId) { + return failure( + "GITHUB_REPLY_IDEMPOTENCY_CONFLICT", + "같은 답글 식별자가 다른 GitHub 스레드에서 사용되었습니다.", + request.comment.id, + ) + } + val remote = resolve(provider) + val remoteThread = remote.reviewRevision.threads.firstOrNull { it.id == request.threadId } + val candidateDomainId = candidate.id.ifBlank { candidate.nodeId.orEmpty() } + val baseThreads = + remote.reviewRevision.threads.map { thread -> + if (thread.id == request.threadId) { + thread.copy(comments = thread.comments.filterNot { it.id == candidateDomainId }) + } else { + thread + } + } + val pureReplyDelta = + remoteThread != null && + remoteThread.isOpen && + remote.body == persisted.body && + remote.base == persisted.base && + remote.state == persisted.state && + remote.changeRevision == persisted.changeRevision && + remote.reviewRevision.body == persisted.reviewRevision.body && + remote.reviewRevision.number == persisted.reviewRevision.number + 1 && + remoteThread.comments.count { it.id == candidateDomainId } == 1 && + baseThreads == persisted.reviewRevision.threads + if (!pureReplyDelta) return PortResult.Success(null) + return PortResult.Success( + ReplyReviewThreadResponse(remote.reviewRevision, receipt("reply", request.threadId)), + ) + } + + override fun resolve( + request: ResolveReviewThreadRequest + ): PortResult { + val current = currentPullRequest(request.pullRequestId) + val currentThread = current?.reviewRevision?.threads?.firstOrNull { it.id == request.threadId } + if (currentThread == null) { + return failure( + "GITHUB_REVIEW_STATE_REQUIRED", + "review thread의 영속 상태를 확인할 수 없습니다.", + request.threadId, + ) + } + if (current.reviewRevision.id != request.reviewRevisionId) { + return failure( + "STALE_REVISION", + "요청한 review revision이 현재 상태와 다릅니다.", + request.pullRequestId, + ) + } + if (isGithubIssueCommentThreadId(currentThread.id)) { + if (currentThread.requiresHumanResolution && !request.actor.isHuman) { + return failure( + "HUMAN_REQUIRED", + "사람이 작성한 review thread는 사람만 해결할 수 있습니다.", + request.threadId, + ) + } + val provider = + fetch(request.pullRequestId) + ?: return failure( + "GITHUB_RESOLVE_FAILED", + "해결할 GitHub 일반 코멘트를 조회할 수 없습니다.", + request.threadId, + ) + val rootComment = + provider.comments.firstOrNull { comment -> + issueCommentThreadId(comment) == currentThread.id && !comment.isWorkflowReply() + } + ?: return failure( + "GITHUB_ISSUE_COMMENT_NOT_FOUND", + "해결할 GitHub 일반 코멘트를 찾을 수 없습니다.", + request.threadId, + ) + val updatedBody = rootComment.body.withResolvedIssueCommentMarker() + val graphQlId = + rootComment.nodeId?.takeIf { it.isNotBlank() } + ?: rootComment.id.takeIf { it.isNotBlank() && !it.all(Char::isDigit) } + val command = + if (graphQlId != null) { + val query = + "mutation(${'$'}issueCommentId:ID!,${'$'}body:String!){updateIssueComment(input:{id:${'$'}issueCommentId,body:${'$'}body}){issueComment{id}}}" + listOf( + "gh", + "api", + "graphql", + "-f", + "query=$query", + "-f", + "issueCommentId=$graphQlId", + "-f", + "body=$updatedBody", + ) + } else { + val databaseId = + rootComment.databaseId + ?: return failure( + "GITHUB_ISSUE_COMMENT_NOT_FOUND", + "GitHub 일반 코멘트에 수정할 수 있는 식별자가 없습니다.", + request.threadId, + ) + listOf( + "gh", + "api", + "--method", + "PATCH", + "repos/{owner}/{repo}/issues/comments/$databaseId", + "-f", + "body=$updatedBody", + ) + } + if ( + execute( + command, + request.pullRequestId, + ) == null + ) { + return failure("GITHUB_RESOLVE_FAILED", "GitHub 일반 코멘트를 해결할 수 없습니다.", request.threadId) + } + val updatedProvider = + fetch(request.pullRequestId, includeReviewThreads = true) + ?: return failure( + "GITHUB_RESOLVE_FAILED", + "해결된 GitHub 일반 코멘트를 조회할 수 없습니다.", + request.threadId, + ) + val pullRequest = resolve(updatedProvider) + val resolvedThread = + pullRequest.reviewRevision.threads.firstOrNull { it.id == request.threadId } + if (resolvedThread?.state != io.springkit.workflow.domain.ThreadState.RESOLVED) { + return failure( + "GITHUB_RESOLVE_FAILED", + "GitHub 일반 코멘트의 해결 상태를 복원할 수 없습니다.", + request.threadId, + ) + } + val revision = nextReviewRevision(pullRequest, pullRequest.reviewRevision.threads) + return PortResult.Success( + ResolveReviewThreadResponse(revision, receipt("resolve", request.threadId)), + ) + } + if (!isGithubReviewThreadId(currentThread.id)) { + return failure( + "GITHUB_GENERAL_COMMENT_UNSUPPORTED", + "일반 pull request 코멘트는 review thread로 해결할 수 없습니다.", + request.threadId, + ) + } + if (currentThread.requiresHumanResolution && !request.actor.isHuman) { + return failure( + "HUMAN_REQUIRED", + "사람이 작성한 review thread는 사람만 해결할 수 있습니다.", + request.threadId, + ) + } + val query = + "mutation(${'$'}threadId:ID!){resolveReviewThread(input:{threadId:${'$'}threadId}){thread{id}}}" + if ( + execute( + listOf( + "gh", + "api", + "graphql", + "-f", + "query=$query", + "-f", + "threadId=${currentThread.id}", + ), + request.pullRequestId, + ) == null + ) { + return failure("GITHUB_RESOLVE_FAILED", "GitHub review thread를 해결할 수 없습니다.", request.threadId) + } + val provider = + fetch(request.pullRequestId, includeReviewThreads = true) + ?: return failure( + "GITHUB_RESOLVE_FAILED", + "해결된 GitHub review thread를 조회할 수 없습니다.", + request.threadId, + ) + val pullRequest = resolve(provider) + val revision = + nextReviewRevision( + pullRequest, + pullRequest.reviewRevision.threads.map { thread -> + if (thread.id == request.threadId) thread.resolve(request.actor) else thread + }, + ) + return PortResult.Success( + ResolveReviewThreadResponse(revision, receipt("resolve", request.threadId)), + ) + } + + override fun ready(request: ReadyReviewRequest): PortResult { + if (!request.actor.isHuman) { + return failure( + "HUMAN_REQUIRED", + "pull request Ready 전환은 사람만 수행할 수 있습니다.", + request.pullRequestId, + ) + } + if ( + execute(listOf("gh", "pr", "ready", request.pullRequestId), request.pullRequestId) == null + ) { + return failure( + "GITHUB_READY_FAILED", + "GitHub pull request를 Ready 상태로 전환할 수 없습니다.", + request.pullRequestId, + ) + } + val provider = + fetch(request.pullRequestId) + ?: return failure( + "GITHUB_READY_FAILED", + "Ready 상태의 GitHub pull request를 조회할 수 없습니다.", + request.pullRequestId, + ) + val pullRequest = resolve(provider).copy(state = PullRequestState.READY) + return PortResult.Success( + ReadyReviewResponse(pullRequest, receipt("ready", request.pullRequestId)) + ) + } + + override fun approve(request: ApproveReviewRequest): PortResult { + if (!request.actor.isHuman) { + return failure("HUMAN_REQUIRED", "pull request 승인은 사람만 수행할 수 있습니다.", request.pullRequestId) + } + if ( + execute( + listOf("gh", "pr", "review", request.pullRequestId, "--approve"), + request.pullRequestId, + ) == null + ) { + return failure( + "GITHUB_APPROVE_FAILED", + "GitHub pull request를 승인할 수 없습니다.", + request.pullRequestId, + ) + } + val provider = + fetch(request.pullRequestId) + ?: return failure( + "GITHUB_APPROVE_FAILED", + "승인된 GitHub pull request를 조회할 수 없습니다.", + request.pullRequestId, + ) + val pullRequest = resolve(provider) + if ( + pullRequest.changeRevision.id != request.changeRevisionId || + pullRequest.changeRevision.diff.identity != request.diffIdentity + ) { + return failure( + "STALE_REVISION", + "요청한 change revision이 현재 pull request와 다릅니다.", + request.pullRequestId, + ) + } + val approval = + Approval( + id = "github-approval-${request.pullRequestId}", + actor = request.actor, + changeRevisionId = request.changeRevisionId, + diffIdentity = request.diffIdentity, + approvedAtEpochMillis = nowEpochMillis(), + ) + return PortResult.Success( + ApproveReviewResponse( + pullRequest.copy(state = PullRequestState.APPROVED, approval = approval), + approval, + receipt("approve", request.pullRequestId), + ) + ) + } + + private fun fetch(reference: String, includeReviewThreads: Boolean = false): GithubPullRequest? { + val result = + execute( + listOf( + "gh", + "pr", + "view", + reference, + "--json", + "number,title,body,state,isDraft,baseRefName,headRefName,headRefOid,reviewDecision,author,comments,reviews", + ), + reference, + ) ?: return null + val provider = + try { + json.decodeFromString(result.stdout) + } catch (_: SerializationException) { + return null + } + if (!includeReviewThreads) { + return provider.copy(commentsLoaded = true) + } + val reviewThreads = fetchReviewThreads(provider.number.toInt()) ?: return null + return provider.copy( + reviewThreads = reviewThreads, + reviewThreadsLoaded = true, + commentsLoaded = true, + ) + } + + private fun fetchReviewThreads(pullRequestNumber: Int): List? { + val query = + """ + query(${'$'}owner:String!,${'$'}repo:String!,${'$'}number:Int!,${'$'}endCursor:String) { + repository(owner:${'$'}owner,name:${'$'}repo) { + pullRequest(number:${'$'}number) { + reviewThreads(first:100,after:${'$'}endCursor) { + nodes { + id + isResolved + comments(first:100) { + nodes { + id + databaseId + body + author { login name } + authorAssociation + createdAt + path + line + } + } + } + pageInfo { hasNextPage endCursor } + } + } + } + } + """ + .trimIndent() + val result = + execute( + listOf( + "gh", + "api", + "graphql", + "--paginate", + "--slurp", + "-f", + "query=$query", + "-F", + "owner={owner}", + "-F", + "repo={repo}", + "-F", + "number=$pullRequestNumber", + "-F", + "endCursor=null", + ), + pullRequestNumber.toString(), + ) ?: return null + val pages = + try { + val element = json.parseToJsonElement(result.stdout) + if (element is JsonArray) element.toList() else listOf(element) + } catch (_: SerializationException) { + lastFailure = + PortResult.Failure( + PortError( + code = "GITHUB_RESPONSE_INVALID", + message = "GitHub review thread 조회 결과를 해석할 수 없습니다.", + target = pullRequestNumber.toString(), + ) + ) + return null + } + val responses = pages.mapNotNull { page -> + try { + json.decodeFromJsonElement(page) + } catch (_: SerializationException) { + null + } + } + if (responses.size != pages.size) { + lastFailure = + PortResult.Failure( + PortError( + code = "GITHUB_RESPONSE_INVALID", + message = "GitHub review thread 조회 결과를 해석할 수 없습니다.", + target = pullRequestNumber.toString(), + ) + ) + return null + } + val error = responses.flatMap { it.errors }.firstOrNull { it.message.isNotBlank() } + if (error != null) { + lastFailure = + PortResult.Failure( + PortError( + code = "GITHUB_REVIEW_THREADS_FAILED", + message = error.message, + target = pullRequestNumber.toString(), + ) + ) + return null + } + val connections = responses.mapNotNull { response -> + response.data?.repository?.pullRequest?.reviewThreads + } + if (connections.size != responses.size) { + lastFailure = + PortResult.Failure( + PortError( + code = "GITHUB_RESPONSE_INVALID", + message = "GitHub review thread 조회 결과에 pull request가 없습니다.", + target = pullRequestNumber.toString(), + ) + ) + return null + } + return connections.flatMap { connection -> + connection.nodes.map { thread -> + GithubReviewThread( + id = thread.id, + isResolved = thread.isResolved, + comments = thread.comments.nodes, + ) + } + } + } + + private fun execute( + command: List, + target: String, + ): io.springkit.workflow.common.CommandResult? { + val result = + try { + commandRunner.run(command, repositoryRoot) + } catch (failure: Exception) { + lastFailure = + PortResult.Failure( + PortError( + code = "GITHUB_COMMAND_FAILED", + message = + "gh 명령을 실행할 수 없습니다: ${failure.message ?: failure::class.simpleName}", + retryable = true, + target = target, + ) + ) + return null + } + if (result.exitCode == 0) { + lastFailure = null + return result + } + lastFailure = + PortResult.Failure( + PortError( + code = "GITHUB_COMMAND_FAILED", + message = + result.stderr + .trim() + .ifBlank { result.stdout.trim() } + .ifBlank { + "gh 명령이 종료 코드 ${result.exitCode}로 실패했습니다." + }, + retryable = result.exitCode == 2, + target = target, + ) + ) + return null + } + + private var lastFailure: PortResult.Failure? = null + + private fun resolve(provider: GithubPullRequest): PullRequest = + effectivePullRequestResolver.resolve(provider, currentPullRequest(provider.number.toString())) + + private fun failure(code: String, message: String, target: String): PortResult = + PortResult.Failure(lastFailure?.error ?: PortError(code, message, target = target)).also { + lastFailure = null + } + + private fun receipt(operation: String, target: String): ChangeReceipt = + ChangeReceipt("github-$operation-$target", "github-$operation") + + private fun nextReviewRevision( + pullRequest: PullRequest, + threads: List, + ): ReviewRevision { + val number = pullRequest.reviewRevision.number + 1 + val occurredAt = nowEpochMillis() + return pullRequest.reviewRevision.copy( + id = "github-review-${pullRequest.id}-$number-$occurredAt", + number = number, + threads = threads, + createdAtEpochMillis = occurredAt, + ) + } + + private fun pullRequestReference(output: String): String? = + output.trim().lineSequence().lastOrNull()?.trim()?.substringAfterLast('/')?.takeIf { + it.isNotBlank() && it.all(Char::isDigit) + } + + private companion object { + val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + } +} + +private class DefaultGithubPullRequestResolver(private val humanActorIds: Set) : + GithubPullRequestResolver { + override fun resolve(provider: GithubPullRequest, previous: PullRequest?): PullRequest { + if (previous != null) { + val providerBody = provider.body.takeIf { it.isNotBlank() } + val providerRevision = provider.headRefOid?.takeIf { it.isNotBlank() } + val previousProviderRevision = previous.changeRevision.providerRevision + val codeChanged = providerRevision != null && previousProviderRevision != providerRevision + val bodyChanged = providerBody != null && providerBody != previous.body + val providerThreads = provider.toDomainThreads(humanActorIds) + val issueThreads = provider.comments.toIssueCommentThreads(humanActorIds) + val reviewThreads = + when { + provider.reviewThreadsLoaded -> providerThreads + provider.commentsLoaded -> + issueThreads + + previous.reviewRevision.threads.filterNot { + it.id.startsWith("github-issue-comment-") + } + providerThreads.isNotEmpty() -> providerThreads + else -> previous.reviewRevision.threads + } + val threadsChanged = reviewThreads != previous.reviewRevision.threads + val nextChangeRevision = + if (codeChanged) { + previous.changeRevision.copy( + id = "github-change-${provider.number}-${previous.changeRevision.number + 1}", + number = previous.changeRevision.number + 1, + providerRevision = providerRevision, + ) + } else { + previous.changeRevision + } + val nextReviewRevision = + if (bodyChanged || threadsChanged) { + previous.reviewRevision.copy( + id = "github-review-${provider.number}-${previous.reviewRevision.number + 1}", + number = previous.reviewRevision.number + 1, + body = providerBody ?: previous.reviewRevision.body, + threads = reviewThreads, + ) + } else { + previous.reviewRevision + } + return previous.copy( + title = provider.title.ifBlank { previous.title }, + body = providerBody ?: previous.body, + base = provider.baseRefName.ifBlank { previous.base }, + state = providerState(provider), + reviewRevision = nextReviewRevision.copy(threads = reviewThreads), + changeRevision = nextChangeRevision, + approval = if (codeChanged) null else previous.approval, + ci = if (codeChanged) CiStatus.PENDING else previous.ci, + aiReview = if (codeChanged) AiReviewStatus.PENDING else previous.aiReview, + ) + } + val number = provider.number.takeIf { it > 0 }?.toString() ?: "github-pr" + val body = provider.body.ifBlank { provider.title.ifBlank { "GitHub pull request $number" } } + val title = provider.title.ifBlank { "GitHub pull request $number" } + val changeId = "github-change-$number" + val reviewId = "github-review-$number" + val diffIdentity = "github-untracked-pr-$number" + val reviewThreads = provider.toDomainThreads(humanActorIds) + return PullRequest( + id = number, + subTaskId = provider.headRefName.ifBlank { "github-$number" }, + title = title, + body = body, + base = provider.baseRefName.ifBlank { "main" }, + state = providerState(provider), + reviewRevision = ReviewRevision(reviewId, 1, body, threads = reviewThreads), + changeRevision = + ChangeRevision( + changeId, + 1, + Diff(diffIdentity), + providerRevision = provider.headRefOid?.takeIf { it.isNotBlank() }, + ), + ) + } + + private fun providerState(provider: GithubPullRequest): PullRequestState = + when { + provider.state.equals("MERGED", ignoreCase = true) -> PullRequestState.MERGED + !provider.state.equals("OPEN", ignoreCase = true) -> PullRequestState.CLOSED + provider.isDraft -> PullRequestState.DRAFT + provider.reviewDecision.equals("APPROVED", ignoreCase = true) -> PullRequestState.APPROVED + else -> PullRequestState.REVIEW + } +} + +private fun GithubPullRequest.toDomainThreads(humanActorIds: Set): List = + comments.toIssueCommentThreads(humanActorIds) + reviewThreads.toDomainThreads(humanActorIds) + +private fun List.toIssueCommentThreads( + humanActorIds: Set +): List { + val roots = filterNot { it.isWorkflowReply() } + val repliesByRoot = filter { it.isWorkflowReply() }.groupByNotNull { it.issueCommentRootId() } + return roots.mapNotNull { providerComment -> + val rootComment = providerComment.toDomainComment(humanActorIds) ?: return@mapNotNull null + val replies = + repliesByRoot[issueCommentRootReference(providerComment)].orEmpty().mapNotNull { + it.toDomainComment(humanActorIds) + } + val comments = listOf(rootComment) + replies + ReviewThread( + id = issueCommentThreadId(providerComment), + level = rootComment.body.reviewLevel() ?: ReviewLevel.C, + comments = comments, + state = + if (providerComment.body.isResolvedIssueComment()) { + io.springkit.workflow.domain.ThreadState.RESOLVED + } else { + io.springkit.workflow.domain.ThreadState.OPEN + }, + ) + } +} + +private fun List.toDomainThreads( + humanActorIds: Set +): List = mapNotNull { providerThread -> + val comments = providerThread.comments.mapNotNull { it.toDomainComment(humanActorIds) } + if (providerThread.id.isBlank() || comments.isEmpty()) { + return@mapNotNull null + } + ReviewThread( + id = providerThread.id, + level = comments.firstNotNullOfOrNull { it.body.reviewLevel() } ?: ReviewLevel.C, + comments = comments, + state = + if (providerThread.isResolved) { + io.springkit.workflow.domain.ThreadState.RESOLVED + } else { + io.springkit.workflow.domain.ThreadState.OPEN + }, + ) +} + +private fun GithubComment.toDomainComment( + humanActorIds: Set +): io.springkit.workflow.domain.ReviewComment? { + val commentId = id.ifBlank { nodeId.orEmpty() } + if (commentId.isBlank() || body.isBlank()) { + return null + } + val actorId = author?.login?.ifBlank { null } ?: "github-comment-$commentId" + val actorKind = classifyCommentAuthor(author, authorAssociation, body, humanActorIds) + val createdAt = + createdAt?.let { value -> + runCatching { Instant.parse(value).toEpochMilli() }.getOrDefault(0) + } ?: 0 + return io.springkit.workflow.domain.ReviewComment( + id = commentId, + author = io.springkit.workflow.domain.Actor(actorId, actorKind, author?.name), + body = body.withoutWorkflowMarkers(), + createdAtEpochMillis = createdAt, + path = path, + line = line, + ) +} + +/* + * `[Agent]` 본문 표식은 GitHub 작성자 메타데이터보다 우선합니다. 표식이 없으면 사람 계정 허용 목록과 GitHub 작성자 연관 관계로 분류합니다. + */ +private fun classifyCommentAuthor( + author: GithubUser?, + authorAssociation: String?, + body: String, + humanActorIds: Set = emptySet(), +): io.springkit.workflow.domain.ActorKind = + when { + body.trimStart().startsWith("[Agent]") -> io.springkit.workflow.domain.ActorKind.AGENT + author?.login?.let { login -> humanActorIds.any { it.equals(login, ignoreCase = true) } } == + true -> io.springkit.workflow.domain.ActorKind.HUMAN + authorAssociation.equals("BOT", ignoreCase = true) -> + io.springkit.workflow.domain.ActorKind.AGENT + author?.login?.endsWith("[bot]", ignoreCase = true) == true -> + io.springkit.workflow.domain.ActorKind.AGENT + authorAssociation?.uppercase()?.let(HUMAN_AUTHOR_ASSOCIATIONS::contains) == true -> + io.springkit.workflow.domain.ActorKind.HUMAN + else -> io.springkit.workflow.domain.ActorKind.AGENT + } + +private fun String.reviewLevel(): ReviewLevel? { + val withoutAgent = replaceFirst(Regex("^\\s*\\[Agent]\\s*"), "") + return Regex("^\\[(R|C|A)]").find(withoutAgent)?.groupValues?.getOrNull(1)?.let { + ReviewLevel.valueOf(it) + } +} + +private fun String.withReviewLevel(level: ReviewLevel): String { + val agentMarked = trimStart().startsWith("[Agent]") + val withoutAgent = replaceFirst(Regex("^\\s*\\[Agent]\\s*"), "") + val withoutLevel = withoutAgent.replaceFirst(Regex("^\\[(R|C|A)]\\s*"), "") + val levelMarked = "[${level.name}] ${withoutLevel.trimStart()}" + return if (agentMarked) "[Agent] $levelMarked" else levelMarked +} + +private fun issueCommentThreadId(comment: GithubComment): String = + "github-issue-comment-${comment.nodeId?.takeIf { it.isNotBlank() } ?: comment.id}" + +private fun isGithubIssueCommentThreadId(id: String): Boolean = + id.startsWith("github-issue-comment-") + +private fun isGithubReviewThreadId(id: String): Boolean = id.startsWith("PRRT_") + +private fun GithubComment.isWorkflowReply(): Boolean = issueCommentRootId() != null + +private fun GithubComment.issueCommentRootId(): String? = + ISSUE_COMMENT_ROOT_MARKER.find(body)?.groupValues?.getOrNull(1) + +private fun issueCommentRootReference(comment: GithubComment): String = + comment.nodeId?.takeIf { it.isNotBlank() } ?: comment.id + +private fun String.withIssueCommentRoot(rootReference: String): String = + "$this\n\n" + +private fun String.withIssueCommentReplyId(commentId: String): String = + "$this\n\n" + +private fun GithubComment.issueCommentReplyId(): String? = + ISSUE_COMMENT_REPLY_MARKER.find(body)?.groupValues?.getOrNull(1) + +private fun String.withReviewThreadReplyId(commentId: String): String = + "$this\n\n" + +private fun GithubComment.reviewThreadReplyId(): String? = + REVIEW_THREAD_REPLY_MARKER.find(body)?.groupValues?.getOrNull(1) + +private fun String.withResolvedIssueCommentMarker(): String = + if (isResolvedIssueComment()) this else "$this\n\n$ISSUE_COMMENT_RESOLVED_MARKER" + +private fun String.isResolvedIssueComment(): Boolean = ISSUE_COMMENT_RESOLVED_MARKER in this + +private fun String.withoutWorkflowMarkers(): String = + replace(ISSUE_COMMENT_ROOT_MARKER, "") + .replace(ISSUE_COMMENT_REPLY_MARKER, "") + .replace(REVIEW_THREAD_REPLY_MARKER, "") + .replace(ISSUE_COMMENT_RESOLVED_MARKER, "") + .trim() + +private fun Iterable.groupByNotNull(keySelector: (T) -> K?): Map> = + mapNotNull { value -> keySelector(value)?.let { it to value } } + .groupBy({ it.first }, { it.second }) + +private val ISSUE_COMMENT_ROOT_MARKER = Regex("") + +private val ISSUE_COMMENT_REPLY_MARKER = Regex("") + +private val REVIEW_THREAD_REPLY_MARKER = Regex("") + +private val HUMAN_AUTHOR_ASSOCIATIONS = + setOf( + "OWNER", + "MEMBER", + "COLLABORATOR", + "CONTRIBUTOR", + "FIRST_TIMER", + "FIRST_TIME_CONTRIBUTOR", + "MANNEQUIN", + "NONE", + ) + +private const val ISSUE_COMMENT_RESOLVED_MARKER = "" + +private fun configuredHumanActorIds(): Set = + (System.getenv("WORKFLOW_HUMAN_ACTORS") ?: System.getenv("WORKFLOW_HUMAN_ACTOR_IDS")) + ?.split(',') + ?.map(String::trim) + ?.filter(String::isNotBlank) + ?.toSet() ?: emptySet() diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/json/WorkflowResultJson.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/json/WorkflowResultJson.kt new file mode 100644 index 00000000..9b51e606 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/json/WorkflowResultJson.kt @@ -0,0 +1,93 @@ +package io.springkit.workflow.adapter.json + +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.FailureConflict +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.NextAction +import io.springkit.workflow.domain.WorkflowResult +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNamingStrategy +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/* + * Workflow 명령 출력과 영속 상태가 공유하는 JSON 설정입니다. + */ +@OptIn(ExperimentalSerializationApi::class) +object WorkflowJson { + val format: Json = Json { + explicitNulls = false + encodeDefaults = true + ignoreUnknownKeys = false + allowStructuredMapKeys = true + prettyPrint = false + classDiscriminator = "type" + namingStrategy = JsonNamingStrategy.SnakeCase + } +} + +fun NextAction.toJsonObject(): JsonObject = buildJsonObject { + put("action", action) + put("actor", actor.name.lowercase()) + command?.let { put("command", it) } +} + +fun BlockedBy.toJsonObject(): JsonObject = buildJsonObject { + put("code", code) + put("message", message) + target?.let { put("target", it) } +} + +fun FailureData.toJsonObject(): JsonObject = buildJsonObject { + put("blocked_by", buildJsonArray { blockedBy.forEach { add(it.toJsonObject()) } }) + put("code", code.name) + put("message", message) + put("next", buildJsonArray { next.forEach { add(it.toJsonObject()) } }) + workspace?.let { + put("workspace", buildJsonObject { put("path", it.path.value) }) + } + if (conflicts.isNotEmpty()) { + put("conflicts", buildJsonArray { conflicts.forEach { add(it.toJsonObject()) } }) + } +} + +/* + * 충돌 파일을 Workflow JSON의 공통 실패 구조로 변환합니다. + */ +fun FailureConflict.toJsonObject(): JsonObject = buildJsonObject { + put("path", path) + put("kind", kind) +} + +fun WorkflowResult.toJsonObject(): JsonObject = + when (this) { + is WorkflowResult.Failure -> + buildJsonObject { + put("data", data.toJsonObject()) + put("type", "failure") + } + is WorkflowResult.Success -> + buildJsonObject { + put("data", data.withNext(next)) + put("type", "success") + } + } + +/* + * 안정적인 `{type, data}` Workflow 출력 계약에 따라 결과를 인코딩합니다. + */ +fun WorkflowResult.encodeToString(): String = + WorkflowJson.format.encodeToString(JsonObject.serializer(), toJsonObject()) + +private fun JsonObject.withNext(next: List): JsonObject { + val fields = toMutableMap() + if (next.isNotEmpty()) { + fields["next"] = buildJsonArray { next.forEach { add(it.toJsonObject()) } } + } + return buildJsonObject { + fields.toSortedMap().forEach { (name, value) -> put(name, value) } + } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/EnvironmentIdentityAdapter.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/EnvironmentIdentityAdapter.kt new file mode 100644 index 00000000..2e7698c2 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/EnvironmentIdentityAdapter.kt @@ -0,0 +1,118 @@ +package io.springkit.workflow.adapter.local + +import io.springkit.workflow.application.AuthorizeRequest +import io.springkit.workflow.application.AuthorizeResponse +import io.springkit.workflow.application.Capability +import io.springkit.workflow.application.CurrentActorRequest +import io.springkit.workflow.application.CurrentActorResponse +import io.springkit.workflow.application.IdentityPort +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.common.LocalCommandRunner +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import java.nio.file.Path + +/* + * 환경 주체와 인증된 `gh api user`를 사용해 현재 principal과 사람 Gate를 판별합니다. + * + * `gh` 인증 성공만으로는 HUMAN을 부여하지 않으며 [humanActorIds]에 명시된 계정만 사람으로 분류합니다. + */ +class EnvironmentIdentityAdapter( + private val commandRunner: CommandRunner = LocalCommandRunner(), + private val workingDirectory: Path = Path.of(System.getProperty("user.dir")), + private val environment: Map = System.getenv(), + private val humanActorIds: Set = emptySet(), +) : IdentityPort { + override fun currentActor(request: CurrentActorRequest): PortResult { + val environmentActor = environmentActor() + if (isAutomation()) { + return environmentActor?.let { PortResult.Success(CurrentActorResponse(it)) } + ?: failure("IDENTITY_UNAVAILABLE", "자동화 실행의 principal을 확인할 수 없습니다.") + } + + val ghActor = authenticatedGithubActor() + if (ghActor != null) return PortResult.Success(CurrentActorResponse(ghActor)) + if (environmentActor != null) return PortResult.Success(CurrentActorResponse(environmentActor)) + return failure("IDENTITY_UNAVAILABLE", "현재 principal을 확인할 수 없습니다.") + } + + override fun authorize(request: AuthorizeRequest): PortResult { + if (request.target.isBlank()) { + return failure("AUTHORIZATION_TARGET_INVALID", "권한 확인 대상이 비어 있습니다.") + } + val current = + when (val result = currentActor()) { + is PortResult.Failure -> return result + is PortResult.Success -> result.value.actor + } + if (current.id != request.actor.id) { + return PortResult.Success( + AuthorizeResponse( + allowed = false, + reason = "요청 주체가 현재 인증된 principal과 다릅니다.", + ) + ) + } + val humanGate = + request.capability in + setOf(Capability.READY, Capability.APPROVE, Capability.DEPLOY, Capability.RELEASE) + if (humanGate && !current.isHuman) { + return PortResult.Success( + AuthorizeResponse( + allowed = false, + reason = "사람 Gate는 인증된 HUMAN principal만 수행할 수 있습니다.", + ) + ) + } + return PortResult.Success(AuthorizeResponse(allowed = true)) + } + + private fun authenticatedGithubActor(): Actor? { + val result = + try { + commandRunner.run(listOf("gh", "api", "user", "--jq", ".login"), workingDirectory) + } catch (_: Exception) { + return null + } + if (result.exitCode != 0) return null + val login = result.stdout.trim() + val kind = if (itIsConfiguredHuman(login)) ActorKind.HUMAN else ActorKind.AGENT + return login.takeIf(String::isNotBlank)?.let { Actor(it, kind, it) } + } + + private fun environmentActor(): Actor? { + val id = + environment["GH_USER"]?.takeIf(String::isNotBlank) + ?: environment["GITHUB_ACTOR"]?.takeIf(String::isNotBlank) + ?: environment["USER"]?.takeIf(String::isNotBlank) + ?: environment["USERNAME"]?.takeIf(String::isNotBlank) + val kind = + when { + id in humanActorIds -> ActorKind.HUMAN + environment["WORKFLOW_ACTOR_KIND"]?.uppercase() == "WORKFLOW" -> ActorKind.WORKFLOW + environment["WORKFLOW_ACTOR_KIND"]?.uppercase() == "AGENT" -> ActorKind.AGENT + else -> if (isAutomation()) ActorKind.WORKFLOW else ActorKind.AGENT + } + return id?.let { Actor(it, kind, it) } + } + + /* + * 명시적으로 허용된 계정만 사람 주체로 분류합니다. + */ + private fun itIsConfiguredHuman(login: String): Boolean = login in humanActorIds + + private fun isAutomation(): Boolean = + environment["GITHUB_ACTIONS"]?.equals("true", ignoreCase = true) == true || + environment["CI"]?.equals("true", ignoreCase = true) == true || + environment["WORKFLOW_ACTOR_KIND"]?.uppercase() in setOf("AGENT", "WORKFLOW") + + private fun failure(code: String, message: String): PortResult = + PortResult.Failure(PortError(code, message)) +} + +/* + * 로컬 환경 주체 adapter의 짧은 이름입니다. + */ +typealias LocalIdentityAdapter = EnvironmentIdentityAdapter diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/LocalWorkspaceAdapter.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/LocalWorkspaceAdapter.kt new file mode 100644 index 00000000..02267d23 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/LocalWorkspaceAdapter.kt @@ -0,0 +1,229 @@ +package io.springkit.workflow.adapter.local + +import io.springkit.workflow.application.ChangeReceipt +import io.springkit.workflow.application.CreateWorkspaceRequest +import io.springkit.workflow.application.CreateWorkspaceResponse +import io.springkit.workflow.application.DeleteWorkspaceRequest +import io.springkit.workflow.application.DeleteWorkspaceResponse +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.StoreSnapshotRequest +import io.springkit.workflow.application.WorkflowStorePort +import io.springkit.workflow.application.WorkflowStoreSnapshot +import io.springkit.workflow.application.WorkspaceLookupRequest +import io.springkit.workflow.application.WorkspaceLookupResponse +import io.springkit.workflow.application.WorkspacePort +import io.springkit.workflow.common.ManagedPathResolver +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspacePath +import java.nio.file.Path + +/* + * 현재 실행 경로를 Store의 관리 Worktree로 해석하는 로컬 [WorkspacePort] 구현입니다. + */ +class LocalWorkspaceAdapter( + private val snapshotProvider: () -> WorkflowStoreSnapshot, + private val currentPathProvider: () -> Path = ::defaultCurrentPath, + managedRoot: Path? = null, +) : WorkspacePort { + private val managedRoot: Path? = managedRoot?.let(ManagedPathResolver::canonicalize) + + /* + * Store Port를 사용해 현재 snapshot을 읽는 Workspace adapter를 만듭니다. + */ + constructor( + storePort: WorkflowStorePort, + currentPathProvider: () -> Path = ::defaultCurrentPath, + managedRoot: Path? = null, + ) : this( + { + when (val result = storePort.snapshot(StoreSnapshotRequest())) { + is PortResult.Success -> result.value.snapshot + is PortResult.Failure -> throw WorkspaceSnapshotException(result.error) + } + }, + currentPathProvider, + managedRoot, + ) + + /* + * 고정 snapshot과 실행 경로로 테스트 가능한 Workspace adapter를 만듭니다. + */ + constructor( + snapshot: WorkflowStoreSnapshot, + currentPath: Path, + managedRoot: Path? = null, + ) : this({ snapshot }, { currentPath }, managedRoot) + + override fun get(request: WorkspaceLookupRequest): PortResult { + val snapshot = readSnapshot() ?: return lastFailure + val currentPath = + safeResolve(currentPathProvider()) ?: return invalidPath(currentPathProvider()) + request.path?.let { path -> + if (safeResolve(path) == null) return invalidPath(path.value) + } + val workspace = + snapshot.workspaces.firstOrNull { candidate -> + matches(candidate, request) && + (request.workspaceId != null || + request.subTaskId != null || + request.path != null || + matchesCurrentPath(candidate.path, currentPath)) + } + ?: return failure( + code = + if (isCurrentLookup(request)) "NOT_MANAGED_WORKTREE" else "WORKSPACE_NOT_FOUND", + message = + if (isCurrentLookup(request)) { + "현재 경로가 관리되는 Worktree에 속하지 않습니다: $currentPath" + } else { + "Workspace를 찾을 수 없습니다." + }, + target = request.path?.value ?: request.workspaceId ?: request.subTaskId, + ) + if (!workspace.managed) { + return failure( + code = "NOT_MANAGED_WORKTREE", + message = "Workspace가 관리되는 Worktree가 아닙니다: ${workspace.id}", + target = workspace.id, + ) + } + return PortResult.Success(WorkspaceLookupResponse(workspace)) + } + + override fun create(request: CreateWorkspaceRequest): PortResult { + if (safeResolve(request.path) == null) return invalidPath(request.path.value) + val workspace = + Workspace( + id = request.workspaceId, + subTaskId = request.subTaskId, + path = request.path, + branch = request.branch, + managed = true, + ) + return PortResult.Success( + CreateWorkspaceResponse( + workspace = workspace, + change = ChangeReceipt("workspace-create-${request.workspaceId}", "create-workspace"), + ) + ) + } + + override fun delete(request: DeleteWorkspaceRequest): PortResult { + val snapshot = readSnapshot() ?: return lastFailure + val workspace = snapshot.workspaces.firstOrNull { it.id == request.workspaceId } + if (workspace == null || workspace.subTaskId != request.subTaskId) { + return failure( + code = "WORKSPACE_NOT_FOUND", + message = "삭제할 Workspace를 찾을 수 없습니다: ${request.workspaceId}", + target = request.workspaceId, + ) + } + if (!workspace.managed) { + return failure( + code = "NOT_MANAGED_WORKTREE", + message = "Workspace가 관리되는 Worktree가 아닙니다: ${workspace.id}", + target = workspace.id, + ) + } + if (safeResolve(workspace.path) == null) return invalidPath(workspace.path.value) + return PortResult.Success( + DeleteWorkspaceResponse( + workspaceId = request.workspaceId, + change = ChangeReceipt("workspace-delete-${request.workspaceId}", "delete-workspace"), + ) + ) + } + + private var lastFailure: PortResult.Failure = + PortResult.Failure( + PortError("WORKSPACE_SNAPSHOT_UNAVAILABLE", "Workflow Store snapshot을 읽을 수 없습니다.") + ) + + private fun readSnapshot(): WorkflowStoreSnapshot? = + try { + snapshotProvider() + } catch (failure: WorkspaceSnapshotException) { + lastFailure = PortResult.Failure(failure.error) + null + } catch (failure: Exception) { + lastFailure = + PortResult.Failure( + PortError( + code = "WORKSPACE_SNAPSHOT_UNAVAILABLE", + message = "Workflow Store snapshot을 읽을 수 없습니다: ${failure.message}", + retryable = true, + ) + ) + null + } + + private fun matches(workspace: Workspace, request: WorkspaceLookupRequest): Boolean = + (request.workspaceId == null || request.workspaceId == workspace.id) && + (request.subTaskId == null || request.subTaskId == workspace.subTaskId) && + (request.path == null || pathsMatch(request.path, workspace.path)) + + private fun isCurrentLookup(request: WorkspaceLookupRequest): Boolean = + request.workspaceId == null && request.subTaskId == null && request.path == null + + private fun resolve(path: WorkspacePath): Path { + val candidate = Path.of(path.value) + if (candidate.isAbsolute) return candidate + val root = managedRoot ?: normalize(currentPathProvider()) + return root.resolve(candidate) + } + + private fun pathsMatch(left: WorkspacePath, right: WorkspacePath): Boolean = + if ( + managedRoot == null && !Path.of(left.value).isAbsolute && !Path.of(right.value).isAbsolute + ) { + Path.of(left.value).normalize() == Path.of(right.value).normalize() + } else { + safeResolve(left)?.let { leftPath -> + safeResolve(right)?.let { rightPath -> leftPath == rightPath } + } ?: false + } + + private fun matchesCurrentPath(workspacePath: WorkspacePath, currentPath: Path): Boolean { + val rawPath = Path.of(workspacePath.value) + if (managedRoot == null && !rawPath.isAbsolute) { + return currentPath.normalize().endsWith(rawPath.normalize()) + } + val candidate = safeResolve(workspacePath) ?: return false + return currentPath == candidate || currentPath.startsWith(candidate) + } + + private fun normalize(path: Path): Path = path.toAbsolutePath().normalize() + + private fun safeResolve(path: WorkspacePath): Path? = safeResolve(resolve(path)) + + private fun safeResolve(path: Path): Path? { + val root = managedRoot ?: return ManagedPathResolver.canonicalize(path) + return ManagedPathResolver.resolveWithin(root, path) + } + + private fun invalidPath(path: Any): PortResult.Failure = + PortResult.Failure( + PortError( + code = "WORKSPACE_PATH_INVALID", + message = "Workspace 경로가 관리 root 밖에 있거나 심볼릭 링크로 탈출합니다: $path", + target = path.toString(), + ) + ) + + private fun failure( + code: String, + message: String, + target: String? = null, + ): PortResult = PortResult.Failure(PortError(code, message, target = target)) + + private class WorkspaceSnapshotException(val error: PortError) : RuntimeException(error.message) +} + +private fun defaultCurrentPath(): Path = + Path.of(System.getProperty("user.dir")).toAbsolutePath().normalize() + +/* + * 현재 실행 경로를 조회하는 용도를 드러내는 호환 별칭입니다. + */ +typealias CurrentWorkspaceAdapter = LocalWorkspaceAdapter diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/OkioWorkflowBodyReader.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/OkioWorkflowBodyReader.kt new file mode 100644 index 00000000..94b9312d --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/OkioWorkflowBodyReader.kt @@ -0,0 +1,16 @@ +package io.springkit.workflow.adapter.local + +import io.springkit.workflow.runtime.WorkflowBodyReader +import okio.FileSystem +import okio.Path.Companion.toPath +import okio.buffer + +/* + * Okio FileSystem에서 본문 파일을 UTF-8 문자열로 읽는 local adapter입니다. + */ +class OkioWorkflowBodyReader( + private val fileSystem: FileSystem = FileSystem.SYSTEM, +) : WorkflowBodyReader { + override fun read(path: String): String = + fileSystem.source(path.toPath()).buffer().use { source -> source.readUtf8() } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/SnapshotTaskAdapter.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/SnapshotTaskAdapter.kt new file mode 100644 index 00000000..975ca47e --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/SnapshotTaskAdapter.kt @@ -0,0 +1,177 @@ +package io.springkit.workflow.adapter.local + +import io.springkit.workflow.application.ChangeReceipt +import io.springkit.workflow.application.Compensation +import io.springkit.workflow.application.CreateSubTaskRequest +import io.springkit.workflow.application.CreateSubTaskResponse +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.SubTaskLookupRequest +import io.springkit.workflow.application.SubTaskLookupResponse +import io.springkit.workflow.application.TaskLookupRequest +import io.springkit.workflow.application.TaskLookupResponse +import io.springkit.workflow.application.TaskPort +import io.springkit.workflow.application.UpdateExternalSubTaskRequest +import io.springkit.workflow.application.UpdateExternalSubTaskResponse +import io.springkit.workflow.application.WorkflowStoreSnapshot +import io.springkit.workflow.domain.ExternalTaskId +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.Task + +/* + * Workflow Store snapshot을 외부 Task 연결의 로컬 읽기 모델로 사용하는 [TaskPort] 구현입니다. 외부 Task 제공자를 설정하지 않은 로컬 + * 실행에서는 외부 ID를 검증하지 않고 임시 Task로 구체화하며, 실제 Task와 SubTask 상태는 애플리케이션 트랜잭션이 저장합니다. + */ +class SnapshotTaskAdapter( + private val snapshotProvider: () -> WorkflowStoreSnapshot, +) : TaskPort { + /* + * 고정 snapshot을 사용하는 테스트용 Task adapter를 만듭니다. + */ + constructor(snapshot: WorkflowStoreSnapshot) : this({ snapshot }) + + override fun get(request: TaskLookupRequest): PortResult { + if (request.taskId == null && request.externalId == null) { + return failure("TASK_LOOKUP_INVALID", "Task ID 또는 외부 Task ID가 필요합니다.") + } + val snapshot = readSnapshot() ?: return lastFailure + val task = + snapshot.tasks.firstOrNull { + (request.taskId == null || request.taskId == it.id) && + (request.externalId == null || request.externalId == it.externalId) + } + ?: request.externalId + ?.takeIf { request.taskId == null || request.taskId == it.value } + ?.let(::materializeTask) + ?: return failure( + "TASK_NOT_FOUND", + "Task를 찾을 수 없습니다.", + request.externalId?.value ?: request.taskId, + ) + return PortResult.Success(TaskLookupResponse(task)) + } + + override fun getSubTask(request: SubTaskLookupRequest): PortResult { + val snapshot = readSnapshot() ?: return lastFailure + val subTask = snapshot.subTasks.firstOrNull { it.id == request.subTaskId } + return if (subTask == null) { + failure("SUBTASK_NOT_FOUND", "SubTask를 찾을 수 없습니다.", request.subTaskId) + } else { + PortResult.Success(SubTaskLookupResponse(subTask)) + } + } + + override fun createSubTask(request: CreateSubTaskRequest): PortResult { + val snapshot = readSnapshot() ?: return lastFailure + val task = + snapshot.tasks.firstOrNull { it.externalId == request.externalTaskId } + ?: materializeTask(request.externalTaskId) + val existing = + snapshot.subTasks.firstOrNull { + it.id == request.subTaskId + } + val subTask = + existing + ?: SubTask( + id = request.subTaskId, + taskId = task.id, + title = request.title, + state = SubTaskState.DEVELOPMENT, + requires = request.requires, + ) + if ( + existing != null && + (existing.taskId != task.id || + existing.title != request.title || + existing.requires != request.requires) + ) { + return failure( + "SUBTASK_ID_CONFLICT", + "이미 다른 메타데이터로 사용 중인 SubTask ID입니다.", + request.subTaskId, + ) + } + val storedTask = + if (subTask.id in task.subTaskIds) task + else task.copy(subTaskIds = task.subTaskIds + subTask.id) + return PortResult.Success( + CreateSubTaskResponse( + task = storedTask, + subTask = subTask, + change = + ChangeReceipt( + id = "task-create-${request.requestId}", + operation = "create-subtask", + compensation = + Compensation( + id = "task-delete-${request.requestId}", + operation = "delete-subtask", + idempotencyKey = request.requestId, + ), + ), + ) + ) + } + + override fun updateSubTask( + request: UpdateExternalSubTaskRequest + ): PortResult { + val snapshot = readSnapshot() ?: return lastFailure + val subTask = + snapshot.subTasks.firstOrNull { it.id == request.subTaskId } + ?: return failure("SUBTASK_NOT_FOUND", "SubTask를 찾을 수 없습니다.", request.subTaskId) + val task = + snapshot.tasks.firstOrNull { it.id == subTask.taskId } + ?: return failure("TASK_NOT_FOUND", "SubTask의 Task를 찾을 수 없습니다.", subTask.taskId) + if (task.externalId != request.externalTaskId) { + return failure("TASK_MISMATCH", "SubTask가 요청한 외부 Task에 속하지 않습니다.", request.subTaskId) + } + return PortResult.Success( + UpdateExternalSubTaskResponse( + externalTaskId = request.externalTaskId, + subTaskId = request.subTaskId, + state = request.state, + change = ChangeReceipt("task-update-${request.requestId}", "update-subtask"), + ) + ) + } + + private var lastFailure: PortResult.Failure = + PortResult.Failure( + PortError("TASK_SNAPSHOT_UNAVAILABLE", "Workflow Store snapshot을 읽을 수 없습니다.") + ) + + private fun readSnapshot(): WorkflowStoreSnapshot? = + try { + snapshotProvider() + } catch (failure: Exception) { + lastFailure = + PortResult.Failure( + PortError( + code = "TASK_SNAPSHOT_UNAVAILABLE", + message = "Workflow Store snapshot을 읽을 수 없습니다: ${failure.message}", + retryable = true, + ) + ) + null + } + + private fun materializeTask(externalTaskId: ExternalTaskId): Task = + Task( + id = externalTaskId.value, + externalId = externalTaskId, + title = externalTaskId.value, + ) + + private fun failure( + code: String, + message: String, + target: String? = null, + ): PortResult = PortResult.Failure(PortError(code, message, target = target)) +} + +/* + * 외부 Task 연결 adapter의 의도를 드러내는 호환 별칭입니다. + */ +typealias LocalTaskAdapter = SnapshotTaskAdapter diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/SystemClockAdapter.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/SystemClockAdapter.kt new file mode 100644 index 00000000..cc453a83 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/local/SystemClockAdapter.kt @@ -0,0 +1,20 @@ +package io.springkit.workflow.adapter.local + +import io.springkit.workflow.application.ClockPort +import io.springkit.workflow.application.NowRequest +import io.springkit.workflow.application.NowResponse +import io.springkit.workflow.application.PortResult + +/* + * 시스템 시계를 애플리케이션 [ClockPort]로 감싸는 adapter입니다. + */ +class SystemClockAdapter(private val epochMillis: () -> Long = System::currentTimeMillis) : + ClockPort { + override fun now(request: NowRequest): PortResult = + PortResult.Success(NowResponse(epochMillis())) +} + +/* + * 로컬 실행 시계의 의미를 드러내는 호환 별칭입니다. + */ +typealias LocalClockAdapter = SystemClockAdapter diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/provider/ProviderDeploymentAdapter.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/provider/ProviderDeploymentAdapter.kt new file mode 100644 index 00000000..b8504a76 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/provider/ProviderDeploymentAdapter.kt @@ -0,0 +1,454 @@ +package io.springkit.workflow.adapter.provider + +import io.springkit.workflow.application.ChangeReceipt +import io.springkit.workflow.application.ChangeStatus +import io.springkit.workflow.application.CreateCandidateRequest +import io.springkit.workflow.application.CreateCandidateResponse +import io.springkit.workflow.application.DeploymentPort +import io.springkit.workflow.application.GetCandidateRequest +import io.springkit.workflow.application.GetCandidateResponse +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.PromoteProductionRequest +import io.springkit.workflow.application.PromoteProductionResponse +import io.springkit.workflow.application.StartCanaryRequest +import io.springkit.workflow.application.StartCanaryResponse +import io.springkit.workflow.application.ValidateCandidateRequest +import io.springkit.workflow.application.ValidateCandidateResponse +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.common.LocalCommandRunner +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.CandidateId +import io.springkit.workflow.domain.DeploymentCandidate +import io.springkit.workflow.domain.DeploymentCandidateState +import io.springkit.workflow.domain.MainRevision +import io.springkit.workflow.domain.Risk +import io.springkit.workflow.domain.SubTaskId +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.ValidationStatus +import java.nio.file.Path +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + +/* + * 설치된 배포 provider CLI에 배포 명령을 위임하는 [DeploymentPort] 구현입니다. + */ +class ProviderDeploymentAdapter( + commandPrefix: List, + private val workingDirectory: Path, + private val commandRunner: CommandRunner = LocalCommandRunner(), +) : DeploymentPort { + private val commandPrefix: List = commandPrefix.toList() + + override fun createCandidate( + request: CreateCandidateRequest + ): PortResult { + validateCreateRequest(request)?.let { + return it + } + val requestDto = + ProviderCreateCandidateRequest( + mainRevision = request.mainRevision, + includedSubTasks = request.includedSubTasks, + risks = request.risks.mapValues { (_, risk) -> risk.name }, + ) + return invoke("create-candidate", request.mainRevision, requestDto).flatMap { payload -> + PortResult.Success( + CreateCandidateResponse( + candidate = payload.candidate, + change = payload.change ?: receipt("create-candidate", payload.candidate.id), + ) + ) + } + } + + override fun getCandidate(request: GetCandidateRequest): PortResult { + return invoke( + "get-candidate", + request.candidateId, + ProviderCandidateRequest(request.candidateId), + expectedCandidateId = request.candidateId, + ) + .flatMap { payload -> PortResult.Success(GetCandidateResponse(payload.candidate)) } + } + + override fun validate(request: ValidateCandidateRequest): PortResult { + validateCandidateRequest(request.candidateId, request.mainRevision)?.let { + return it + } + return invoke( + "validate-candidate", + request.candidateId, + ProviderValidateCandidateRequest(request.candidateId, request.mainRevision), + expectedCandidateId = request.candidateId, + ) + .flatMap { payload -> + PortResult.Success( + ValidateCandidateResponse( + candidate = payload.candidate, + change = payload.change ?: receipt("validate-candidate", request.candidateId), + ) + ) + } + } + + override fun startCanary(request: StartCanaryRequest): PortResult { + validateCandidateId(request.candidateId)?.let { + return it + } + return invoke( + "start-canary", + request.candidateId, + ProviderStartCanaryRequest(request.candidateId, request.actor?.toDto()), + expectedCandidateId = request.candidateId, + ) + .flatMap { payload -> + PortResult.Success( + StartCanaryResponse( + candidate = payload.candidate, + change = payload.change ?: receipt("start-canary", request.candidateId), + ) + ) + } + } + + override fun promoteProduction( + request: PromoteProductionRequest + ): PortResult { + validateCandidateId(request.candidateId)?.let { + return it + } + return invoke( + "promote-production", + request.candidateId, + ProviderPromoteProductionRequest(request.candidateId, request.expectedState.name), + expectedCandidateId = request.candidateId, + ) + .flatMap { payload -> + PortResult.Success( + PromoteProductionResponse( + candidate = payload.candidate, + change = payload.change ?: receipt("promote-production", request.candidateId), + ) + ) + } + } + + private fun invoke( + operation: String, + target: String, + request: T, + expectedCandidateId: CandidateId? = null, + ): PortResult where T : Any { + if (target.isBlank()) { + return failure( + "DEPLOYMENT_PROVIDER_REQUEST_INVALID", + "배포 대상 식별자가 비어 있습니다.", + target, + ) + } + if (commandPrefix.isEmpty() || commandPrefix.any { it.isBlank() }) { + return failure( + "DEPLOYMENT_PROVIDER_COMMAND_INVALID", + "배포 provider 명령 접두사가 비어 있습니다.", + target, + ) + } + val requestJson = + try { + encodeRequest(request) + } catch (failure: SerializationException) { + return failure( + "DEPLOYMENT_PROVIDER_REQUEST_INVALID", + "배포 provider 요청 JSON을 만들 수 없습니다.", + target, + ) + } catch (failure: IllegalArgumentException) { + return failure( + "DEPLOYMENT_PROVIDER_REQUEST_INVALID", + "배포 provider 요청이 올바르지 않습니다.", + target, + ) + } + val command = commandPrefix + operation + listOf("--request-json", requestJson) + val result = + try { + commandRunner.run(command, workingDirectory) + } catch (failure: Exception) { + return failure( + "DEPLOYMENT_PROVIDER_COMMAND_FAILED", + "배포 provider 명령을 실행할 수 없습니다: ${failure.message ?: failure::class.simpleName}", + target, + retryable = true, + ) + } + if (result.exitCode != 0) { + return failure( + "DEPLOYMENT_PROVIDER_COMMAND_FAILED", + result.stderr + .trim() + .ifBlank { result.stdout.trim() } + .ifBlank { + "배포 provider 명령이 종료 코드 ${result.exitCode}로 실패했습니다." + }, + target, + retryable = result.exitCode == 2, + ) + } + return decodeResponse(result.stdout, target, expectedCandidateId) + } + + private fun encodeRequest(request: Any): String = + when (request) { + is ProviderCreateCandidateRequest -> json.encodeToString(request) + is ProviderCandidateRequest -> json.encodeToString(request) + is ProviderValidateCandidateRequest -> json.encodeToString(request) + is ProviderStartCanaryRequest -> json.encodeToString(request) + is ProviderPromoteProductionRequest -> json.encodeToString(request) + else -> error("지원하지 않는 배포 provider 요청입니다.") + } + + private fun decodeResponse( + stdout: String, + target: String, + expectedCandidateId: CandidateId?, + ): PortResult { + val payload = + try { + json.decodeFromString(stdout) + } catch (failure: SerializationException) { + return failure( + "DEPLOYMENT_PROVIDER_RESPONSE_INVALID", + "배포 provider 응답 JSON을 해석할 수 없습니다.", + target, + ) + } catch (failure: IllegalArgumentException) { + return failure( + "DEPLOYMENT_PROVIDER_RESPONSE_INVALID", + "배포 provider 응답 JSON이 올바르지 않습니다.", + target, + ) + } + val candidateDto = + payload.candidate + ?: return failure( + "DEPLOYMENT_PROVIDER_RESPONSE_INVALID", + "배포 provider 응답에 candidate가 없습니다.", + target, + ) + val candidate = + try { + candidateDto.toDomain() + } catch (failure: IllegalArgumentException) { + return failure( + "DEPLOYMENT_PROVIDER_INVARIANT_VIOLATION", + "배포 provider candidate가 Workflow 불변식을 만족하지 않습니다.", + target, + ) + } + if (expectedCandidateId != null && candidate.id != expectedCandidateId) { + return failure( + "DEPLOYMENT_PROVIDER_INVARIANT_VIOLATION", + "배포 provider가 요청한 후보와 다른 candidate를 반환했습니다.", + target, + ) + } + val change = + try { + payload.change?.toDomain() + } catch (failure: IllegalArgumentException) { + return failure( + "DEPLOYMENT_PROVIDER_INVARIANT_VIOLATION", + "배포 provider change가 Workflow 불변식을 만족하지 않습니다.", + target, + ) + } + return PortResult.Success(ProviderResponse(candidate, change)) + } + + private fun validateCreateRequest(request: CreateCandidateRequest): PortResult.Failure? { + if (request.mainRevision.isBlank()) { + return invalidArgument("배포 후보 main revision이 비어 있습니다.", request.mainRevision) + } + if (request.includedSubTasks.any { it.isBlank() }) { + return invalidArgument("배포 후보 SubTask 식별자가 비어 있습니다.", request.mainRevision) + } + if (request.risks.keys.any { it.isBlank() }) { + return invalidArgument("배포 후보 위험도 대상이 비어 있습니다.", request.mainRevision) + } + return null + } + + private fun validateCandidateRequest( + candidateId: CandidateId, + mainRevision: MainRevision, + ): PortResult.Failure? { + validateCandidateId(candidateId)?.let { + return it + } + if (mainRevision.isBlank()) { + return invalidArgument("배포 후보 main revision이 비어 있습니다.", candidateId) + } + return null + } + + private fun validateCandidateId(candidateId: CandidateId): PortResult.Failure? = + if (candidateId.isBlank()) { + invalidArgument("배포 후보 식별자가 비어 있습니다.", candidateId) + } else { + null + } + + private fun invalidArgument(message: String, target: String): PortResult.Failure = + failure("DEPLOYMENT_PROVIDER_REQUEST_INVALID", message, target) + + private fun receipt(operation: String, target: String): ChangeReceipt = + ChangeReceipt( + id = "provider-deployment-$operation-$target", + operation = "provider-deployment-$operation", + ) + + private fun failure( + code: String, + message: String, + target: String, + retryable: Boolean = false, + ): PortResult.Failure = PortResult.Failure(PortError(code, message, retryable, target)) + + private fun PortResult.flatMap(transform: (T) -> PortResult): PortResult = + when (this) { + is PortResult.Success -> transform(value) + is PortResult.Failure -> this + } + + private companion object { + val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = false + } + } +} + +@Serializable +private data class ProviderCreateCandidateRequest( + @SerialName("main_revision") val mainRevision: MainRevision, + @SerialName("included_subtasks") val includedSubTasks: List, + val risks: Map, +) + +@Serializable +private data class ProviderCandidateRequest( + @SerialName("candidate_id") val candidateId: CandidateId +) + +@Serializable +private data class ProviderValidateCandidateRequest( + @SerialName("candidate_id") val candidateId: CandidateId, + @SerialName("main_revision") val mainRevision: MainRevision, +) + +@Serializable +private data class ProviderStartCanaryRequest( + @SerialName("candidate_id") val candidateId: CandidateId, + val actor: ProviderActor? = null, +) + +@Serializable +private data class ProviderPromoteProductionRequest( + @SerialName("candidate_id") val candidateId: CandidateId, + @SerialName("expected_state") val expectedState: String, +) + +@Serializable +private data class ProviderActor( + val id: String, + val kind: String, + val name: String? = null, +) + +@Serializable +private data class ProviderResponseDto( + val candidate: ProviderCandidateDto? = null, + val change: ProviderChangeDto? = null, +) + +@Serializable +private data class ProviderCandidateDto( + val id: String, + @SerialName("main_revision") val mainRevision: String, + @SerialName("included_subtasks") val includedSubTasks: List, + val risks: Map = emptyMap(), + val validations: List = emptyList(), + val state: DeploymentCandidateState, +) { + fun toDomain(): DeploymentCandidate { + require(id.isNotBlank()) + require(mainRevision.isNotBlank()) + require(includedSubTasks.all { it.isNotBlank() }) + val candidateRisks = risks.map { (subTaskId, risk) -> + require(subTaskId.isNotBlank()) + subTaskId to risk + } + return DeploymentCandidate( + id = id, + mainRevision = mainRevision, + includedSubTasks = includedSubTasks, + risks = candidateRisks.toMap(), + validations = validations.map { it.toDomain() }, + state = state, + ) + } +} + +@Serializable +private data class ProviderValidationDto( + val id: String, + val name: String, + val status: ValidationStatus, + val required: Boolean = true, + val revision: String? = null, + val message: String? = null, +) { + fun toDomain(): Validation = + Validation( + id = id.also { require(it.isNotBlank()) }, + name = name.also { require(it.isNotBlank()) }, + status = status, + required = required, + revision = revision, + message = message, + ) +} + +@Serializable +private data class ProviderChangeDto( + val id: String, + val operation: String, + val status: ChangeStatus, + @SerialName("before_revision") val beforeRevision: String? = null, + @SerialName("after_revision") val afterRevision: String? = null, +) { + init { + require(beforeRevision == null || beforeRevision.isNotBlank()) + require(afterRevision == null || afterRevision.isNotBlank()) + } + + fun toDomain(): ChangeReceipt = + ChangeReceipt( + id = id, + operation = operation, + status = status, + beforeRevision = beforeRevision, + afterRevision = afterRevision, + ) +} + +private data class ProviderResponse( + val candidate: DeploymentCandidate, + val change: ChangeReceipt?, +) + +private fun Actor.toDto(): ProviderActor = ProviderActor(id = id, kind = kind.name, name = name) diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/provider/ProviderReleaseAdapter.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/provider/ProviderReleaseAdapter.kt new file mode 100644 index 00000000..e5a60adc --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/provider/ProviderReleaseAdapter.kt @@ -0,0 +1,419 @@ +package io.springkit.workflow.adapter.provider + +import io.springkit.workflow.application.ChangeReceipt +import io.springkit.workflow.application.ChangeStatus +import io.springkit.workflow.application.ContinueReleaseRequest +import io.springkit.workflow.application.ContinueReleaseResponse +import io.springkit.workflow.application.CreateReleaseRequest +import io.springkit.workflow.application.CreateReleaseResponse +import io.springkit.workflow.application.GetReleaseRequest +import io.springkit.workflow.application.GetReleaseResponse +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.ReleasePort +import io.springkit.workflow.application.StartReleaseRequest +import io.springkit.workflow.application.StartReleaseResponse +import io.springkit.workflow.application.ValidateReleaseRequest +import io.springkit.workflow.application.ValidateReleaseResponse +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.common.LocalCommandRunner +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.Release +import io.springkit.workflow.domain.ReleaseState +import java.nio.file.Path +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json + +/* + * 설치된 외부 공개 provider CLI에 Release 작업을 위임하는 어댑터입니다. + */ +class ProviderReleaseAdapter( + commandPrefix: List, + private val workingDirectory: Path, + private val commandRunner: CommandRunner = LocalCommandRunner(), +) : ReleasePort { + private val commandPrefix: List = commandPrefix.toList() + + override fun get(request: GetReleaseRequest): PortResult { + val target = request.releaseId ?: request.candidateId ?: "all" + validateGetRequest(request)?.let { + return it + } + return execute( + operation = Operation.GET, + requestJson = json.encodeToString(GetRequestPayload.serializer(), request.toPayload()), + target = target, + ) { output -> + val response = json.decodeFromString(output) + GetReleaseResponse(response.releases.map(ReleasePayload::toDomain)) + } + } + + override fun create(request: CreateReleaseRequest): PortResult { + validateCreateRequest(request)?.let { + return it + } + return executeChange( + operation = Operation.CREATE, + requestJson = json.encodeToString(CreateRequestPayload.serializer(), request.toPayload()), + target = request.releaseId, + ) { output, change -> + val response = json.decodeFromString(output) + val release = response.release.toDomain() + require(release.id == request.releaseId) { + "provider returned release ${release.id} for ${request.releaseId}" + } + require(release.candidateId == request.candidateId) { + "provider returned candidate ${release.candidateId} for ${request.candidateId}" + } + CreateReleaseResponse(release, change ?: receipt(Operation.CREATE, request.releaseId)) + } + } + + override fun validateInternal( + request: ValidateReleaseRequest + ): PortResult { + validateReleaseId(request.releaseId)?.let { + return it + } + return executeChange( + operation = Operation.VALIDATE, + requestJson = + json.encodeToString( + ValidateRequestPayload.serializer(), + request.toPayload(), + ), + target = request.releaseId, + ) { output, change -> + val response = json.decodeFromString(output) + val release = response.release.toDomain() + require(release.id == request.releaseId) { + "provider returned release ${release.id} for ${request.releaseId}" + } + ValidateReleaseResponse( + release, + change ?: receipt(Operation.VALIDATE, request.releaseId), + ) + } + } + + override fun start(request: StartReleaseRequest): PortResult { + validateReleaseId(request.releaseId)?.let { + return it + } + return executeChange( + operation = Operation.START, + requestJson = json.encodeToString(StartRequestPayload.serializer(), request.toPayload()), + target = request.releaseId, + ) { output, change -> + val response = json.decodeFromString(output) + val release = response.release.toDomain() + require(release.id == request.releaseId) { + "provider returned release ${release.id} for ${request.releaseId}" + } + StartReleaseResponse(release, change ?: receipt(Operation.START, request.releaseId)) + } + } + + override fun continueRollout( + request: ContinueReleaseRequest + ): PortResult { + validateReleaseId(request.releaseId)?.let { + return it + } + return executeChange( + operation = Operation.CONTINUE, + requestJson = json.encodeToString(ContinueRequestPayload.serializer(), request.toPayload()), + target = request.releaseId, + ) { output, change -> + val response = json.decodeFromString(output) + val release = response.release.toDomain() + require(release.id == request.releaseId) { + "provider returned release ${release.id} for ${request.releaseId}" + } + ContinueReleaseResponse( + release, + change ?: receipt(Operation.CONTINUE, request.releaseId), + ) + } + } + + private fun execute( + operation: Operation, + requestJson: String, + target: String, + decode: (String) -> T, + ): PortResult { + val result = + when (val command = run(operation, requestJson, target)) { + is PortResult.Failure -> return command + is PortResult.Success -> command.value + } + return try { + PortResult.Success(decode(result.stdout)) + } catch (failure: SerializationException) { + invalidResponse(operation, target) + } catch (failure: IllegalArgumentException) { + invariantFailure(operation, target, failure) + } catch (failure: IllegalStateException) { + invariantFailure(operation, target, failure) + } + } + + private fun executeChange( + operation: Operation, + requestJson: String, + target: String, + decode: (String, ChangeReceipt?) -> T, + ): PortResult { + val result = + when (val command = run(operation, requestJson, target)) { + is PortResult.Failure -> return command + is PortResult.Success -> command.value + } + return try { + val envelope = json.decodeFromString(result.stdout) + val change = envelope.change?.toDomain() + PortResult.Success(decode(result.stdout, change)) + } catch (failure: SerializationException) { + invalidResponse(operation, target) + } catch (failure: IllegalArgumentException) { + invariantFailure(operation, target, failure) + } catch (failure: IllegalStateException) { + invariantFailure(operation, target, failure) + } + } + + private fun run( + operation: Operation, + requestJson: String, + target: String, + ): PortResult { + if (commandPrefix.isEmpty() || commandPrefix.any { it.isBlank() }) { + return PortResult.Failure( + PortError( + code = "RELEASE_PROVIDER_COMMAND_INVALID", + message = "외부 공개 provider CLI 명령 접두사가 비어 있거나 공백입니다.", + target = target, + ) + ) + } + val command = commandPrefix + operation.value + "--request-json" + requestJson + return try { + val result = commandRunner.run(command, workingDirectory) + if (result.exitCode == 0) { + PortResult.Success(result) + } else { + PortResult.Failure( + PortError( + code = "RELEASE_PROVIDER_COMMAND_FAILED", + message = + result.stderr + .trim() + .ifBlank { result.stdout.trim() } + .ifBlank { + "외부 공개 provider 명령이 종료 코드 ${result.exitCode}로 실패했습니다." + }, + retryable = result.exitCode == 2, + target = target, + ) + ) + } + } catch (failure: Exception) { + PortResult.Failure( + PortError( + code = "RELEASE_PROVIDER_COMMAND_FAILED", + message = + "외부 공개 provider 명령을 실행할 수 없습니다: " + + (failure.message ?: failure::class.simpleName), + retryable = true, + target = target, + ) + ) + } + } + + private fun invalidResponse( + operation: Operation, + target: String, + ): PortResult.Failure = + PortResult.Failure( + PortError( + code = "RELEASE_PROVIDER_RESPONSE_INVALID", + message = "외부 공개 provider ${operation.value} 응답 JSON을 해석할 수 없습니다.", + target = target, + ) + ) + + private fun invariantFailure( + operation: Operation, + target: String, + failure: Exception, + ): PortResult.Failure = + PortResult.Failure( + PortError( + code = "RELEASE_PROVIDER_INVARIANT_VIOLATION", + message = + "외부 공개 provider ${operation.value} 응답이 Workflow Release 불변식을 위반했습니다: " + + (failure.message ?: "unknown invariant"), + target = target, + ) + ) + + private fun receipt(operation: Operation, target: String): ChangeReceipt = + ChangeReceipt( + id = "provider-release-${operation.value}-$target", + operation = "provider-release-${operation.value}", + ) + + private fun validateGetRequest(request: GetReleaseRequest): PortResult.Failure? { + if (request.releaseId?.isBlank() == true) { + return invalidRequest(request.releaseId) + } + if (request.candidateId?.isBlank() == true) { + return invalidRequest(request.candidateId) + } + return null + } + + private fun validateCreateRequest(request: CreateReleaseRequest): PortResult.Failure? { + if (request.releaseId.isBlank()) return invalidRequest(request.releaseId) + if (request.candidateId.isBlank()) return invalidRequest(request.candidateId) + return null + } + + private fun validateReleaseId(releaseId: String): PortResult.Failure? = + if (releaseId.isBlank()) invalidRequest(releaseId) else null + + private fun invalidRequest(target: String?): PortResult.Failure = + PortResult.Failure( + PortError( + code = "RELEASE_PROVIDER_REQUEST_INVALID", + message = "외부 공개 provider 요청 식별자가 비어 있습니다.", + target = target, + ) + ) + + private enum class Operation(val value: String) { + GET("get-release"), + CREATE("create-release"), + VALIDATE("validate-release"), + START("start-release"), + CONTINUE("continue-rollout"), + } + + private companion object { + val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + } +} + +@Serializable +private data class GetRequestPayload( + @SerialName("release_id") val releaseId: String? = null, + @SerialName("candidate_id") val candidateId: String? = null, +) + +@Serializable +private data class CreateRequestPayload( + @SerialName("release_id") val releaseId: String, + @SerialName("candidate_id") val candidateId: String, +) + +@Serializable +private data class ValidateRequestPayload( + @SerialName("release_id") val releaseId: String, + val actor: ActorPayload? = null, +) + +@Serializable +private data class StartRequestPayload( + @SerialName("release_id") val releaseId: String, + val actor: ActorPayload, +) + +@Serializable +private data class ContinueRequestPayload(@SerialName("release_id") val releaseId: String) + +@Serializable +private data class ActorPayload( + val id: String, + val kind: String, + val name: String? = null, +) + +@Serializable private data class GetResponsePayload(val releases: List) + +@Serializable +private data class ChangeResponsePayload( + val release: ReleasePayload, + val change: ChangePayload? = null, +) + +@Serializable +private data class ReleasePayload( + @SerialName("release_id") val id: String, + @SerialName("candidate_id") val candidateId: String, + val state: ReleaseState = ReleaseState.SAFE_DEFAULT, + @SerialName("production_ready") val productionReady: Boolean = false, + @SerialName("internal_validation_passed") val internalValidationPassed: Boolean = false, + @SerialName("cleanup_sub_task_id") val cleanupSubTaskId: String? = null, +) { + fun toDomain(): Release = + Release( + id = id, + candidateId = candidateId, + state = state, + productionReady = productionReady, + internalValidationPassed = internalValidationPassed, + cleanupSubTaskId = cleanupSubTaskId, + ) +} + +@Serializable +private data class ChangePayload( + val id: String, + val operation: String, + val status: ChangeStatus, + @SerialName("before_revision") val beforeRevision: String? = null, + @SerialName("after_revision") val afterRevision: String? = null, +) { + init { + require(beforeRevision == null || beforeRevision.isNotBlank()) + require(afterRevision == null || afterRevision.isNotBlank()) + } + + fun toDomain(): ChangeReceipt = + ChangeReceipt( + id = id, + operation = operation, + status = status, + beforeRevision = beforeRevision, + afterRevision = afterRevision, + ) +} + +private fun GetReleaseRequest.toPayload(): GetRequestPayload = + GetRequestPayload(releaseId = releaseId, candidateId = candidateId) + +private fun CreateReleaseRequest.toPayload(): CreateRequestPayload = + CreateRequestPayload( + releaseId = releaseId, + candidateId = candidateId, + ) + +private fun ValidateReleaseRequest.toPayload(): ValidateRequestPayload = + ValidateRequestPayload(releaseId = releaseId, actor = actor?.toPayload()) + +private fun StartReleaseRequest.toPayload(): StartRequestPayload = + StartRequestPayload(releaseId = releaseId, actor = actor.toPayload()) + +private fun ContinueReleaseRequest.toPayload(): ContinueRequestPayload = + ContinueRequestPayload(releaseId = releaseId) + +private fun Actor.toPayload(): ActorPayload = ActorPayload(id = id, kind = kind.name, name = name) diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/store/OkioWorkflowStoreAdapter.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/store/OkioWorkflowStoreAdapter.kt new file mode 100644 index 00000000..21f0d8ca --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/store/OkioWorkflowStoreAdapter.kt @@ -0,0 +1,479 @@ +package io.springkit.workflow.adapter.store + +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.StoreEventRequest +import io.springkit.workflow.application.StoreEventResponse +import io.springkit.workflow.application.StoreScope +import io.springkit.workflow.application.StoreSnapshotRequest +import io.springkit.workflow.application.StoreSnapshotResponse +import io.springkit.workflow.application.StoreTransactionRequest +import io.springkit.workflow.application.StoreTransactionResponse +import io.springkit.workflow.application.StoreTransactionState +import io.springkit.workflow.application.StoreWriteRequest +import io.springkit.workflow.application.StoreWriteResponse +import io.springkit.workflow.application.WorkflowStorePort +import io.springkit.workflow.application.WorkflowStoreSnapshot +import io.springkit.workflow.common.ExclusiveFileLock +import io.springkit.workflow.domain.Dependency +import io.springkit.workflow.domain.WorkflowState +import java.io.IOException +import java.util.UUID +import okio.FileSystem +import okio.Path +import okio.Path.Companion.toPath +import okio.buffer + +/* + * 원자적으로 교체하는 하나의 Okio JSON 문서로 영속화하는 [WorkflowStorePort] 구현입니다. + */ +class OkioWorkflowStoreAdapter( + private val statePath: Path, + private val fileSystem: FileSystem = FileSystem.SYSTEM, + lockPath: Path? = null, +) : WorkflowStorePort { + constructor( + fileSystem: FileSystem, + statePath: Path, + lockPath: Path? = null, + ) : this( + statePath, + fileSystem, + lockPath, + ) + + private val lockPath: Path = lockPath ?: defaultLockPath(statePath) + private val transactions = mutableMapOf() + private val completed = mutableMapOf() + private val completedKeys = mutableMapOf() + + /* + * `FakeFileSystem`은 호스트 파일 시스템과 연결되지 않습니다. 따라서 실제 프로세스 간 잠금은 Okio 시스템 파일 시스템에서만 사용하며, + * `FileChannel` 자체의 격리는 [ExclusiveFileLock]이 담당합니다. + */ + private val useProcessLock: Boolean = fileSystem === FileSystem.SYSTEM + + @Synchronized + override fun begin(request: StoreTransactionRequest): PortResult { + completed[request.transactionId]?.let { previous -> + return if (completedKeys[request.transactionId] == request.idempotencyKey) { + PortResult.Success(previous) + } else { + failure(Codes.IDEMPOTENCY_CONFLICT, "transaction id was already completed") + } + } + transactions[request.transactionId]?.let { open -> + return if (open.idempotencyKey == request.idempotencyKey) { + PortResult.Success( + StoreTransactionResponse( + request.transactionId, + StoreTransactionState.OPEN, + open.base.storeRevision.toString(), + ) + ) + } else { + failure(Codes.IDEMPOTENCY_CONFLICT, "transaction id is already open") + } + } + + val current = readStateResult() ?: return lastReadFailure + val revision = current.storeRevision.toString() + if (request.expectedRevision != null && request.expectedRevision != revision) { + return failure( + Codes.STALE_REVISION, + "expected store revision ${request.expectedRevision}, current revision is $revision", + retryable = true, + ) + } + + val lock = + if (useProcessLock) { + try { + ExclusiveFileLock.tryAcquire(lockPath) + } catch (failure: IOException) { + return failure(Codes.STORE_IO_FAILURE, "could not open store lock: ${failure.message}") + } + } else { + null + } + if (useProcessLock && lock == null) { + return failure(Codes.LOCK_UNAVAILABLE, "workflow store is locked", retryable = true) + } + + /* + * 잠금을 얻은 뒤 revision을 확인해야 합니다. 최초 읽기와 잠금 획득 사이에 다른 프로세스가 커밋할 수 있습니다. + */ + val lockedState = + if (useProcessLock) { + try { + readState() + } catch (failure: Throwable) { + lock?.close() + return readFailure(failure) + } + } else { + current + } + val lockedRevision = lockedState.storeRevision.toString() + if (request.expectedRevision != null && request.expectedRevision != lockedRevision) { + lock?.close() + return failure( + Codes.STALE_REVISION, + "expected store revision ${request.expectedRevision}, current revision is $lockedRevision", + retryable = true, + ) + } + transactions[request.transactionId] = + Transaction(request.transactionId, request.idempotencyKey, lockedState, lock) + return PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.OPEN, lockedRevision) + ) + } + + @Synchronized + override fun snapshot(request: StoreSnapshotRequest): PortResult { + val state = readStateResult() ?: return lastReadFailure + return try { + PortResult.Success(StoreSnapshotResponse(state.toSnapshot().scoped(request))) + } catch (failure: IllegalArgumentException) { + failure(Codes.INVALID_SNAPSHOT, failure.message ?: "invalid workflow state") + } + } + + @Synchronized + override fun write(request: StoreWriteRequest): PortResult { + val transaction = transactions[request.transactionId] ?: return transactionMissing() + val current = currentLockedState(transaction) ?: return lastReadFailure + val currentRevision = current.storeRevision.toString() + if ( + currentRevision != transaction.base.storeRevision.toString() || + request.expectedRevision != currentRevision + ) { + return failure( + Codes.STALE_REVISION, + "expected store revision ${request.expectedRevision}, current revision is $currentRevision", + retryable = true, + ) + } + if (request.snapshot.revision != request.expectedRevision) { + return failure( + Codes.STALE_REVISION, + "snapshot revision ${request.snapshot.revision} does not match expected revision ${request.expectedRevision}", + retryable = true, + ) + } + val next = + try { + stateFromSnapshot(request.snapshot).copy(storeRevision = current.storeRevision + 1) + } catch (failure: IllegalArgumentException) { + return failure(Codes.INVALID_SNAPSHOT, failure.message ?: "invalid workflow snapshot") + } + transaction.pending = next + return PortResult.Success(StoreWriteResponse(next.storeRevision.toString())) + } + + @Synchronized + override fun append(request: StoreEventRequest): PortResult { + val transaction = transactions[request.transactionId] ?: return transactionMissing() + val base = transaction.pending ?: transaction.base + val next = + base.copy( + storeRevision = base.storeRevision + 1, + eventLog = base.eventLog.append(request.event, request.audit), + ) + transaction.pending = next + return PortResult.Success(StoreEventResponse(request.event, next.storeRevision.toString())) + } + + @Synchronized + override fun commit(request: StoreTransactionRequest): PortResult { + val transaction = transactions[request.transactionId] + if (transaction == null) { + return completed[request.transactionId]?.let { PortResult.Success(it) } + ?: transactionMissing() + } + val current = currentLockedState(transaction) ?: return lastReadFailure + if ( + current.storeRevision != transaction.base.storeRevision || + (request.expectedRevision != null && + request.expectedRevision != current.storeRevision.toString()) + ) { + return failure( + Codes.STALE_REVISION, + "store revision changed while transaction was open", + retryable = true, + ) + } + val committed = transaction.pending ?: current + try { + if (transaction.pending != null) writeAtomically(committed, request.transactionId) + } catch (failure: Throwable) { + return writeFailure(failure) + } + val response = + StoreTransactionResponse( + request.transactionId, + StoreTransactionState.COMMITTED, + committed.storeRevision.toString(), + ) + transactions.remove(request.transactionId) + completed[request.transactionId] = response + completedKeys[request.transactionId] = transaction.idempotencyKey + transaction.lock?.close() + return PortResult.Success(response) + } + + @Synchronized + override fun rollback(request: StoreTransactionRequest): PortResult { + val transaction = transactions.remove(request.transactionId) + if (transaction == null) { + return completed[request.transactionId]?.let { PortResult.Success(it) } + ?: transactionMissing() + } + val response = + StoreTransactionResponse( + request.transactionId, + StoreTransactionState.ROLLED_BACK, + transaction.base.storeRevision.toString(), + ) + completed[request.transactionId] = response + completedKeys[request.transactionId] = transaction.idempotencyKey + transaction.lock?.close() + return PortResult.Success(response) + } + + private fun currentLockedState(transaction: Transaction): WorkflowState? = + try { + if (useProcessLock) readState() else transaction.base + } catch (failure: Throwable) { + lastReadFailure = readFailure(failure) + null + } + + private fun readStateResult(): WorkflowState? = + try { + readState() + } catch (failure: Throwable) { + lastReadFailure = readFailure(failure) + null + } + + private fun readState(): WorkflowState { + if (!fileSystem.exists(statePath)) return WorkflowState() + val json = fileSystem.source(statePath).buffer().use { it.readUtf8() } + return WorkflowStateJsonCodec.decode(json) + } + + private fun writeAtomically(state: WorkflowState, transactionId: String) { + val parent = statePath.parent + if (parent != null) fileSystem.createDirectories(parent) + val temporary = + siblingPath( + statePath, + ".${statePath.name}.tmp-${safeFilePart(transactionId)}-${UUID.randomUUID()}", + ) + try { + fileSystem.sink(temporary).buffer().use { + it.writeUtf8(WorkflowStateJsonCodec.encodeToString(state)) + } + fileSystem.atomicMove(temporary, statePath) + } catch (failure: Throwable) { + runCatching { fileSystem.delete(temporary) } + throw failure + } + } + + private fun stateFromSnapshot(snapshot: WorkflowStoreSnapshot): WorkflowState { + require(snapshot.schemaVersion == WorkflowStateJsonCodec.CURRENT_SCHEMA_VERSION) { + "unsupported store snapshot schema version: ${snapshot.schemaVersion}" + } + val revision = snapshot.revision.toLongOrNull() + require(revision != null && revision >= 0) { "store revision must be a non-negative number" } + + requireUnique(snapshot.tasks.map { it.id }, "task") + requireUnique(snapshot.subTasks.map { it.id }, "subtask") + requireUnique(snapshot.pullRequests.map { it.id }, "pull request") + requireUnique(snapshot.workspaces.map { it.id }, "workspace") + requireUnique(snapshot.dependencies.map { it.subTaskId }, "dependency subtask") + requireUnique(snapshot.mergeQueue.map { it.id }, "merge queue") + requireUnique(snapshot.candidates.map { it.id }, "candidate") + requireUnique(snapshot.releases.map { it.id }, "release") + + val subTasks = snapshot.subTasks.associateBy { it.id } + val expectedDependencies = + subTasks.values + .mapNotNull { it.requires?.let { required -> Dependency(it.id, required) } } + .toSet() + require(snapshot.dependencies.toSet() == expectedDependencies) { + "snapshot dependencies must match subtask requires" + } + val expectedWorkspaces = subTasks.values.mapNotNull { it.workspace }.associateBy { it.id } + val actualWorkspaces = snapshot.workspaces.associateBy { it.id } + require(actualWorkspaces == expectedWorkspaces) { + "snapshot workspaces must match subtask workspaces" + } + + return WorkflowState( + schemaVersion = snapshot.schemaVersion, + storeRevision = revision, + sequence = snapshot.sequence, + tasks = snapshot.tasks.associateBy { it.id }, + subTasks = subTasks, + pullRequests = snapshot.pullRequests.associateBy { it.id }, + checks = snapshot.checks, + integrations = snapshot.integrations.associateBy { it.subTaskId }, + mergeQueue = snapshot.mergeQueue.associateBy { it.id }, + deploymentCandidates = snapshot.candidates.associateBy { it.id }, + releases = snapshot.releases.associateBy { it.id }, + startRequests = snapshot.startRequests, + syncConflicts = snapshot.syncConflicts, + eventLog = snapshot.eventLog, + ) + } + + private fun WorkflowState.toSnapshot(): WorkflowStoreSnapshot { + val subTaskValues = subTasks.values.sortedBy { it.id } + return WorkflowStoreSnapshot( + revision = storeRevision.toString(), + schemaVersion = schemaVersion, + sequence = sequence, + tasks = tasks.values.sortedBy { it.id }, + subTasks = subTaskValues, + dependencies = + subTaskValues.mapNotNull { + it.requires?.let { required -> Dependency(it.id, required) } + }, + workspaces = subTaskValues.mapNotNull { it.workspace }.sortedBy { it.id }, + pullRequests = pullRequests.values.sortedBy { it.id }, + checks = checks, + integrations = integrations.values.sortedBy { it.subTaskId }, + mergeQueue = mergeQueue.values.sortedBy { it.id }, + candidates = deploymentCandidates.values.sortedBy { it.id }, + releases = releases.values.sortedBy { it.id }, + startRequests = startRequests, + syncConflicts = syncConflicts, + eventLog = eventLog, + ) + } + + private fun WorkflowStoreSnapshot.scoped(request: StoreSnapshotRequest): WorkflowStoreSnapshot { + if (request.scope == StoreScope.ALL) return this + val taskIds = request.taskId?.let(::setOf) ?: tasks.map { it.id }.toSet() + val subTaskIds = + when { + request.subTaskId != null -> setOf(request.subTaskId) + request.taskId != null -> subTasks.filter { it.taskId in taskIds }.map { it.id }.toSet() + else -> subTasks.map { it.id }.toSet() + } + return when (request.scope) { + StoreScope.TASK -> + copy( + tasks = tasks.filter { it.id in taskIds }, + subTasks = subTasks.filter { it.id in subTaskIds }, + ) + StoreScope.SUBTASK -> copy(subTasks = subTasks.filter { it.id in subTaskIds }) + StoreScope.WORKSPACE -> + copy( + subTasks = subTasks.filter { it.id in subTaskIds }, + workspaces = workspaces.filter { it.subTaskId in subTaskIds }, + ) + StoreScope.REVIEW -> + copy( + subTasks = subTasks.filter { it.id in subTaskIds }, + pullRequests = pullRequests.filter { it.subTaskId in subTaskIds }, + ) + StoreScope.INTEGRATION -> + copy( + subTasks = subTasks.filter { it.id in subTaskIds }, + integrations = integrations.filter { it.subTaskId in subTaskIds }, + mergeQueue = mergeQueue.filter { it.subTaskId in subTaskIds }, + ) + StoreScope.DEPLOYMENT -> + copy( + candidates = + candidates.filter { request.candidateId == null || it.id == request.candidateId } + ) + StoreScope.RELEASE -> + copy( + releases = releases.filter { request.releaseId == null || it.id == request.releaseId } + ) + StoreScope.ALL -> this + } + } + + private fun requireUnique(values: List, label: String) { + require(values.distinct().size == values.size) { "$label ids must be unique" } + } + + private fun transactionMissing(): PortResult = + failure(Codes.TRANSACTION_NOT_FOUND, "store transaction is not open") + + private fun failure( + code: String, + message: String, + retryable: Boolean = false, + target: String? = null, + ): PortResult = PortResult.Failure(PortError(code, message, retryable, target)) + + private fun readFailure(failure: Throwable): PortResult = + when (failure) { + is StateDecodeException -> + PortResult.Failure( + PortError(Codes.STORE_CORRUPT, failure.message ?: "invalid store JSON") + ) + is IllegalArgumentException -> + PortResult.Failure( + PortError(Codes.STORE_CORRUPT, failure.message ?: "invalid workflow state") + ) + else -> + PortResult.Failure( + PortError( + Codes.STORE_IO_FAILURE, + failure.message ?: "could not read workflow store", + retryable = true, + ) + ) + } + + private fun writeFailure(failure: Throwable): PortResult = + PortResult.Failure( + PortError( + Codes.STORE_IO_FAILURE, + failure.message ?: "could not commit workflow store", + retryable = true, + ) + ) + + private fun safeFilePart(value: String): String = value.replace(Regex("[^A-Za-z0-9._-]"), "_") + + private var lastReadFailure: PortResult = + PortResult.Failure( + PortError(Codes.STORE_IO_FAILURE, "could not read workflow store", retryable = true) + ) + + private data class Transaction( + val id: String, + val idempotencyKey: String?, + val base: WorkflowState, + val lock: ExclusiveFileLock?, + var pending: WorkflowState? = null, + ) + + private object Codes { + const val IDEMPOTENCY_CONFLICT = "IDEMPOTENCY_CONFLICT" + const val INVALID_SNAPSHOT = "INVALID_SNAPSHOT" + const val LOCK_UNAVAILABLE = "LOCK_UNAVAILABLE" + const val STALE_REVISION = "STALE_REVISION" + const val STORE_CORRUPT = "STORE_CORRUPT" + const val STORE_IO_FAILURE = "STORE_IO_FAILURE" + const val TRANSACTION_NOT_FOUND = "TRANSACTION_NOT_FOUND" + } + + companion object { + private fun defaultLockPath(statePath: Path): Path = + siblingPath(statePath, ".${statePath.name}.lock") + + private fun siblingPath(path: Path, name: String): Path = + path.parent?.resolve(name) ?: name.toPath() + } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/store/StoreIdAdapter.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/store/StoreIdAdapter.kt new file mode 100644 index 00000000..bc87ea85 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/store/StoreIdAdapter.kt @@ -0,0 +1,154 @@ +package io.springkit.workflow.adapter.store + +import io.springkit.workflow.application.IdKind +import io.springkit.workflow.application.IdPort +import io.springkit.workflow.application.IssueIdRequest +import io.springkit.workflow.application.IssueIdResponse +import io.springkit.workflow.application.IssuedId +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.StoreSnapshotRequest +import io.springkit.workflow.application.StoreTransactionRequest +import io.springkit.workflow.application.StoreTransactionState +import io.springkit.workflow.application.StoreWriteRequest +import io.springkit.workflow.application.WorkflowStorePort +import io.springkit.workflow.domain.IdSequence +import java.util.UUID + +/* + * Workflow Store의 sequence를 트랜잭션으로 증가시켜 식별자를 발급합니다. + */ +class StoreIdAdapter(private val store: WorkflowStorePort) : IdPort { + override fun issue(request: IssueIdRequest): PortResult = + synchronized(store) { + when (request.kind) { + IdKind.REVIEW_REVISION, + IdKind.CHANGE_REVISION, + IdKind.THREAD, + IdKind.COMMENT, + IdKind.MERGE_QUEUE, + IdKind.CANDIDATE, + IdKind.RELEASE, + IdKind.AUDIT -> issueSequenced(request) + else -> issueInfrastructure(request) + } + } + + private fun issueSequenced(request: IssueIdRequest): PortResult { + val transactionId = "id-${UUID.randomUUID()}" + val transaction = StoreTransactionRequest(transactionId, idempotencyKey = transactionId) + when (val begun = store.begin(transaction)) { + is PortResult.Failure -> return begun + is PortResult.Success -> + if (begun.value.state != StoreTransactionState.OPEN) { + return failure("ID_TRANSACTION_NOT_OPEN", "ID 발급 트랜잭션을 열 수 없습니다.") + } + } + + val current = + when (val result = store.snapshot(StoreSnapshotRequest())) { + is PortResult.Failure -> return rollbackAfterFailure(transaction, result) + is PortResult.Success -> result.value.snapshot + } + val currentValue = current.sequence.valueOf(request.kind) + val lastValue = currentValue.let { value -> + if (value > Long.MAX_VALUE - request.count.toLong()) { + return rollbackAfterFailure( + transaction, + failure("ID_SEQUENCE_EXHAUSTED", "식별자 sequence가 최대값에 도달했습니다."), + ) + } + value + request.count + } + val updated = current.copy(sequence = current.sequence.withValue(request.kind, lastValue)) + val written = + store.write( + StoreWriteRequest( + transactionId = transactionId, + expectedRevision = current.revision, + snapshot = updated, + ) + ) + if (written is PortResult.Failure) return rollbackAfterFailure(transaction, written) + + val committed = store.commit(transaction) + if (committed is PortResult.Failure) return rollbackAfterFailure(transaction, committed) + + val ids = + (currentValue + 1..lastValue).map { value -> + IssuedId(request.kind, "${request.kind.prefix()}-$value") + } + return PortResult.Success(IssueIdResponse(ids)) + } + + private fun issueInfrastructure(request: IssueIdRequest): PortResult { + val suffix = sanitize(request.requestId ?: request.kind.name.lowercase()) + val ids = + (1..request.count).map { + IssuedId(request.kind, "${request.kind.prefix()}-${UUID.randomUUID()}-$suffix") + } + return PortResult.Success(IssueIdResponse(ids)) + } + + private fun rollbackAfterFailure( + transaction: StoreTransactionRequest, + failure: PortResult.Failure, + ): PortResult.Failure { + store.rollback(transaction) + return failure + } + + private fun failure(code: String, message: String): PortResult.Failure = + PortResult.Failure(PortError(code, message, retryable = code == "ID_SEQUENCE_EXHAUSTED")) + + private fun sanitize(value: String): String = + value.replace(Regex("[^A-Za-z0-9._-]+"), "-").trim('-').ifBlank { "request" } + + private fun IdKind.prefix(): String = + when (this) { + IdKind.REVIEW_REVISION -> "rv" + IdKind.CHANGE_REVISION -> "cr" + IdKind.THREAD -> "thread" + IdKind.COMMENT -> "comment" + IdKind.MERGE_QUEUE -> "mq" + IdKind.CANDIDATE -> "dc" + IdKind.RELEASE -> "rel" + IdKind.AUDIT -> "audit" + IdKind.TASK -> "task" + IdKind.SUBTASK -> "subtask" + IdKind.WORKSPACE -> "ws" + IdKind.BRANCH -> "branch" + IdKind.PULL_REQUEST -> "pr" + IdKind.VALIDATION -> "validation" + IdKind.CHECK -> "check" + IdKind.TRANSACTION -> "tx" + IdKind.EVENT -> "event" + IdKind.COMPENSATION -> "compensation" + } + + private fun IdSequence.valueOf(kind: IdKind): Long = + when (kind) { + IdKind.REVIEW_REVISION -> review + IdKind.CHANGE_REVISION -> change + IdKind.THREAD -> thread + IdKind.COMMENT -> comment + IdKind.MERGE_QUEUE -> mergeQueue + IdKind.CANDIDATE -> candidate + IdKind.RELEASE -> release + IdKind.AUDIT -> audit + else -> error("sequence is not defined for $kind") + } + + private fun IdSequence.withValue(kind: IdKind, value: Long): IdSequence = + when (kind) { + IdKind.REVIEW_REVISION -> copy(review = value) + IdKind.CHANGE_REVISION -> copy(change = value) + IdKind.THREAD -> copy(thread = value) + IdKind.COMMENT -> copy(comment = value) + IdKind.MERGE_QUEUE -> copy(mergeQueue = value) + IdKind.CANDIDATE -> copy(candidate = value) + IdKind.RELEASE -> copy(release = value) + IdKind.AUDIT -> copy(audit = value) + else -> error("sequence is not defined for $kind") + } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/store/WorkflowStateJsonCodec.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/store/WorkflowStateJsonCodec.kt new file mode 100644 index 00000000..5d153470 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/store/WorkflowStateJsonCodec.kt @@ -0,0 +1,87 @@ +package io.springkit.workflow.adapter.store + +import io.springkit.workflow.adapter.json.WorkflowJson +import io.springkit.workflow.domain.WorkflowState +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.encodeToJsonElement + +/* + * 영속 Workflow 상태를 위한 JSON 코덱입니다. + */ +object WorkflowStateJsonCodec { + const val CURRENT_SCHEMA_VERSION: Int = 1 + + private val serializer = WorkflowState.serializer() + + fun encodeToString(state: WorkflowState): String = + WorkflowJson.format.encodeToString(serializer, state) + + fun decode(json: String): WorkflowState = decodeSafely { + val element = WorkflowJson.format.parseToJsonElement(json) + WorkflowJson.format.decodeFromJsonElement(serializer, withoutLegacyFeatureFlagFields(element)) + } + + fun decode(json: ByteArray): WorkflowState = decode(json.toString(Charsets.UTF_8)) + + fun encodeToJsonElement(state: WorkflowState): JsonElement = + WorkflowJson.format.encodeToJsonElement(serializer, state) + + fun decode(json: JsonElement): WorkflowState = decodeSafely { + WorkflowJson.format.decodeFromJsonElement(serializer, withoutLegacyFeatureFlagFields(json)) + } + + private fun withoutLegacyFeatureFlagFields(element: JsonElement): JsonElement { + val root = element as? JsonObject ?: return element + val fieldsByCollection = + mapOf( + "sub_tasks" to setOf("exposure", "feature_flag_id"), + "pull_requests" to setOf("exposure", "feature_flag_id"), + "releases" to setOf("feature_flag_id"), + ) + return JsonObject( + root.mapValues { (collection, values) -> + val legacyFields = fieldsByCollection[collection] ?: return@mapValues values + val records = values as? JsonObject ?: return@mapValues values + JsonObject( + records.mapValues { (_, record) -> + val fields = record as? JsonObject ?: return@mapValues record + JsonObject(fields.filterKeys { it !in legacyFields }) + } + ) + } + ) + } + + private fun decodeSafely(read: () -> WorkflowState): WorkflowState = + try { + read().also { state -> + if (state.schemaVersion != CURRENT_SCHEMA_VERSION) { + throw StateDecodeException( + "unsupported workflow state schema version: ${state.schemaVersion}" + ) + } + } + } catch (exception: StateDecodeException) { + throw exception + } catch (exception: SerializationException) { + throw StateDecodeException("invalid workflow state JSON", exception) + } catch (exception: IllegalArgumentException) { + throw StateDecodeException( + "invalid workflow state: ${exception.message ?: "invariant violation"}", + exception, + ) + } +} + +/* + * 직렬화된 상태가 잘못되었거나 Workflow 상태 불변식을 위반했음을 나타냅니다. + */ +class StateDecodeException(message: String, cause: Throwable? = null) : + SerializationException(message) { + init { + cause?.let(::initCause) + } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/validation/LocalValidationAdapter.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/validation/LocalValidationAdapter.kt new file mode 100644 index 00000000..e01d3eab --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/adapter/validation/LocalValidationAdapter.kt @@ -0,0 +1,144 @@ +package io.springkit.workflow.adapter.validation + +import io.springkit.workflow.application.ChangeReceipt +import io.springkit.workflow.application.GetValidationRequest +import io.springkit.workflow.application.GetValidationResponse +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.RunValidationRequest +import io.springkit.workflow.application.RunValidationResponse +import io.springkit.workflow.application.ValidationPort +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.common.LocalCommandRunner +import io.springkit.workflow.domain.CheckResult +import io.springkit.workflow.domain.CheckSummary +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.ValidationStatus +import io.springkit.workflow.domain.WorkspaceId +import java.nio.file.Files +import java.nio.file.Path + +/* + * 로컬 Gradle 명령에 검증 실행을 위임하는 어댑터입니다. + */ +class LocalValidationAdapter( + private val workspacePathResolver: (WorkspaceId) -> Path, + private val validationCommands: Map> = DEFAULT_COMMANDS, + private val commandRunner: CommandRunner = LocalCommandRunner(), + private val existingResultLookup: + ((GetValidationRequest) -> PortResult)? = + null, +) : ValidationPort { + override fun run(request: RunValidationRequest): PortResult { + val workspacePath = + resolveWorkspace(request.workspaceId) ?: return workspacePathFailure(request) + val validations = mutableListOf() + + for (required in request.required) { + val command = validationCommands[required.name] + if (command == null || command.isEmpty() || command.any { it.isBlank() }) { + return PortResult.Failure( + PortError( + code = "VALIDATION_COMMAND_NOT_CONFIGURED", + message = "검증 명령을 확인할 수 없습니다: ${required.name}", + target = required.name, + ) + ) + } + + val commandWorkspace = commandWorkingDirectory(command, workspacePath) + val execution = runCatching { commandRunner.run(command, commandWorkspace) } + validations += + execution.fold( + onSuccess = { required.toValidation(it, request.revision) }, + onFailure = { required.failedValidation(request.revision, it) }, + ) + } + + val checks = validations.map { validation -> + CheckResult( + id = validation.id, + name = validation.name, + status = validation.status, + fingerprint = request.fingerprint, + revision = request.revision, + message = validation.message, + ) + } + return PortResult.Success( + RunValidationResponse( + validations = validations, + summary = CheckSummary(request.fingerprint, request.revision, checks), + change = + ChangeReceipt( + id = "validation-${request.workspaceId}-${request.fingerprint}", + operation = "run-validation", + ), + ) + ) + } + + override fun get(request: GetValidationRequest): PortResult = + existingResultLookup?.invoke(request) + ?: PortResult.Success(GetValidationResponse(emptyList(), emptyList())) + + private fun resolveWorkspace(workspaceId: WorkspaceId): Path? = + try { + workspacePathResolver(workspaceId) + } catch (_: Exception) { + null + } + + private fun commandWorkingDirectory(command: List, workspacePath: Path): Path { + if (command.firstOrNull() != "./gradlew") return workspacePath + if (Files.isRegularFile(workspacePath.resolve("gradlew"))) return workspacePath + val moduleWrapper = workspacePath.resolve("tools").resolve("workflow").resolve("gradlew") + return if (Files.isRegularFile(moduleWrapper)) moduleWrapper.parent else workspacePath + } + + private fun workspacePathFailure(request: RunValidationRequest): PortResult.Failure = + PortResult.Failure( + PortError( + code = "WORKSPACE_PATH_UNAVAILABLE", + message = "workspace 경로를 확인할 수 없습니다: ${request.workspaceId}", + target = request.workspaceId, + ) + ) + + private fun Validation.toValidation(result: CommandResult, revision: String): Validation = + if (result.exitCode == 0) { + copy(status = ValidationStatus.PASSED, revision = revision, message = null) + } else { + copy( + status = ValidationStatus.FAILED, + revision = revision, + message = result.failureMessage(), + ) + } + + private fun Validation.failedValidation(revision: String, exception: Throwable): Validation = + copy( + status = ValidationStatus.FAILED, + revision = revision, + message = "검증 명령 실행 중 예외가 발생했습니다: ${exception.message ?: exception::class.simpleName}", + ) + + private fun CommandResult.failureMessage(): String { + val output = stderr.trim().ifBlank { stdout.trim() } + return if (output.isBlank()) { + "명령이 종료 코드 ${exitCode}로 실패했습니다." + } else { + "명령이 종료 코드 ${exitCode}로 실패했습니다: $output" + } + } + + companion object { + val DEFAULT_COMMANDS: Map> = + mapOf( + "static" to listOf("./gradlew", "spotlessCheck"), + "test" to listOf("./gradlew", "test"), + "build" to listOf("./gradlew", "build"), + ) + } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/application/DeliveryGateUseCases.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/DeliveryGateUseCases.kt new file mode 100644 index 00000000..36c24186 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/DeliveryGateUseCases.kt @@ -0,0 +1,406 @@ +package io.springkit.workflow.application + +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.AuditEntry +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.CandidateId +import io.springkit.workflow.domain.DeploymentCandidate +import io.springkit.workflow.domain.DeploymentCandidateState +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.GateRecorded +import io.springkit.workflow.domain.GateType +import io.springkit.workflow.domain.Release +import io.springkit.workflow.domain.ReleaseId +import io.springkit.workflow.domain.ReleaseState +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.WorkflowRules + +/* + * 고위험 배포와 외부 공개를 시작하기 위한 사람 Gate 요청입니다. + */ +data class DeployGateRequest( + val candidateId: CandidateId, + val requestId: String = "deploy-$candidateId", +) { + init { + require(candidateId.isNotBlank()) { "deployment candidate id must not be blank" } + require(requestId.isNotBlank()) { "deployment gate request id must not be blank" } + } +} + +/* + * Release를 시작하기 위한 사람 Gate 요청입니다. + */ +data class ReleaseGateRequest( + val releaseId: ReleaseId, + val requestId: String = "release-$releaseId", +) { + init { + require(releaseId.isNotBlank()) { "release id must not be blank" } + require(requestId.isNotBlank()) { "release gate request id must not be blank" } + } +} + +/* + * 배포와 공개 Gate 유스케이스를 하나의 애플리케이션 경계로 제공합니다. + */ +class DeliveryGateUseCases( + private val storePort: WorkflowStorePort, + private val deploymentPort: DeploymentPort, + private val releasePort: ReleasePort, + private val identityPort: IdentityPort, + private val idPort: IdPort? = null, + private val clockPort: ClockPort? = null, + private val compensationPort: CompensationPort = NoOpCompensationPort, +) { + /* + * 사람의 배포 승인을 확인하고 고위험 후보의 Production Canary를 시작합니다. + */ + fun deploy(request: DeployGateRequest): WorkflowResult = + executeGate( + requestId = request.requestId, + targetId = request.candidateId, + capability = Capability.DEPLOY, + gate = GateType.DEPLOY, + ) { actor, snapshot -> + val stored = snapshot.candidates.firstOrNull { it.id == request.candidateId } + if (stored == null) { + return@executeGate failurePort( + FailureCode.DEPLOYMENT_NOT_FOUND, + "deployment candidate was not found", + request.candidateId, + ) + } + + val current = + when ( + val result = deploymentPort.getCandidate(GetCandidateRequest(request.candidateId)) + ) { + is PortResult.Failure -> return@executeGate result + is PortResult.Success -> result.value.candidate + } + validateDeploymentProviderResult(current, stored, stored.state)?.let { + return@executeGate failurePort(FailureCode.INVARIANT_VIOLATION, it, request.candidateId) + } + + gateFailure(GateType.DEPLOY, actor, stored, current, request.candidateId)?.let { + return@executeGate it + } + val started = + when (val result = deploymentPort.startCanary(StartCanaryRequest(current.id, actor))) { + is PortResult.Failure -> return@executeGate result + is PortResult.Success -> result.value + } + validateDeploymentProviderResult( + started.candidate, + current, + DeploymentCandidateState.CANARY, + ) + ?.let { + return@executeGate failurePort( + FailureCode.INVARIANT_VIOLATION, + it, + request.candidateId, + started.change, + ) + } + + val occurredAt = now(request.requestId) + val event = GateRecorded(current.id, GateType.DEPLOY, actor, occurredAt) + val audit = + AuditEntry( + id = auditId(request.requestId, GateType.DEPLOY, current.id), + actor = actor, + action = "deploy", + targetId = current.id, + mainRevision = current.mainRevision, + occurredAtEpochMillis = occurredAt, + ) + PortResult.Success( + TransactionMutation( + data = started, + snapshot = + snapshot.copy( + candidates = snapshot.candidates.replaceById(started.candidate) { it.id }, + eventLog = snapshot.eventLog.append(event, audit), + ), + changes = listOf(started.change), + ) + ) + } + + /* + * 사람의 공개 승인을 확인하고 내부 검수가 끝난 Release를 시작합니다. + */ + fun release(request: ReleaseGateRequest): WorkflowResult = + executeGate( + requestId = request.requestId, + targetId = request.releaseId, + capability = Capability.RELEASE, + gate = GateType.RELEASE, + ) { actor, snapshot -> + val stored = snapshot.releases.firstOrNull { it.id == request.releaseId } + if (stored == null) { + return@executeGate failurePort( + FailureCode.RELEASE_NOT_FOUND, + "release was not found", + request.releaseId, + ) + } + + val current = + when (val result = releasePort.get(GetReleaseRequest(releaseId = request.releaseId))) { + is PortResult.Failure -> return@executeGate result + is PortResult.Success -> + result.value.releases.singleOrNull { it.id == request.releaseId } + ?: return@executeGate failurePort( + FailureCode.RELEASE_NOT_FOUND, + "release provider did not return the requested release", + request.releaseId, + ) + } + validateReleaseProviderResult(current, stored, stored.state)?.let { + return@executeGate failurePort(FailureCode.INVARIANT_VIOLATION, it, request.releaseId) + } + + gateFailure(GateType.RELEASE, actor, stored, current, request.releaseId)?.let { + return@executeGate it + } + + val started = + when (val result = releasePort.start(StartReleaseRequest(current.id, actor))) { + is PortResult.Failure -> return@executeGate result + is PortResult.Success -> result.value + } + validateReleaseProviderResult(started.release, current, ReleaseState.ROLLOUT)?.let { + return@executeGate failurePort( + FailureCode.INVARIANT_VIOLATION, + it, + request.releaseId, + started.change, + ) + } + + val occurredAt = now(request.requestId) + val event = GateRecorded(current.id, GateType.RELEASE, actor, occurredAt) + val audit = + AuditEntry( + id = auditId(request.requestId, GateType.RELEASE, current.id), + actor = actor, + action = "release", + targetId = current.id, + mainRevision = + snapshot.candidates.firstOrNull { it.id == current.candidateId }?.mainRevision, + occurredAtEpochMillis = occurredAt, + ) + PortResult.Success( + TransactionMutation( + data = started, + snapshot = + snapshot.copy( + releases = snapshot.releases.replaceById(started.release) { it.id }, + eventLog = snapshot.eventLog.append(event, audit), + ), + changes = listOf(started.change), + ) + ) + } + + /* + * 배포 요청을 애플리케이션 명령 형태로 실행합니다. + */ + fun execute(request: DeployGateRequest): WorkflowResult = deploy(request) + + /* + * 공개 요청을 애플리케이션 명령 형태로 실행합니다. + */ + fun execute(request: ReleaseGateRequest): WorkflowResult = release(request) + + /* + * 배포 요청을 인바운드 어댑터의 공통 진입점으로 처리합니다. + */ + fun handle(request: DeployGateRequest): WorkflowResult = deploy(request) + + /* + * 공개 요청을 인바운드 어댑터의 공통 진입점으로 처리합니다. + */ + fun handle(request: ReleaseGateRequest): WorkflowResult = release(request) + + private fun executeGate( + requestId: String, + targetId: String, + capability: Capability, + gate: GateType, + operation: (Actor, WorkflowStoreSnapshot) -> PortResult>, + ): WorkflowResult { + val actor = + when (val result = identityPort.currentActor(CurrentActorRequest(requestId))) { + is PortResult.Failure -> return result.toWorkflowFailure(FailureCode.HUMAN_REQUIRED) + is PortResult.Success -> result.value.actor + } + val authorization = + when (val result = identityPort.authorize(AuthorizeRequest(actor, capability, targetId))) { + is PortResult.Failure -> return result.toWorkflowFailure(FailureCode.HUMAN_REQUIRED) + is PortResult.Success -> result.value + } + if (!authorization.allowed) { + return failure( + FailureCode.HUMAN_REQUIRED, + authorization.reason ?: "the actor is not authorized for this human gate", + targetId, + ) + } + if (!actor.isHuman) { + return failure( + FailureCode.HUMAN_REQUIRED, + "${gate.name.lowercase()} gate requires a human actor", + targetId, + ) + } + + val transaction = WorkflowTransaction(storePort, compensationPort) + return transaction.execute( + StoreTransactionRequest( + transactionId = "gate-${gate.name.lowercase()}-$targetId-$requestId", + idempotencyKey = requestId, + ), + ) { snapshot -> + operation(actor, snapshot) + } + } + + private fun gateFailure( + gate: GateType, + actor: Actor, + stored: DeploymentCandidate, + current: DeploymentCandidate, + targetId: String, + ): PortResult.Failure? { + val storedFailure = WorkflowRules.humanGateCondition(gate, actor, candidate = stored) + val currentFailure = WorkflowRules.humanGateCondition(gate, actor, candidate = current) + return (storedFailure ?: currentFailure)?.let { + failurePort( + if (it.code == FailureCode.HUMAN_REQUIRED) it.code + else FailureCode.DEPLOYMENT_GATE_BLOCKED, + it.message, + targetId, + ) + } + } + + private fun validateDeploymentProviderResult( + actual: DeploymentCandidate, + expected: DeploymentCandidate, + expectedState: DeploymentCandidateState, + ): String? = + when { + actual.id != expected.id -> "deployment provider returned a different candidate" + actual.mainRevision != expected.mainRevision -> + "deployment provider returned a different main revision" + actual.includedSubTasks != expected.includedSubTasks -> + "deployment provider changed included subtasks" + actual.risks != expected.risks -> "deployment provider changed candidate risks" + actual.validations != expected.validations -> + "deployment provider changed candidate validations" + actual.state != expectedState -> + "deployment provider returned an unexpected candidate state" + else -> null + } + + private fun validateReleaseProviderResult( + actual: Release, + expected: Release, + expectedState: ReleaseState, + ): String? = + when { + actual.id != expected.id -> "release provider returned a different release" + actual.candidateId != expected.candidateId -> + "release provider returned a different candidate" + actual.cleanupSubTaskId != expected.cleanupSubTaskId -> + "release provider changed the cleanup subtask" + actual.state != expectedState -> "release provider returned an unexpected release state" + actual.productionReady != expected.productionReady -> + "release provider changed production readiness" + actual.internalValidationPassed != expected.internalValidationPassed -> + "release provider changed internal validation readiness" + else -> null + } + + private fun gateFailure( + gate: GateType, + actor: Actor, + stored: Release, + current: Release, + targetId: String, + ): PortResult.Failure? { + val storedFailure = WorkflowRules.humanGateCondition(gate, actor, release = stored) + val currentFailure = WorkflowRules.humanGateCondition(gate, actor, release = current) + return (storedFailure ?: currentFailure)?.let { + failurePort( + if (it.code == FailureCode.HUMAN_REQUIRED) it.code else FailureCode.RELEASE_GATE_BLOCKED, + it.message, + targetId, + ) + } + } + + private fun failure( + code: FailureCode, + message: String, + target: String, + ): WorkflowResult.Failure = + WorkflowResult.Failure( + FailureData( + code = code, + message = message, + blockedBy = listOf(BlockedBy(code.name, message, target)), + ), + ) + + private fun failurePort( + code: FailureCode, + message: String, + target: String, + change: ChangeReceipt? = null, + ): PortResult.Failure = PortResult.Failure(PortError(code.name, message, target = target), change) + + private fun auditId(requestId: String, gate: GateType, targetId: String): String = + when ( + val result = idPort?.issue(IssueIdRequest(IdKind.AUDIT, "$gate:$targetId:$requestId")) + ) { + is PortResult.Success -> result.value.ids.firstOrNull()?.value ?: "audit-$gate-$targetId" + is PortResult.Failure, + null, + -> "audit-${gate.name.lowercase()}-$targetId-$requestId" + } + + private fun now(requestId: String): Long = + when (val result = clockPort?.now(NowRequest(requestId))) { + is PortResult.Success -> result.value.epochMillis + is PortResult.Failure, + null, + -> 0L + } + + private companion object { + val NoOpCompensationPort: CompensationPort = + object : CompensationPort { + override fun compensate(request: CompensateRequest): PortResult = + PortResult.Success(CompensateResponse(request.change)) + } + } +} + +/* + * 배포 Gate 요청을 기존 명명 규칙으로 사용할 수 있게 하는 별칭입니다. + */ +typealias DeploymentGateRequest = DeployGateRequest + +/* + * 공개 Gate 요청을 기존 명명 규칙으로 사용할 수 있게 하는 별칭입니다. + */ +typealias ExternalReleaseGateRequest = ReleaseGateRequest + +private fun List.replaceById(value: T, id: (T) -> String): List = + filterNot { id(it) == id(value) } + value diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/application/DeploymentLifecycleUseCase.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/DeploymentLifecycleUseCase.kt new file mode 100644 index 00000000..afd2394d --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/DeploymentLifecycleUseCase.kt @@ -0,0 +1,1134 @@ +package io.springkit.workflow.application + +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.AuditEntry +import io.springkit.workflow.domain.CandidateId +import io.springkit.workflow.domain.DeploymentCandidate +import io.springkit.workflow.domain.DeploymentCandidateState +import io.springkit.workflow.domain.DeploymentRecorded +import io.springkit.workflow.domain.EventLog +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.IntegrationState +import io.springkit.workflow.domain.MainRevision +import io.springkit.workflow.domain.MergeRecorded +import io.springkit.workflow.domain.NextAction +import io.springkit.workflow.domain.Risk +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskId +import io.springkit.workflow.domain.ValidationStatus +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.requiredValidationsPassed + +/* + * 병합된 main revision을 후보로 만들고 Production까지 진행하는 요청입니다. + */ +data class DeploymentLifecycleRequest( + val mainRevision: MainRevision, + val requestId: String = "candidate-$mainRevision", + val expectedStoreRevision: String? = null, +) { + init { + require(mainRevision.isNotBlank()) { "deployment main revision must not be blank" } + require(requestId.isNotBlank()) { "deployment request id must not be blank" } + } +} + +/* + * Canary 결과를 Workflow에 반영하는 요청입니다. + */ +data class DeploymentCanaryEventRequest( + val candidateId: CandidateId, + val outcome: DeploymentCanaryOutcome, + val eventId: String, + val requestId: String = "canary-$eventId", +) { + init { + require(candidateId.isNotBlank()) { "canary candidate id must not be blank" } + require(eventId.isNotBlank()) { "canary event id must not be blank" } + require(requestId.isNotBlank()) { "canary request id must not be blank" } + } +} + +/* + * Canary가 최종적으로 보고한 결과입니다. + */ +enum class DeploymentCanaryOutcome { + SUCCEEDED, + FAILED, +} + +/* + * 후보 검증 또는 Canary에서 발생한 실패 단계를 구분합니다. + */ +enum class DeploymentFailureStage { + VALIDATION, + CANARY, +} + +/* + * 배포 lifecycle의 상태와 실패 단계를 반환합니다. + */ +data class DeploymentLifecycleResponse( + val candidate: DeploymentCandidate, + val failureStage: DeploymentFailureStage? = null, + val idempotent: Boolean = false, +) + +typealias DeploymentCandidateRequest = DeploymentLifecycleRequest + +typealias DeploymentCandidateResponse = DeploymentLifecycleResponse + +typealias CanaryEventRequest = DeploymentCanaryEventRequest + +/* + * merge된 main revision을 고정하고 후보 검증, Canary, Production 전이를 조정합니다. + */ +class DeploymentLifecycleUseCase( + private val storePort: WorkflowStorePort, + private val deploymentPort: DeploymentPort, + private val idPort: IdPort? = null, + private val clockPort: ClockPort? = null, + private val compensationPort: CompensationPort? = null, +) { + /* + * merge된 SubTask로 배포 후보를 만들고 검증을 시작합니다. + */ + fun create(request: DeploymentLifecycleRequest): WorkflowResult { + val initial = readSnapshot(request.expectedStoreRevision, request.mainRevision) + if (initial is SnapshotRead.Failure) return initial.failure + val snapshot = (initial as SnapshotRead.Success).snapshot + val merged = mergedSubTasks(snapshot, request.mainRevision) + if (merged is MergedSubTasks.Failure) return merged.failure + snapshot.candidates + .firstOrNull { it.mainRevision == request.mainRevision } + ?.let { + return WorkflowResult.Success( + DeploymentLifecycleResponse(it, idempotent = true), + nextActions(it), + ) + } + val (subTasks, risks) = (merged as MergedSubTasks.Success).value + snapshot.candidates + .firstOrNull { it.state.isActiveDeployment() } + ?.let { + return WorkflowResult.Success( + DeploymentLifecycleResponse(it, idempotent = true), + nextActions(it), + ) + } + val transactionRequest = + StoreTransactionRequest( + transactionId = issueTransactionId(request.requestId), + expectedRevision = snapshot.revision, + idempotencyKey = request.requestId, + ) + + return transaction(transactionRequest) { current -> + val currentMerged = mergedSubTasks(current, request.mainRevision) + if (currentMerged is MergedSubTasks.Failure) + return@transaction Operation.Abort(currentMerged.failure.toPortFailure()) + current.candidates + .firstOrNull { it.mainRevision == request.mainRevision } + ?.let { + return@transaction Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(it, idempotent = true), + current, + ) + ) + } + val (currentSubTasks, currentRisks) = (currentMerged as MergedSubTasks.Success).value + current.candidates + .firstOrNull { it.state.isActiveDeployment() } + ?.let { + return@transaction Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(it, idempotent = true), + current, + ) + ) + } + if (currentSubTasks.map { it.id } != subTasks.map { it.id } || currentRisks != risks) { + return@transaction Operation.Abort( + failurePort( + FailureCode.STALE_REVISION, + "merged subtasks changed while creating the deployment candidate", + request.mainRevision, + ) + ) + } + + val created = + when ( + val result = + deploymentPort.createCandidate( + CreateCandidateRequest( + mainRevision = request.mainRevision, + includedSubTasks = currentSubTasks.map(SubTask::id), + risks = currentRisks, + ) + ) + ) { + is PortResult.Failure -> return@transaction Operation.Abort(result) + is PortResult.Success -> result.value + } + val candidate = created.candidate + validateCandidateIdentity( + candidate, + request.mainRevision, + currentSubTasks.map(SubTask::id), + currentRisks, + expectedCandidateId = null, + ) + ?.let { + return@transaction Operation.Abort( + failurePort(it.first, it.second, candidate.id, created.change), + listOf(created.change), + ) + } + if (candidate.state != DeploymentCandidateState.CANDIDATE) { + return@transaction Operation.Abort( + failurePort( + FailureCode.INVARIANT_VIOLATION, + "deployment provider did not create a candidate", + candidate.id, + created.change, + ) + ) + } + + val validated = + when ( + val result = + deploymentPort.validate( + ValidateCandidateRequest(candidate.id, request.mainRevision) + ) + ) { + is PortResult.Failure -> + return@transaction Operation.Abort( + result.withChange(created.change), + listOf(created.change), + ) + is PortResult.Success -> result.value + } + validateCandidateIdentity( + validated.candidate, + request.mainRevision, + candidate.includedSubTasks, + candidate.risks, + expectedCandidateId = candidate.id, + ) + ?.let { + return@transaction Operation.Abort( + failurePort(it.first, it.second, candidate.id, validated.change), + listOf(created.change, validated.change), + ) + } + val validationCandidate = validated.candidate + val changes = listOf(created.change, validated.change) + val validationFailed = + validationCandidate.validations.any { + it.required && it.status == ValidationStatus.FAILED + } + val validationPassed = + validationCandidate.validations.isNotEmpty() && + requiredValidationsPassed(validationCandidate.validations) + if (validationFailed) { + val failed = validationCandidate.copy(state = DeploymentCandidateState.FAILED) + return@transaction Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(failed, DeploymentFailureStage.VALIDATION), + current.copy( + candidates = current.candidates + failed, + eventLog = + current.eventLog.append(DeploymentRecorded(failed.id, failed.state)), + ), + changes, + ) + ) + } + if (!validationPassed) { + val validating = validationCandidate.copy(state = DeploymentCandidateState.VALIDATING) + return@transaction Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(validating), + current.copy( + candidates = current.candidates + validating, + eventLog = + current.eventLog.append( + DeploymentRecorded(validating.id, validating.state) + ), + ), + changes, + ) + ) + } + + if (candidate.risk == Risk.HIGH) { + val awaiting = + validationCandidate.copy(state = DeploymentCandidateState.AWAITING_DEPLOY_APPROVAL) + return@transaction Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(awaiting), + current.copy( + candidates = current.candidates + awaiting, + eventLog = + current.eventLog.append( + DeploymentRecorded(awaiting.id, awaiting.state) + ), + ), + changes, + ) + ) + } + + val canary = + when (val result = deploymentPort.startCanary(StartCanaryRequest(candidate.id))) { + is PortResult.Failure -> + return@transaction Operation.Abort( + result.withChange(created.change), + listOf(created.change, validated.change), + ) + is PortResult.Success -> result.value + } + validateCandidateIdentity( + canary.candidate, + request.mainRevision, + candidate.includedSubTasks, + candidate.risks, + expectedCandidateId = candidate.id, + ) + ?.let { + return@transaction Operation.Abort( + failurePort(it.first, it.second, candidate.id, canary.change), + listOf(created.change, validated.change, canary.change), + ) + } + if (canary.candidate.state != DeploymentCandidateState.CANARY) { + return@transaction Operation.Abort( + failurePort( + FailureCode.INVARIANT_VIOLATION, + "deployment provider did not start the candidate canary", + candidate.id, + canary.change, + ) + ) + } + val updated = + canary.candidate.copy( + state = DeploymentCandidateState.CANARY, + ) + Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(updated), + current.copy( + candidates = current.candidates + updated, + eventLog = + current.eventLog.append(DeploymentRecorded(updated.id, updated.state)), + ), + changes + canary.change, + ) + ) + } + .mapFailureStage() + } + + /* + * 배포 후보 생성 명령의 공통 진입점입니다. + */ + fun execute(request: DeploymentLifecycleRequest): WorkflowResult = + create(request) + + /* + * 배포 후보 생성 명령을 인바운드 어댑터에서 호출합니다. + */ + fun handle(request: DeploymentLifecycleRequest): WorkflowResult = + create(request) + + /* + * 외부 Canary 변경 이벤트를 처리합니다. + */ + fun handle(event: ExternalEvent): WorkflowResult { + if (event.kind == ExternalEventKind.DEPLOYMENT_CHANGED) { + return handleDeploymentChanged(event) + } + if (event.kind != ExternalEventKind.CANARY_CHANGED) { + return failure( + FailureCode.INVALID_ARGUMENT, + "only DEPLOYMENT_CHANGED and CANARY_CHANGED events can advance a deployment candidate", + event.targetId, + ) + } + val outcome = + when ( + event.attributes["outcome"]?.uppercase() + ?: event.attributes["result"]?.uppercase() + ?: event.attributes["status"]?.uppercase() + ) { + "SUCCESS", + "SUCCEEDED", + "PASSED", + "HEALTHY", + "COMPLETED", + -> DeploymentCanaryOutcome.SUCCEEDED + "FAILURE", + "FAILED", + "ERROR", + "UNHEALTHY", + -> DeploymentCanaryOutcome.FAILED + else -> + return failure( + FailureCode.INVALID_ARGUMENT, + "CANARY_CHANGED event must contain a recognized outcome", + event.targetId, + ) + } + return handle( + DeploymentCanaryEventRequest( + candidateId = event.targetId, + outcome = outcome, + eventId = event.id, + requestId = "canary-${event.id}", + ) + ) + } + + /* + * 명시적인 Canary 결과를 처리합니다. + */ + fun handle(request: DeploymentCanaryEventRequest): WorkflowResult { + val initial = readSnapshot(null, request.candidateId) + if (initial is SnapshotRead.Failure) return initial.failure + val snapshot = (initial as SnapshotRead.Success).snapshot + val current = + snapshot.candidates.firstOrNull { it.id == request.candidateId } + ?: return failure( + FailureCode.DEPLOYMENT_NOT_FOUND, + "deployment candidate was not found", + request.candidateId, + ) + if ( + request.outcome == DeploymentCanaryOutcome.SUCCEEDED && + current.state == DeploymentCandidateState.PRODUCTION + ) { + return WorkflowResult.Success(DeploymentLifecycleResponse(current, idempotent = true)) + } + if ( + request.outcome == DeploymentCanaryOutcome.FAILED && + current.state == DeploymentCandidateState.FAILED + ) { + return WorkflowResult.Success( + DeploymentLifecycleResponse( + current, + failureStage = DeploymentFailureStage.CANARY, + idempotent = true, + ) + ) + } + if (current.state != DeploymentCandidateState.CANARY) { + return failure( + FailureCode.STATE_CONFLICT, + "canary event requires a candidate in CANARY state", + request.candidateId, + ) + } + val transactionRequest = + StoreTransactionRequest( + transactionId = issueTransactionId(request.requestId), + expectedRevision = snapshot.revision, + idempotencyKey = request.eventId, + ) + return transaction(transactionRequest) { latest -> + val candidate = + latest.candidates.firstOrNull { it.id == request.candidateId } + ?: return@transaction Operation.Abort( + failurePort( + FailureCode.DEPLOYMENT_NOT_FOUND, + "deployment candidate was not found", + request.candidateId, + ) + ) + if (request.outcome == DeploymentCanaryOutcome.FAILED) { + val failed = candidate.copy(state = DeploymentCandidateState.FAILED) + return@transaction Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(failed, DeploymentFailureStage.CANARY), + latest.copy( + candidates = latest.candidates.replaceCandidate(failed), + eventLog = + latest.eventLog.append(DeploymentRecorded(failed.id, failed.state)), + ), + ) + ) + } + val promoted = + when ( + val result = + deploymentPort.promoteProduction( + PromoteProductionRequest(candidate.id, DeploymentCandidateState.CANARY) + ) + ) { + is PortResult.Failure -> return@transaction Operation.Abort(result) + is PortResult.Success -> result.value + } + validateCandidateIdentity( + promoted.candidate, + candidate.mainRevision, + candidate.includedSubTasks, + candidate.risks, + expectedCandidateId = candidate.id, + ) + ?.let { + return@transaction Operation.Abort( + failurePort(it.first, it.second, candidate.id, promoted.change), + listOf(promoted.change), + ) + } + if (promoted.candidate.state != DeploymentCandidateState.PRODUCTION) { + return@transaction Operation.Abort( + failurePort( + FailureCode.INVARIANT_VIOLATION, + "deployment provider did not promote the canary to production", + candidate.id, + promoted.change, + ) + ) + } + Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(promoted.candidate), + latest.copy( + candidates = latest.candidates.replaceCandidate(promoted.candidate), + eventLog = + latest.eventLog.append( + DeploymentRecorded(promoted.candidate.id, promoted.candidate.state) + ), + ), + listOf(promoted.change), + ) + ) + } + .mapFailureStage() + } + + /* + * 외부 Deployment 변경 이벤트를 재조회하고 검증 결과에 따라 안전하게 진행합니다. + */ + private fun handleDeploymentChanged( + event: ExternalEvent + ): WorkflowResult { + val initial = readSnapshot(null, event.targetId) + if (initial is SnapshotRead.Failure) return initial.failure + val snapshot = (initial as SnapshotRead.Success).snapshot + val current = + snapshot.candidates.firstOrNull { it.id == event.targetId } + ?: return failure( + FailureCode.DEPLOYMENT_NOT_FOUND, + "deployment candidate was not found", + event.targetId, + ) + if (snapshot.eventLog.audits.any { it.id == event.id }) { + return WorkflowResult.Success( + DeploymentLifecycleResponse(current, idempotent = true), + nextActions(current), + ) + } + if (current.state.isAfterValidation()) { + return WorkflowResult.Success( + DeploymentLifecycleResponse(current, idempotent = true), + nextActions(current), + ) + } + if (current.state == DeploymentCandidateState.FAILED) { + return WorkflowResult.Success( + DeploymentLifecycleResponse(current, DeploymentFailureStage.VALIDATION, true) + ) + } + if ( + current.state !in + setOf(DeploymentCandidateState.CANDIDATE, DeploymentCandidateState.VALIDATING) + ) { + return failure( + FailureCode.STATE_CONFLICT, + "deployment changed event cannot advance the candidate from its current state", + current.id, + ) + } + + val transactionRequest = + StoreTransactionRequest( + transactionId = issueTransactionId(event.id), + expectedRevision = snapshot.revision, + idempotencyKey = event.id, + ) + return transaction(transactionRequest) { latest -> + val latestCandidate = + latest.candidates.firstOrNull { it.id == event.targetId } + ?: return@transaction Operation.Abort( + failurePort( + FailureCode.DEPLOYMENT_NOT_FOUND, + "deployment candidate was not found", + event.targetId, + ) + ) + if (latest.eventLog.audits.any { it.id == event.id }) { + return@transaction Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(latestCandidate, idempotent = true), + latest, + ) + ) + } + if (latestCandidate.state.isAfterValidation()) { + return@transaction Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(latestCandidate, idempotent = true), + latest, + ) + ) + } + if (latestCandidate.state == DeploymentCandidateState.FAILED) { + return@transaction Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse( + latestCandidate, + DeploymentFailureStage.VALIDATION, + idempotent = true, + ), + latest, + ) + ) + } + if ( + latestCandidate.state !in + setOf(DeploymentCandidateState.CANDIDATE, DeploymentCandidateState.VALIDATING) + ) { + return@transaction Operation.Abort( + failurePort( + FailureCode.STATE_CONFLICT, + "deployment changed event cannot advance the candidate from its current state", + latestCandidate.id, + ) + ) + } + + val observed = + when (val result = deploymentPort.getCandidate(GetCandidateRequest(event.targetId))) { + is PortResult.Failure -> return@transaction Operation.Abort(result) + is PortResult.Success -> result.value + } + validateCandidateIdentity( + observed.candidate, + latestCandidate.mainRevision, + latestCandidate.includedSubTasks, + latestCandidate.risks, + expectedCandidateId = latestCandidate.id, + ) + ?.let { + return@transaction Operation.Abort( + failurePort(it.first, it.second, latestCandidate.id), + ) + } + + val validation = + if ( + observed.candidate.state == DeploymentCandidateState.FAILED || + observed.candidate.state.isAfterValidation() || + !observed.candidate.requiresValidationRefresh() + ) { + observed.candidate to null + } else { + when ( + val result = + deploymentPort.validate( + ValidateCandidateRequest( + latestCandidate.id, + latestCandidate.mainRevision, + ) + ) + ) { + is PortResult.Failure -> return@transaction Operation.Abort(result) + is PortResult.Success -> result.value.candidate to result.value.change + } + } + val validatedCandidate = validation.first + val validationChange = validation.second + validateCandidateIdentity( + validatedCandidate, + latestCandidate.mainRevision, + latestCandidate.includedSubTasks, + latestCandidate.risks, + expectedCandidateId = latestCandidate.id, + ) + ?.let { + return@transaction Operation.Abort( + failurePort(it.first, it.second, latestCandidate.id, validationChange), + listOfNotNull(validationChange), + ) + } + if (validatedCandidate.state == DeploymentCandidateState.FAILED) { + val failed = validatedCandidate.copy(state = DeploymentCandidateState.FAILED) + return@transaction Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(failed, DeploymentFailureStage.VALIDATION), + latest.copy( + candidates = latest.candidates.replaceCandidate(failed), + eventLog = latest.deploymentEventLog(event, failed), + ), + listOfNotNull(validationChange), + ) + ) + } + if (validatedCandidate.state.isAfterValidation()) { + val progressed = validatedCandidate + return@transaction Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(progressed, idempotent = true), + latest.copy( + candidates = latest.candidates.replaceCandidate(progressed), + eventLog = latest.deploymentEventLog(event, progressed), + ), + listOfNotNull(validationChange), + ) + ) + } + + val validationFailed = + validatedCandidate.validations.any { + it.required && it.status == ValidationStatus.FAILED + } + val validationPassed = + validatedCandidate.validations.isNotEmpty() && + requiredValidationsPassed(validatedCandidate.validations) + if (validationFailed) { + val failed = validatedCandidate.copy(state = DeploymentCandidateState.FAILED) + return@transaction Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(failed, DeploymentFailureStage.VALIDATION), + latest.copy( + candidates = latest.candidates.replaceCandidate(failed), + eventLog = latest.deploymentEventLog(event, failed), + ), + listOfNotNull(validationChange), + ) + ) + } + if (!validationPassed) { + val validating = validatedCandidate.copy(state = DeploymentCandidateState.VALIDATING) + return@transaction Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(validating), + latest.copy( + candidates = latest.candidates.replaceCandidate(validating), + eventLog = latest.deploymentEventLog(event, validating), + ), + listOfNotNull(validationChange), + ) + ) + } + + if (latestCandidate.risk == Risk.HIGH) { + val awaiting = + validatedCandidate.copy(state = DeploymentCandidateState.AWAITING_DEPLOY_APPROVAL) + return@transaction Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(awaiting), + latest.copy( + candidates = latest.candidates.replaceCandidate(awaiting), + eventLog = latest.deploymentEventLog(event, awaiting), + ), + listOfNotNull(validationChange), + ) + ) + } + + val canary = + when ( + val result = deploymentPort.startCanary(StartCanaryRequest(latestCandidate.id)) + ) { + is PortResult.Failure -> + return@transaction Operation.Abort( + result.withChange(validationChange), + listOfNotNull(validationChange), + ) + is PortResult.Success -> result.value + } + validateCandidateIdentity( + canary.candidate, + latestCandidate.mainRevision, + latestCandidate.includedSubTasks, + latestCandidate.risks, + expectedCandidateId = latestCandidate.id, + ) + ?.let { + return@transaction Operation.Abort( + failurePort(it.first, it.second, latestCandidate.id, canary.change), + listOfNotNull(validationChange, canary.change), + ) + } + if (canary.candidate.state != DeploymentCandidateState.CANARY) { + return@transaction Operation.Abort( + failurePort( + FailureCode.INVARIANT_VIOLATION, + "deployment provider did not start the candidate canary", + latestCandidate.id, + canary.change, + ), + listOfNotNull(validationChange, canary.change), + ) + } + Operation.Commit( + TransactionMutation( + DeploymentLifecycleResponse(canary.candidate), + latest.copy( + candidates = latest.candidates.replaceCandidate(canary.candidate), + eventLog = latest.deploymentEventLog(event, canary.candidate), + ), + listOfNotNull(validationChange, canary.change), + ) + ) + } + .mapFailureStage() + } + + /* + * 명시적인 Canary 결과를 처리하는 별칭입니다. + */ + fun handleCanaryEvent( + request: DeploymentCanaryEventRequest + ): WorkflowResult = handle(request) + + /* + * 외부 Canary 이벤트를 처리하는 별칭입니다. + */ + fun handleCanaryEvent(event: ExternalEvent): WorkflowResult = + handle(event) + + private fun readSnapshot(expectedRevision: String?, target: String): SnapshotRead { + val result = storePort.snapshot(StoreSnapshotRequest(StoreScope.ALL)) + if (result is PortResult.Failure) + return SnapshotRead.Failure(result.toWorkflowFailure(FailureCode.STORE_FAILURE)) + val snapshot = (result as PortResult.Success).value.snapshot + if (expectedRevision != null && expectedRevision != snapshot.revision) { + return SnapshotRead.Failure( + failure(FailureCode.STALE_REVISION, "workflow store revision is stale", target) + ) + } + return SnapshotRead.Success(snapshot) + } + + private fun mergedSubTasks( + snapshot: WorkflowStoreSnapshot, + mainRevision: MainRevision, + ): MergedSubTasks { + val production = latestProductionCandidate(snapshot) + val eventMerges = snapshot.eventLog.events.filterIsInstance() + val integrationMerges = + snapshot.integrations + .filter { it.state == IntegrationState.MERGED && !it.mainRevision.isNullOrBlank() } + .map { + MergePoint( + subTaskId = it.subTaskId, + mainRevision = requireNotNull(it.mainRevision), + ) + } + val eventHasTarget = eventMerges.any { it.mainRevision == mainRevision } + val eventHasProduction = + production == null || eventMerges.any { it.mainRevision == production.mainRevision } + val mergePoints = + if (eventHasTarget && eventHasProduction) { + eventMerges.map { MergePoint(it.targetId, it.mainRevision) } + } else { + integrationMerges + } + val targetFirstIndex = mergePoints.indexOfFirst { it.mainRevision == mainRevision } + val targetLastIndex = mergePoints.indexOfLast { it.mainRevision == mainRevision } + if (targetFirstIndex < 0) { + return MergedSubTasks.Failure( + failure( + FailureCode.DEPLOYMENT_NOT_FOUND, + "no merged subtasks were found for the main revision", + mainRevision, + ) + ) + } + val productionIndex = production?.let { candidate -> + mergePoints.indexOfLast { it.mainRevision == candidate.mainRevision } + } + if (production != null && productionIndex == -1) { + return MergedSubTasks.Failure( + failure( + FailureCode.INVARIANT_VIOLATION, + "the current production revision is missing from the merge history", + production.mainRevision, + ) + ) + } + if (productionIndex != null && productionIndex >= 0 && targetFirstIndex < productionIndex) { + return MergedSubTasks.Failure( + failure( + FailureCode.STATE_CONFLICT, + "deployment target revision precedes the current production revision", + mainRevision, + ) + ) + } + val firstCandidateIndex = productionIndex?.plus(1) ?: 0 + val targetPoints = mergePoints.subList(firstCandidateIndex, targetLastIndex + 1) + val mergedIds = targetPoints.map(MergePoint::subTaskId).distinct() + val subTasks = mergedIds.mapNotNull { id -> snapshot.subTasks.firstOrNull { it.id == id } } + if (subTasks.size != mergedIds.size) { + return MergedSubTasks.Failure( + failure( + FailureCode.INVARIANT_VIOLATION, + "a merged integration references an unknown subtask", + mainRevision, + ) + ) + } + return MergedSubTasks.Success(subTasks to subTasks.associate { it.id to it.risk }) + } + + private fun latestProductionCandidate( + snapshot: WorkflowStoreSnapshot, + ): DeploymentCandidate? { + val productionEvents = + snapshot.eventLog.events.filterIsInstance().filter { + it.state == DeploymentCandidateState.PRODUCTION + } + return productionEvents + .asSequence() + .mapNotNull { event -> snapshot.candidates.firstOrNull { it.id == event.targetId } } + .lastOrNull { it.state == DeploymentCandidateState.PRODUCTION } + ?: snapshot.candidates.lastOrNull { it.state == DeploymentCandidateState.PRODUCTION } + } + + private fun validateCandidateIdentity( + candidate: DeploymentCandidate, + mainRevision: MainRevision, + includedSubTasks: List, + risks: Map, + expectedCandidateId: CandidateId?, + ): Pair? = + when { + expectedCandidateId != null && candidate.id != expectedCandidateId -> + FailureCode.INVARIANT_VIOLATION to "deployment provider returned a different candidate" + candidate.mainRevision != mainRevision -> + FailureCode.INVARIANT_VIOLATION to + "deployment provider returned a different main revision" + candidate.includedSubTasks != includedSubTasks -> + FailureCode.INVARIANT_VIOLATION to "deployment provider changed included subtasks" + candidate.risks != risks -> + FailureCode.INVARIANT_VIOLATION to "deployment provider changed candidate risks" + else -> null + } + + private fun transaction( + request: StoreTransactionRequest, + operation: (WorkflowStoreSnapshot) -> Operation, + ): WorkflowResult { + when (val begun = storePort.begin(request)) { + is PortResult.Failure -> return begun.toWorkflowFailure(FailureCode.STORE_FAILURE) + is PortResult.Success -> + if (begun.value.state != StoreTransactionState.OPEN) { + return failure( + FailureCode.STORE_FAILURE, + "workflow store transaction is not open", + request.transactionId, + ) + } + } + val snapshot = + when (val result = storePort.snapshot(StoreSnapshotRequest(StoreScope.ALL))) { + is PortResult.Failure -> { + storePort.rollback(request) + return result.toWorkflowFailure(FailureCode.STORE_FAILURE) + } + is PortResult.Success -> result.value.snapshot + } + if (request.expectedRevision != null && request.expectedRevision != snapshot.revision) { + storePort.rollback(request) + return failure( + FailureCode.STALE_REVISION, + "workflow store revision is stale", + request.transactionId, + ) + } + return when (val outcome = operation(snapshot)) { + is Operation.Abort -> { + compensate( + (outcome.changes + listOfNotNull(outcome.failure.change)).distinctBy(ChangeReceipt::id), + outcome.failure.error.message, + ) + storePort.rollback(request) + outcome.failure.toWorkflowFailure(FailureCode.EXTERNAL_FAILURE) + } + is Operation.Commit -> { + val write = + storePort.write( + StoreWriteRequest( + request.transactionId, + snapshot.revision, + outcome.mutation.snapshot, + ) + ) + if (write is PortResult.Failure) { + compensate(outcome.mutation.changes, write.error.message) + storePort.rollback(request) + return write.toWorkflowFailure(FailureCode.STORE_FAILURE) + } + val committed = storePort.commit(request) + if (committed is PortResult.Failure) + return committed.toWorkflowFailure(FailureCode.STORE_FAILURE) + when (val failure = outcome.failure) { + null -> WorkflowResult.Success(outcome.mutation.data) + else -> WorkflowResult.Failure(failure) + } + } + } + } + + private fun compensate(changes: List, reason: String) { + changes.asReversed().forEach { change -> + if (change.compensation != null) { + compensationGateway().compensate(CompensateRequest(change, reason)) + } + } + } + + private fun compensationGateway(): CompensationPort = + compensationPort + ?: object : CompensationPort { + override fun compensate(request: CompensateRequest): PortResult = + PortResult.Success(CompensateResponse(request.change)) + } + + private fun issueTransactionId(requestId: String): String = + when (val result = idPort?.issue(IssueIdRequest(IdKind.TRANSACTION, requestId))) { + is PortResult.Success -> result.value.ids.firstOrNull()?.value ?: "tx-$requestId" + is PortResult.Failure, + null, + -> "tx-$requestId" + } + + private fun nextActions(candidate: DeploymentCandidate): List = + when (candidate.state) { + DeploymentCandidateState.CANDIDATE, + DeploymentCandidateState.VALIDATING, + -> listOf(NextAction(io.springkit.workflow.domain.ActorKind.WORKFLOW, "validate_candidate")) + DeploymentCandidateState.AWAITING_DEPLOY_APPROVAL -> + listOf( + NextAction( + io.springkit.workflow.domain.ActorKind.HUMAN, + "approve_deployment", + "./tools/workflow/bin/workflow gate deploy ${candidate.id}", + ) + ) + DeploymentCandidateState.CANARY -> + listOf(NextAction(io.springkit.workflow.domain.ActorKind.WORKFLOW, "await_canary")) + DeploymentCandidateState.PRODUCTION, + DeploymentCandidateState.FAILED, + DeploymentCandidateState.NOT_SELECTED, + -> emptyList() + } + + private fun failure(code: FailureCode, message: String, target: String): WorkflowResult.Failure = + WorkflowResult.Failure( + FailureData( + code, + message, + blockedBy = + listOf(io.springkit.workflow.domain.BlockedBy(code.name, message, target)), + ) + ) + + private fun failurePort( + code: FailureCode, + message: String, + target: String, + change: ChangeReceipt? = null, + ): PortResult.Failure = PortResult.Failure(PortError(code.name, message, target = target), change) + + private sealed interface SnapshotRead { + data class Success(val snapshot: WorkflowStoreSnapshot) : SnapshotRead + + data class Failure(val failure: WorkflowResult.Failure) : SnapshotRead + } + + private sealed interface MergedSubTasks { + data class Success(val value: Pair, Map>) : MergedSubTasks + + data class Failure(val failure: WorkflowResult.Failure) : MergedSubTasks + } + + private sealed interface Operation { + data class Commit( + val mutation: TransactionMutation, + val failure: FailureData? = null, + ) : Operation + + data class Abort( + val failure: PortResult.Failure, + val changes: List = emptyList(), + ) : Operation + } + + private data class MergePoint( + val subTaskId: SubTaskId, + val mainRevision: MainRevision, + ) +} + +private fun WorkflowResult.mapFailureStage(): WorkflowResult = this + +private fun PortResult.Failure.withChange(change: ChangeReceipt?): PortResult.Failure = + PortResult.Failure(error, this.change ?: change) + +private fun WorkflowResult.Failure.toPortFailure(): PortResult.Failure = + PortResult.Failure( + PortError( + code = data.code.name, + message = data.message, + target = data.blockedBy.firstOrNull()?.target, + ) + ) + +private fun List.replaceCandidate( + value: DeploymentCandidate +): List = map { current -> if (current.id == value.id) value else current } + +private fun DeploymentCandidateState.isAfterValidation(): Boolean = + this == DeploymentCandidateState.AWAITING_DEPLOY_APPROVAL || + this == DeploymentCandidateState.CANARY || + this == DeploymentCandidateState.PRODUCTION + +private fun DeploymentCandidateState.isActiveDeployment(): Boolean = + this == DeploymentCandidateState.CANDIDATE || + this == DeploymentCandidateState.VALIDATING || + this == DeploymentCandidateState.AWAITING_DEPLOY_APPROVAL || + this == DeploymentCandidateState.CANARY + +private fun DeploymentCandidate.requiresValidationRefresh(): Boolean = + validations.isEmpty() || + validations.any { + it.required && + it.status != ValidationStatus.PASSED && + it.status != ValidationStatus.FAILED + } + +private fun WorkflowStoreSnapshot.deploymentEventLog( + event: ExternalEvent, + candidate: DeploymentCandidate, +): EventLog = + eventLog.append( + DeploymentRecorded(candidate.id, candidate.state, event.occurredAtEpochMillis), + AuditEntry( + id = event.id, + actor = Actor("workflow", ActorKind.WORKFLOW), + action = "deployment_changed", + targetId = candidate.id, + occurredAtEpochMillis = event.occurredAtEpochMillis, + ), + ) diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/application/MergeQueueLifecycleUseCase.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/MergeQueueLifecycleUseCase.kt new file mode 100644 index 00000000..4c5a29f6 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/MergeQueueLifecycleUseCase.kt @@ -0,0 +1,567 @@ +package io.springkit.workflow.application + +import io.springkit.workflow.domain.ChangeRevisionId +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.Integration +import io.springkit.workflow.domain.IntegrationState +import io.springkit.workflow.domain.MergeQueueEntry +import io.springkit.workflow.domain.MergeQueueState +import io.springkit.workflow.domain.MergeRecorded +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskId +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.Task +import io.springkit.workflow.domain.WorkflowResult + +/* + * Merge Queue 검증을 통과한 SubTask를 squash merge하고 통합 상태를 저장하는 요청입니다. + */ +data class MergeQueueLifecycleRequest( + val subTaskId: SubTaskId, + val mergeQueueEntryId: String? = null, + val changeRevisionId: ChangeRevisionId? = null, + val requestId: String = "merge-$subTaskId", + val expectedStoreRevision: String? = null, +) { + init { + require(subTaskId.isNotBlank()) { "merge subtask id must not be blank" } + require(mergeQueueEntryId == null || mergeQueueEntryId.isNotBlank()) { + "merge queue entry id must not be blank" + } + require(changeRevisionId == null || changeRevisionId.isNotBlank()) { + "merge change revision id must not be blank" + } + require(requestId.isNotBlank()) { "merge request id must not be blank" } + } +} + +/* + * squash merge 결과와 외부 Task 동기화 결과를 반환합니다. + */ +data class MergeQueueLifecycleResponse( + val task: Task, + val subTask: SubTask, + val pullRequest: PullRequest, + val integration: Integration, + val mergeQueue: MergeQueueEntry, +) + +/* + * Merge Queue 통합과 Workflow Store 상태 전이를 조정합니다. + */ +class MergeQueueLifecycleUseCase( + private val storePort: WorkflowStorePort, + private val mergeQueuePort: MergeQueuePort, + private val taskPort: TaskPort? = null, + private val idPort: IdPort? = null, + private val clockPort: ClockPort? = null, + private val compensationPort: CompensationPort? = null, +) { + /* + * 기존 Application 호출부의 Store, Task, Merge Queue 포트 순서를 지원합니다. + */ + constructor( + orderedStorePort: WorkflowStorePort, + orderedTaskPort: TaskPort, + orderedMergeQueuePort: MergeQueuePort, + orderedIdPort: IdPort? = null, + orderedClockPort: ClockPort? = null, + orderedCompensationPort: CompensationPort? = null, + ) : this( + orderedStorePort, + orderedMergeQueuePort, + orderedTaskPort, + orderedIdPort, + orderedClockPort, + orderedCompensationPort, + ) + + /* + * Merge Queue 검증 결과를 확인하고 squash merge를 실행합니다. + */ + fun merge(request: MergeQueueLifecycleRequest): WorkflowResult = + execute(request) + + /* + * Merge Queue 통합 요청을 애플리케이션 명령 형태로 실행합니다. + */ + fun execute(request: MergeQueueLifecycleRequest): WorkflowResult { + val snapshot = + when (val result = storePort.snapshot(StoreSnapshotRequest(StoreScope.ALL))) { + is PortResult.Failure -> return result.toWorkflowFailure(FailureCode.STORE_FAILURE) + is PortResult.Success -> result.value.snapshot + } + if ( + request.expectedStoreRevision != null && request.expectedStoreRevision != snapshot.revision + ) { + return failure( + FailureCode.STALE_REVISION, + "workflow store revision is stale", + request.subTaskId, + ) + } + idempotentResponse(snapshot, request)?.let { + return WorkflowResult.Success(it) + } + + val providerQueue = + when (val refreshed = refreshProviderQueue(snapshot, request)) { + is WorkflowResult.Failure -> return refreshed + is WorkflowResult.Success -> refreshed.data + } + val refreshedSnapshot = + when (val result = storePort.snapshot(StoreSnapshotRequest(StoreScope.ALL))) { + is PortResult.Failure -> return result.toWorkflowFailure(FailureCode.STORE_FAILURE) + is PortResult.Success -> result.value.snapshot + } + + val transactionRequest = + StoreTransactionRequest( + transactionId = issueTransactionId(request.requestId), + expectedRevision = refreshedSnapshot.revision, + idempotencyKey = request.requestId, + ) + return WorkflowTransaction(storePort, compensationGateway()).execute(transactionRequest) { + current -> + mergeIn(current, request, providerQueue) + } + } + + /* + * 통합 완료 이벤트를 처리하는 공통 진입점입니다. + */ + fun handle(request: MergeQueueLifecycleRequest): WorkflowResult = + execute(request) + + private fun mergeIn( + snapshot: WorkflowStoreSnapshot, + request: MergeQueueLifecycleRequest, + providerQueue: MergeQueueEntry, + ): PortResult> { + val subTask = + snapshot.subTasks.firstOrNull { it.id == request.subTaskId } + ?: return failurePort( + FailureCode.SUBTASK_NOT_FOUND, + "subtask was not found", + request.subTaskId, + ) + val task = + snapshot.tasks.firstOrNull { it.id == subTask.taskId } + ?: return failurePort(FailureCode.TASK_NOT_FOUND, "task was not found", subTask.taskId) + val pullRequestId = + subTask.pullRequestId + ?: return failurePort( + FailureCode.REVIEW_NOT_FOUND, + "subtask does not have a pull request", + subTask.id, + ) + val pullRequest = + snapshot.pullRequests.firstOrNull { it.id == pullRequestId } + ?: return failurePort( + FailureCode.REVIEW_NOT_FOUND, + "pull request was not found", + pullRequestId, + ) + if (pullRequest.subTaskId != subTask.id) { + return failurePort( + FailureCode.INVARIANT_VIOLATION, + "pull request does not belong to the subtask", + pullRequest.id, + ) + } + + val queue = + snapshot.mergeQueue.firstOrNull { + if (request.mergeQueueEntryId != null) it.id == request.mergeQueueEntryId + else it.subTaskId == subTask.id + } + ?: return failurePort( + FailureCode.MERGE_QUEUE_FAILED, + "merge queue entry was not found", + subTask.id, + ) + if (queue.subTaskId != subTask.id || queue.pullRequestId != pullRequest.id) { + return failurePort( + FailureCode.INVARIANT_VIOLATION, + "merge queue entry does not belong to the subtask pull request", + queue.id, + ) + } + val changeRevisionId = request.changeRevisionId ?: pullRequest.changeRevision.id + if ( + queue.changeRevisionId != changeRevisionId || + pullRequest.changeRevision.id != changeRevisionId + ) { + return failurePort(FailureCode.STALE_REVISION, "change revision is stale", subTask.id) + } + if (subTask.state == SubTaskState.MERGED && pullRequest.state == PullRequestState.MERGED) { + val integration = snapshot.integrations.firstOrNull { it.subTaskId == subTask.id } + if (integration?.state == IntegrationState.MERGED && integration.mainRevision != null) { + return PortResult.Success( + TransactionMutation( + MergeQueueLifecycleResponse(task, subTask, pullRequest, integration, queue), + snapshot, + ) + ) + } + } + if (queue.state != MergeQueueState.PASSED) { + return failurePort( + FailureCode.MERGE_QUEUE_FAILED, + "merge queue validation has not passed", + subTask.id, + ) + } + + if ( + providerQueue.changeRevisionId != changeRevisionId || + providerQueue.pullRequestId != pullRequest.id || + providerQueue.state != MergeQueueState.PASSED + ) { + return failurePort( + FailureCode.STALE_REVISION, + "merge queue provider state is stale", + subTask.id, + ) + } + val currentProviderRevision = providerQueue.providerRevision + val expectedProviderRevision = pullRequest.changeRevision.providerRevision + if (expectedProviderRevision == null) { + return failurePort( + FailureCode.STALE_REVISION, + "pull request provider revision is missing", + subTask.id, + ) + } + if ( + currentProviderRevision == null || + currentProviderRevision != expectedProviderRevision || + (queue.providerRevision != null && queue.providerRevision != currentProviderRevision) + ) { + return failurePort( + FailureCode.STALE_REVISION, + "merge queue provider revision is stale", + subTask.id, + ) + } + + val merged = + when ( + val result = + mergeQueuePort.merge( + MergeQueueMergeRequest( + entryId = queue.id, + expectedChangeRevisionId = changeRevisionId, + ) + ) + ) { + is PortResult.Failure -> return result + is PortResult.Success -> result.value + } + val integration = merged.integration + if ( + integration.subTaskId != subTask.id || + integration.state != IntegrationState.MERGED || + integration.mainRevision.isNullOrBlank() + ) { + return failurePort( + FailureCode.INVARIANT_VIOLATION, + "merge provider did not return a completed integration", + subTask.id, + merged.change, + ) + } + val mergedQueue = + integration.mergeQueue?.also { returned -> + if ( + returned.id != queue.id || + returned.subTaskId != subTask.id || + returned.pullRequestId != pullRequest.id || + returned.changeRevisionId != changeRevisionId || + returned.state != MergeQueueState.MERGED + ) { + return failurePort( + FailureCode.INVARIANT_VIOLATION, + "merge provider returned an invalid merge queue entry", + queue.id, + merged.change, + ) + } + } ?: queue.copy(state = MergeQueueState.MERGED) + + val updatedSubTask = subTask.copy(state = SubTaskState.MERGED) + val updatedPullRequest = pullRequest.copy(state = PullRequestState.MERGED) + val updatedIntegration = integration.copy(mergeQueue = mergedQueue) + val taskChange = + when (val result = updateExternalTask(task, subTask, request)) { + is PortResult.Failure -> return result.withChange(merged.change) + is PortResult.Success -> result.value.change + } + val event = MergeRecorded(subTask.id, integration.mainRevision, now(request.requestId)) + val updatedSnapshot = + snapshot.copy( + subTasks = snapshot.subTasks.replaceById(updatedSubTask) { it.id }, + pullRequests = snapshot.pullRequests.replaceById(updatedPullRequest) { it.id }, + integrations = snapshot.integrations.replaceOrAdd(updatedIntegration) { it.subTaskId }, + mergeQueue = snapshot.mergeQueue.replaceById(mergedQueue) { it.id }, + eventLog = snapshot.eventLog.append(event), + ) + return PortResult.Success( + TransactionMutation( + MergeQueueLifecycleResponse( + task = task, + subTask = updatedSubTask, + pullRequest = updatedPullRequest, + integration = updatedIntegration, + mergeQueue = mergedQueue, + ), + updatedSnapshot, + changes = listOf(merged.change, taskChange).filterNotNull(), + ) + ) + } + + private fun updateExternalTask( + task: Task, + subTask: SubTask, + request: MergeQueueLifecycleRequest, + ): PortResult { + val port = + taskPort + ?: return PortResult.Success( + UpdateExternalSubTaskResponse( + task.externalId, + subTask.id, + SubTaskState.MERGED, + ChangeReceipt( + "task-update-skipped-${request.requestId}", + "skip-task-update", + ), + ) + ) + val result = + port.updateSubTask( + UpdateExternalSubTaskRequest( + externalTaskId = task.externalId, + subTaskId = subTask.id, + state = SubTaskState.MERGED, + requestId = request.requestId, + ) + ) + if ( + result is PortResult.Success && + (result.value.externalTaskId != task.externalId || + result.value.subTaskId != subTask.id || + result.value.state != SubTaskState.MERGED) + ) { + return PortResult.Failure( + PortError( + FailureCode.INVARIANT_VIOLATION.name, + "task provider returned an invalid subtask update", + target = subTask.id, + ), + result.value.change, + ) + } + return result + } + + private fun refreshProviderQueue( + snapshot: WorkflowStoreSnapshot, + request: MergeQueueLifecycleRequest, + ): WorkflowResult { + val subTask = + snapshot.subTasks.firstOrNull { it.id == request.subTaskId } + ?: return failure( + FailureCode.SUBTASK_NOT_FOUND, + "subtask was not found", + request.subTaskId, + ) + val pullRequestId = + subTask.pullRequestId + ?: return failure( + FailureCode.REVIEW_NOT_FOUND, + "subtask does not have a pull request", + subTask.id, + ) + val pullRequest = + snapshot.pullRequests.firstOrNull { it.id == pullRequestId } + ?: return failure( + FailureCode.REVIEW_NOT_FOUND, + "pull request was not found", + pullRequestId, + ) + val expectedProviderRevision = pullRequest.changeRevision.providerRevision + if (expectedProviderRevision == null) { + return failure( + FailureCode.STALE_REVISION, + "pull request provider revision is missing", + pullRequest.id, + ) + } + val queue = + snapshot.mergeQueue.firstOrNull { + if (request.mergeQueueEntryId != null) it.id == request.mergeQueueEntryId + else it.subTaskId == subTask.id + } + ?: return failure( + FailureCode.MERGE_QUEUE_FAILED, + "merge queue entry was not found", + subTask.id, + ) + val providerQueue = + when ( + val result = mergeQueuePort.get(GetMergeQueueRequest(pullRequestId = pullRequest.id)) + ) { + is PortResult.Failure -> return result.toWorkflowFailure(FailureCode.EXTERNAL_FAILURE) + is PortResult.Success -> + result.value.entries.singleOrNull { it.subTaskId == subTask.id } + ?: return failure( + FailureCode.MERGE_QUEUE_FAILED, + "merge queue provider did not return the requested entry", + queue.id, + ) + } + if (providerQueue.pullRequestId != pullRequest.id) { + return failure( + FailureCode.INVARIANT_VIOLATION, + "merge queue provider returned a different pull request", + queue.id, + ) + } + val expectedChangeRevisionId = request.changeRevisionId ?: pullRequest.changeRevision.id + if (providerQueue.changeRevisionId != expectedChangeRevisionId) { + return failure(FailureCode.STALE_REVISION, "change revision is stale", subTask.id) + } + if (providerQueue.providerRevision != expectedProviderRevision) { + return failure( + FailureCode.STALE_REVISION, + "merge queue provider revision is missing or stale", + subTask.id, + ) + } + val refreshedEntry = + providerQueue.copy( + id = queue.id, + subTaskId = queue.subTaskId, + pullRequestId = queue.pullRequestId, + providerRevision = + queue.providerRevision + ?: expectedProviderRevision.takeIf { + providerQueue.providerRevision == expectedProviderRevision + }, + ) + if (refreshedEntry == queue) return WorkflowResult.Success(providerQueue) + + val refreshKey = + "merge-queue-refresh:${request.requestId}:${refreshedEntry.state}:${refreshedEntry.providerRevision ?: "unknown"}:${refreshedEntry.validations.hashCode()}" + val transactionRequest = + StoreTransactionRequest( + transactionId = issueTransactionId(refreshKey), + expectedRevision = snapshot.revision, + idempotencyKey = refreshKey, + ) + return WorkflowTransaction(storePort, compensationGateway()).execute(transactionRequest) { + current -> + val currentQueue = + current.mergeQueue.firstOrNull { + it.id == queue.id || + (it.subTaskId == queue.subTaskId && it.pullRequestId == queue.pullRequestId) + } + val currentEntry = refreshedEntry.copy(id = currentQueue?.id ?: refreshedEntry.id) + val updatedQueue = + current.mergeQueue.replaceOrAdd(currentEntry) { + it.id + } + PortResult.Success( + TransactionMutation( + providerQueue, + current.copy(mergeQueue = updatedQueue), + ) + ) + } + } + + private fun idempotentResponse( + snapshot: WorkflowStoreSnapshot, + request: MergeQueueLifecycleRequest, + ): MergeQueueLifecycleResponse? { + val subTask = snapshot.subTasks.firstOrNull { it.id == request.subTaskId } ?: return null + if (subTask.state != SubTaskState.MERGED) return null + val task = snapshot.tasks.firstOrNull { it.id == subTask.taskId } ?: return null + val pullRequestId = subTask.pullRequestId ?: return null + val pullRequest = snapshot.pullRequests.firstOrNull { it.id == pullRequestId } ?: return null + if (pullRequest.state != PullRequestState.MERGED) return null + val integration = + snapshot.integrations.firstOrNull { it.subTaskId == subTask.id } ?: return null + if (integration.state != IntegrationState.MERGED || integration.mainRevision.isNullOrBlank()) { + return null + } + val queue = + snapshot.mergeQueue.firstOrNull { + (request.mergeQueueEntryId == null || it.id == request.mergeQueueEntryId) && + it.subTaskId == subTask.id + } ?: return null + return MergeQueueLifecycleResponse( + task, + subTask, + pullRequest, + integration, + integration.mergeQueue ?: queue, + ) + } + + private fun issueTransactionId(requestId: String): String = + when (val result = idPort?.issue(IssueIdRequest(IdKind.TRANSACTION, requestId))) { + is PortResult.Success -> result.value.ids.firstOrNull()?.value ?: "tx-$requestId" + is PortResult.Failure, + null -> "tx-$requestId" + } + + private fun now(requestId: String): Long = + when (val result = clockPort?.now(NowRequest(requestId))) { + is PortResult.Success -> result.value.epochMillis + is PortResult.Failure, + null -> 0L + } + + private fun compensationGateway(): CompensationPort = + compensationPort + ?: object : CompensationPort { + override fun compensate(request: CompensateRequest): PortResult = + PortResult.Success(CompensateResponse(request.change)) + } + + private fun failurePort( + code: FailureCode, + message: String, + target: String, + change: ChangeReceipt? = null, + ): PortResult.Failure = PortResult.Failure(PortError(code.name, message, target = target), change) + + private fun failure( + code: FailureCode, + message: String, + target: String, + ): WorkflowResult.Failure = + WorkflowResult.Failure( + io.springkit.workflow.domain.FailureData( + code, + message, + blockedBy = + listOf(io.springkit.workflow.domain.BlockedBy(code.name, message, target)), + ) + ) +} + +private fun PortResult.Failure.withChange(change: ChangeReceipt): PortResult.Failure = + PortResult.Failure(error, this.change ?: change) + +private fun List.replaceById(value: T, id: (T) -> String): List = map { current -> + if (id(current) == id(value)) value else current +} + +private fun List.replaceOrAdd(value: T, id: (T) -> String): List = + if (any { id(it) == id(value) }) replaceById(value, id) else this + value diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/application/Ports.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/Ports.kt new file mode 100644 index 00000000..03bf6d53 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/Ports.kt @@ -0,0 +1,1094 @@ +package io.springkit.workflow.application + +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.AiReviewStatus +import io.springkit.workflow.domain.Approval +import io.springkit.workflow.domain.AuditEntry +import io.springkit.workflow.domain.BranchName +import io.springkit.workflow.domain.CandidateId +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.ChangeRevisionId +import io.springkit.workflow.domain.CheckResult +import io.springkit.workflow.domain.CheckSummary +import io.springkit.workflow.domain.CiRun +import io.springkit.workflow.domain.CiStatus +import io.springkit.workflow.domain.Dependency +import io.springkit.workflow.domain.DeploymentCandidate +import io.springkit.workflow.domain.DeploymentCandidateState +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.DomainEvent +import io.springkit.workflow.domain.EventLog +import io.springkit.workflow.domain.ExternalTaskId +import io.springkit.workflow.domain.IdSequence +import io.springkit.workflow.domain.Integration +import io.springkit.workflow.domain.MainRevision +import io.springkit.workflow.domain.MergeQueueEntry +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestId +import io.springkit.workflow.domain.Release +import io.springkit.workflow.domain.ReleaseId +import io.springkit.workflow.domain.ReviewComment +import io.springkit.workflow.domain.ReviewLevel +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.ReviewRevisionId +import io.springkit.workflow.domain.Risk +import io.springkit.workflow.domain.StartRequestKey +import io.springkit.workflow.domain.StartRequestRecord +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskId +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.SyncConflict +import io.springkit.workflow.domain.Task +import io.springkit.workflow.domain.TaskId +import io.springkit.workflow.domain.ThreadId +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspaceId +import io.springkit.workflow.domain.WorkspacePath + +/* + * 공급자 오류를 노출하지 않고 애플리케이션과 외부 포트가 공유하는 결과입니다. + */ +sealed interface PortResult { + data class Success(val value: T, val change: ChangeReceipt? = null) : PortResult + + data class Failure( + val error: PortError, + val change: ChangeReceipt? = null, + ) : PortResult +} + +data class PortError( + val code: String, + val message: String, + val retryable: Boolean = false, + val target: String? = null, +) + +/* + * 어댑터 또는 저장소 트랜잭션이 기록하는 되돌릴 수 있는 변경입니다. + */ +data class ChangeReceipt( + val id: String, + val operation: String, + val status: ChangeStatus = ChangeStatus.APPLIED, + val compensation: Compensation? = null, + val beforeRevision: String? = null, + val afterRevision: String? = null, +) { + init { + require(id.isNotBlank()) { "change receipt id must not be blank" } + require(operation.isNotBlank()) { "change receipt operation must not be blank" } + require(status != ChangeStatus.COMPENSATED || compensation != null) { + "compensated change must retain its compensation" + } + } +} + +enum class ChangeStatus { + APPLIED, + PENDING, + COMPENSATED, +} + +data class Compensation( + val id: String, + val operation: String, + val idempotencyKey: String, + val automatic: Boolean = true, +) { + init { + require(id.isNotBlank()) { "compensation id must not be blank" } + require(operation.isNotBlank()) { "compensation operation must not be blank" } + require(idempotencyKey.isNotBlank()) { "compensation idempotency key must not be blank" } + } +} + +data class PortPage(val values: List, val nextCursor: String? = null) + +enum class StoreScope { + ALL, + TASK, + SUBTASK, + WORKSPACE, + REVIEW, + INTEGRATION, + DEPLOYMENT, + RELEASE, +} + +data class WorkflowStoreSnapshot( + val revision: String, + val schemaVersion: Int = 1, + val sequence: IdSequence = IdSequence(), + val tasks: List = emptyList(), + val subTasks: List = emptyList(), + val dependencies: List = emptyList(), + val workspaces: List = emptyList(), + val pullRequests: List = emptyList(), + val checks: Map = emptyMap(), + val integrations: List = emptyList(), + val mergeQueue: List = emptyList(), + val candidates: List = emptyList(), + val releases: List = emptyList(), + val startRequests: Map = emptyMap(), + val syncConflicts: Map = emptyMap(), + val eventLog: EventLog = EventLog(), +) { + init { + require(revision.isNotBlank()) { "store revision must not be blank" } + require(schemaVersion == 1) { "unsupported store snapshot schema version: $schemaVersion" } + } +} + +data class StoreSnapshotRequest( + val scope: StoreScope = StoreScope.ALL, + val taskId: TaskId? = null, + val subTaskId: SubTaskId? = null, + val candidateId: CandidateId? = null, + val releaseId: ReleaseId? = null, +) + +data class StoreSnapshotResponse(val snapshot: WorkflowStoreSnapshot) + +data class StoreTransactionRequest( + val transactionId: String, + val expectedRevision: String? = null, + val idempotencyKey: String? = null, +) { + init { + require(transactionId.isNotBlank()) { "transaction id must not be blank" } + } +} + +data class StoreTransactionResponse( + val transactionId: String, + val state: StoreTransactionState, + val revision: String? = null, +) + +enum class StoreTransactionState { + OPEN, + COMMITTED, + ROLLED_BACK, +} + +data class StoreWriteRequest( + val transactionId: String, + val expectedRevision: String, + val snapshot: WorkflowStoreSnapshot, +) + +data class StoreWriteResponse(val revision: String) + +data class StoreEventRequest( + val transactionId: String, + val event: DomainEvent, + val audit: AuditEntry? = null, +) + +data class StoreEventResponse(val event: DomainEvent, val revision: String) + +/* + * 영속화된 Workflow 상태와 트랜잭션 경계를 제공합니다. + */ +interface WorkflowStorePort : StoreTransactionPort { + fun snapshot(request: StoreSnapshotRequest): PortResult + + fun write(request: StoreWriteRequest): PortResult + + fun append(request: StoreEventRequest): PortResult +} + +interface StoreTransactionPort { + fun begin(request: StoreTransactionRequest): PortResult + + fun commit(request: StoreTransactionRequest): PortResult + + fun rollback(request: StoreTransactionRequest): PortResult +} + +data class TaskLookupRequest( + val taskId: TaskId? = null, + val externalId: ExternalTaskId? = null, +) + +data class TaskLookupResponse(val task: Task) + +data class SubTaskLookupRequest(val subTaskId: SubTaskId) + +data class SubTaskLookupResponse(val subTask: SubTask) + +data class CreateSubTaskRequest( + val externalTaskId: ExternalTaskId, + val subTaskId: SubTaskId, + val title: String, + val requestId: String, + val requires: SubTaskId? = null, +) { + init { + require(subTaskId.isNotBlank()) { "subtask id must not be blank" } + require(title.isNotBlank()) { "subtask title must not be blank" } + require(requestId.isNotBlank()) { "subtask request id must not be blank" } + } +} + +data class CreateSubTaskResponse( + val task: Task, + val subTask: SubTask, + override val change: ChangeReceipt, +) : ChangeResponse + +data class UpdateExternalSubTaskRequest( + val externalTaskId: ExternalTaskId, + val subTaskId: SubTaskId, + val state: SubTaskState, + val requestId: String, +) + +data class UpdateExternalSubTaskResponse( + val externalTaskId: ExternalTaskId, + val subTaskId: SubTaskId, + val state: SubTaskState, + override val change: ChangeReceipt, +) : ChangeResponse + +/* + * 외부 작업 추적을 Workflow Store 상태와 분리합니다. + */ +interface TaskPort { + fun get(request: TaskLookupRequest): PortResult + + fun getSubTask(request: SubTaskLookupRequest): PortResult + + fun createSubTask(request: CreateSubTaskRequest): PortResult + + fun updateSubTask( + request: UpdateExternalSubTaskRequest + ): PortResult +} + +data class WorkspaceLookupRequest( + val workspaceId: WorkspaceId? = null, + val subTaskId: SubTaskId? = null, + val path: WorkspacePath? = null, +) + +data class ContentSnapshot( + val revision: String, + val fingerprint: String, + val dirty: Boolean, + val files: List = emptyList(), +) { + init { + require(revision.isNotBlank()) { "content revision must not be blank" } + require(fingerprint.isNotBlank()) { "content fingerprint must not be blank" } + } +} + +data class WorkspaceLookupResponse( + val workspace: Workspace, + val content: ContentSnapshot? = null, +) + +data class CreateWorkspaceRequest( + val workspaceId: WorkspaceId, + val subTaskId: SubTaskId, + val branch: BranchName, + val baseRevision: String, + val path: WorkspacePath, +) + +data class CreateWorkspaceResponse( + val workspace: Workspace, + override val change: ChangeReceipt, +) : ChangeResponse + +data class DeleteWorkspaceRequest(val workspaceId: WorkspaceId, val subTaskId: SubTaskId) + +data class DeleteWorkspaceResponse( + val workspaceId: WorkspaceId, + override val change: ChangeReceipt, +) : ChangeResponse + +data class InspectContentRequest( + val workspaceId: WorkspaceId, + val expectedRevision: String? = null, +) + +data class InspectContentResponse(val content: ContentSnapshot) + +interface WorkspacePort { + fun get(request: WorkspaceLookupRequest): PortResult + + fun create(request: CreateWorkspaceRequest): PortResult + + fun delete(request: DeleteWorkspaceRequest): PortResult +} + +interface ContentPort { + fun inspect(request: InspectContentRequest): PortResult +} + +data class MainRevisionRequest(val remote: String = "origin", val branch: BranchName = "main") + +data class MainRevisionResponse(val revision: MainRevision) + +data class GitStatus( + val revision: String, + val fingerprint: String, + val dirty: Boolean, + val conflicts: List = emptyList(), +) { + init { + require(revision.isNotBlank()) { "git revision must not be blank" } + require(fingerprint.isNotBlank()) { "git fingerprint must not be blank" } + } +} + +data class GitInspectRequest(val workspaceId: WorkspaceId) + +data class GitInspectResponse(val status: GitStatus) + +/* + * 지정된 Branch에 현재 Worktree를 게시할 수 있는지 읽기 전용으로 확인합니다. + */ +data class CheckRemotePushRequest( + val workspaceId: WorkspaceId, + val branch: BranchName, + val remote: String = "origin", +) { + init { + require(workspaceId.isNotBlank()) { "workspace id must not be blank" } + require(branch.isNotBlank()) { "branch must not be blank" } + require(remote.isNotBlank()) { "remote must not be blank" } + } +} + +/* + * Git push dry-run 결과입니다. + */ +data class CheckRemotePushResponse(val publishable: Boolean, val message: String? = null) + +data class CreateBranchRequest( + val branch: BranchName, + val baseBranch: BranchName, + val baseRevision: String, +) + +data class CreateBranchResponse( + val branch: BranchName, + val revision: String, + override val change: ChangeReceipt, +) : ChangeResponse + +data class CreateWorktreeRequest( + val workspaceId: WorkspaceId, + val branch: BranchName, + val path: WorkspacePath, +) + +data class CreateWorktreeResponse( + val workspaceId: WorkspaceId, + val branch: BranchName, + val path: WorkspacePath, + override val change: ChangeReceipt, +) : ChangeResponse + +data class RestackRequest( + val workspaceId: WorkspaceId, + val branch: BranchName, + val baseBranch: BranchName, + val baseRevision: String, + val expectedRevision: String, +) + +data class RestackResponse( + val revision: String, + val fingerprint: String, + val diffChanged: Boolean, + val conflicts: List = emptyList(), + override val change: ChangeReceipt, +) : ChangeResponse + +data class ContinueRestackRequest(val workspaceId: WorkspaceId) + +data class ContinueRestackResponse(override val change: ChangeReceipt) : ChangeResponse + +data class AbortRestackRequest(val workspaceId: WorkspaceId) + +data class AbortRestackResponse(override val change: ChangeReceipt) : ChangeResponse + +data class RemoveBranchRequest(val branch: BranchName, val expectedRevision: String? = null) + +data class RemoveBranchResponse(val branch: BranchName, override val change: ChangeReceipt) : + ChangeResponse + +/* + * 원격 저장소의 Branch를 나타냅니다. + */ +data class RemoteBranch( + val remote: String, + val branch: BranchName, + val revision: String? = null, +) + +/* + * 원격 Branch 목록 조회 요청입니다. + */ +data class ListRemoteBranchesRequest(val remote: String = "origin") { + init { + require(remote.isNotBlank()) { "remote must not be blank" } + } +} + +/* + * 원격 Branch 목록 조회 결과입니다. + */ +data class ListRemoteBranchesResponse(val branches: List) + +/* + * 원격 저장소에서 Branch를 제거하는 요청입니다. + */ +data class RemoveRemoteBranchRequest( + val remote: String = "origin", + val branch: BranchName, + val expectedRevision: String? = null, +) { + init { + require(remote.isNotBlank()) { "remote must not be blank" } + require(branch.isNotBlank()) { "remote branch must not be blank" } + } +} + +/* + * 원격 Branch 제거 결과입니다. + */ +data class RemoveRemoteBranchResponse( + val remote: String, + val branch: BranchName, + override val change: ChangeReceipt, +) : ChangeResponse + +/* + * Worktree 제거 요청입니다. + */ +data class RemoveWorktreeRequest( + val workspaceId: WorkspaceId, + val path: WorkspacePath, +) { + init { + require(workspaceId.isNotBlank()) { "workspace id must not be blank" } + } +} + +/* + * Worktree 제거 결과입니다. + */ +data class RemoveWorktreeResponse( + val workspaceId: WorkspaceId, + val path: WorkspacePath, + override val change: ChangeReceipt, +) : ChangeResponse + +interface GitPort { + fun refreshMain(request: MainRevisionRequest): PortResult + + fun inspect(request: GitInspectRequest): PortResult + + /* + * 원격 Branch 변경 없이 push 가능 여부를 확인합니다. + */ + fun checkRemotePush(request: CheckRemotePushRequest): PortResult = + PortResult.Failure( + PortError( + code = "GIT_PUBLISH_CHECK_UNSUPPORTED", + message = "원격 게시 가능성 확인을 지원하지 않습니다.", + target = request.branch, + ) + ) + + fun createBranch(request: CreateBranchRequest): PortResult + + fun createWorktree(request: CreateWorktreeRequest): PortResult + + fun restack(request: RestackRequest): PortResult + + fun continueRestack(request: ContinueRestackRequest): PortResult = + PortResult.Failure( + PortError( + code = "GIT_RECOVERY_UNSUPPORTED", + message = "Git rebase 복구 동작을 지원하지 않습니다.", + target = request.workspaceId, + ) + ) + + fun abortRestack(request: AbortRestackRequest): PortResult = + PortResult.Failure( + PortError( + code = "GIT_RECOVERY_UNSUPPORTED", + message = "Git rebase 복구 동작을 지원하지 않습니다.", + target = request.workspaceId, + ) + ) + + fun removeBranch(request: RemoveBranchRequest): PortResult + + /* + * 설치된 Git CLI를 사용해 원격 Branch 목록을 조회합니다. + */ + fun listRemoteBranches( + request: ListRemoteBranchesRequest + ): PortResult = + PortResult.Failure( + PortError( + code = "GIT_CLEANUP_UNSUPPORTED", + message = "원격 Branch 목록 조회를 지원하지 않습니다.", + target = request.remote, + ) + ) + + /* + * 설치된 Git CLI를 사용해 원격 Branch를 제거합니다. + */ + fun removeRemoteBranch( + request: RemoveRemoteBranchRequest + ): PortResult = + PortResult.Failure( + PortError( + code = "GIT_CLEANUP_UNSUPPORTED", + message = "원격 Branch 제거를 지원하지 않습니다.", + target = request.branch, + ) + ) + + /* + * 설치된 Git CLI를 사용해 Worktree를 제거합니다. + */ + fun removeWorktree(request: RemoveWorktreeRequest): PortResult = + PortResult.Failure( + PortError( + code = "GIT_CLEANUP_UNSUPPORTED", + message = "Worktree 제거를 지원하지 않습니다.", + target = request.workspaceId, + ) + ) +} + +/* + * 현재 Worktree의 변경을 원격 Branch에 게시하는 요청입니다. + */ +data class PublishBranchRequest( + val workspaceId: WorkspaceId, + val branch: BranchName, + val commitTitle: String, + val remote: String = "origin", + val expectedRevision: String? = null, + val expectedFingerprint: String? = null, +) { + init { + require(branch.isNotBlank()) { "publish branch must not be blank" } + require(commitTitle.isNotBlank()) { "publish commit title must not be blank" } + require(remote.isNotBlank()) { "publish remote must not be blank" } + } +} + +/* + * 원격 Branch 게시 결과입니다. + */ +data class PublishBranchResponse( + val branch: BranchName, + val revision: String, + val fingerprint: String, + val committed: Boolean, + override val change: ChangeReceipt, +) : ChangeResponse { + init { + require(branch.isNotBlank()) { "published branch must not be blank" } + require(revision.isNotBlank()) { "published revision must not be blank" } + require(fingerprint.isNotBlank()) { "published fingerprint must not be blank" } + } +} + +/* + * 설치된 Git CLI에 Branch 게시를 위임하는 포트입니다. + */ +interface GitPublishPort { + fun publish(request: PublishBranchRequest): PortResult +} + +data class OpenReviewRequest( + val subTaskId: SubTaskId, + val title: String, + val body: String, + val base: BranchName, + val branch: BranchName, + val risk: Risk, + val changeRevision: ChangeRevision, + val reviewRevision: ReviewRevision, +) + +data class OpenReviewResponse( + val pullRequest: PullRequest, + override val change: ChangeReceipt, +) : ChangeResponse + +data class GetReviewRequest(val pullRequestId: PullRequestId) + +data class GetReviewResponse(val pullRequest: PullRequest) + +data class UpdateReviewRequest( + val pullRequestId: PullRequestId, + val expectedReviewRevisionId: ReviewRevisionId, + val body: String? = null, + val base: BranchName? = null, + val changeRevision: ChangeRevision? = null, + val reviewRevision: ReviewRevision? = null, +) + +data class UpdateReviewResponse( + val pullRequest: PullRequest, + val codeChanged: Boolean, + override val change: ChangeReceipt, +) : ChangeResponse + +data class AddReviewCommentRequest( + val pullRequestId: PullRequestId, + val reviewRevisionId: ReviewRevisionId, + val author: Actor, + val level: ReviewLevel, + val comment: ReviewComment, +) + +data class AddReviewCommentResponse( + val reviewRevision: ReviewRevision, + val threadId: ThreadId, + override val change: ChangeReceipt, +) : ChangeResponse + +data class ReplyReviewThreadRequest( + val pullRequestId: PullRequestId, + val reviewRevisionId: ReviewRevisionId, + val threadId: ThreadId, + val comment: ReviewComment, +) + +data class ReplyReviewThreadResponse( + val reviewRevision: ReviewRevision, + override val change: ChangeReceipt, +) : ChangeResponse + +data class ResolveReviewThreadRequest( + val pullRequestId: PullRequestId, + val reviewRevisionId: ReviewRevisionId, + val threadId: ThreadId, + val actor: Actor, +) + +data class ResolveReviewThreadResponse( + val reviewRevision: ReviewRevision, + override val change: ChangeReceipt, +) : ChangeResponse + +data class ReadyReviewRequest( + val pullRequestId: PullRequestId, + val reviewRevisionId: ReviewRevisionId, + val actor: Actor, +) + +data class ReadyReviewResponse( + val pullRequest: PullRequest, + override val change: ChangeReceipt, +) : ChangeResponse + +data class ApproveReviewRequest( + val pullRequestId: PullRequestId, + val changeRevisionId: ChangeRevisionId, + val diffIdentity: String, + val actor: Actor, +) + +data class ApproveReviewResponse( + val pullRequest: PullRequest, + val approval: Approval, + override val change: ChangeReceipt, +) : ChangeResponse + +interface ReviewPort { + fun open(request: OpenReviewRequest): PortResult + + fun get(request: GetReviewRequest): PortResult + + fun update(request: UpdateReviewRequest): PortResult + + fun comment(request: AddReviewCommentRequest): PortResult + + fun reply(request: ReplyReviewThreadRequest): PortResult + + /* + * 요청 답글만 원격에 추가된 상태인지 검증한 뒤 저장 가능한 revision을 반환합니다. + */ + fun recoverReply(request: ReplyReviewThreadRequest): PortResult = + PortResult.Success(null) + + fun resolve(request: ResolveReviewThreadRequest): PortResult + + fun ready(request: ReadyReviewRequest): PortResult + + fun approve(request: ApproveReviewRequest): PortResult +} + +data class RunValidationRequest( + val workspaceId: WorkspaceId, + val revision: String, + val fingerprint: String, + val required: List = emptyList(), +) + +data class RunValidationResponse( + val validations: List, + val summary: CheckSummary? = null, + override val change: ChangeReceipt, +) : ChangeResponse + +data class GetValidationRequest( + val workspaceId: WorkspaceId? = null, + val revision: String, + val fingerprint: String? = null, +) + +data class GetValidationResponse(val validations: List, val checks: List) + +interface ValidationPort { + fun run(request: RunValidationRequest): PortResult + + fun get(request: GetValidationRequest): PortResult +} + +data class StartCiRequest( + val pullRequestId: PullRequestId, + val revision: String, + val checks: List = emptyList(), +) + +data class StartCiResponse( + val run: CiRun, + override val change: ChangeReceipt, +) : ChangeResponse + +data class GetCiRequest(val pullRequestId: PullRequestId, val revision: String? = null) + +data class GetCiResponse(val runs: List, val status: CiStatus) + +interface CiPort { + fun start(request: StartCiRequest): PortResult + + fun get(request: GetCiRequest): PortResult +} + +data class StartAiReviewRequest( + val pullRequestId: PullRequestId, + val reviewRevisionId: ReviewRevisionId, + val changeRevisionId: ChangeRevisionId, + val diff: Diff, +) + +data class StartAiReviewResponse( + val status: AiReviewStatus, + override val change: ChangeReceipt, +) : ChangeResponse + +data class GetAiReviewRequest( + val pullRequestId: PullRequestId, + val reviewRevisionId: ReviewRevisionId, +) + +data class GetAiReviewResponse( + val status: AiReviewStatus, + val comments: List = emptyList(), +) + +interface AiReviewPort { + fun start(request: StartAiReviewRequest): PortResult + + fun get(request: GetAiReviewRequest): PortResult +} + +data class EnqueueMergeRequest( + val subTaskId: SubTaskId, + val pullRequestId: PullRequestId, + val changeRevisionId: ChangeRevisionId, + val expectedRevision: String, +) + +data class EnqueueMergeResponse( + val entry: MergeQueueEntry, + override val change: ChangeReceipt, +) : ChangeResponse + +data class GetMergeQueueRequest( + val subTaskId: SubTaskId? = null, + val pullRequestId: PullRequestId? = null, +) + +data class GetMergeQueueResponse(val entries: List) + +data class MergeQueueMergeRequest( + val entryId: String, + val expectedChangeRevisionId: ChangeRevisionId, +) + +data class MergeQueueMergeResponse( + val integration: Integration, + override val change: ChangeReceipt, +) : ChangeResponse + +interface MergeQueuePort { + fun enqueue(request: EnqueueMergeRequest): PortResult + + fun get(request: GetMergeQueueRequest): PortResult + + fun merge(request: MergeQueueMergeRequest): PortResult +} + +data class CreateCandidateRequest( + val mainRevision: MainRevision, + val includedSubTasks: List, + val risks: Map, +) + +data class CreateCandidateResponse( + val candidate: DeploymentCandidate, + override val change: ChangeReceipt, +) : ChangeResponse + +data class GetCandidateRequest(val candidateId: CandidateId) + +data class GetCandidateResponse(val candidate: DeploymentCandidate) + +data class ValidateCandidateRequest(val candidateId: CandidateId, val mainRevision: MainRevision) + +data class ValidateCandidateResponse( + val candidate: DeploymentCandidate, + override val change: ChangeReceipt, +) : ChangeResponse + +data class StartCanaryRequest(val candidateId: CandidateId, val actor: Actor? = null) + +data class StartCanaryResponse( + val candidate: DeploymentCandidate, + override val change: ChangeReceipt, +) : ChangeResponse + +data class PromoteProductionRequest( + val candidateId: CandidateId, + val expectedState: DeploymentCandidateState, +) + +data class PromoteProductionResponse( + val candidate: DeploymentCandidate, + override val change: ChangeReceipt, +) : ChangeResponse + +interface DeploymentPort { + fun createCandidate(request: CreateCandidateRequest): PortResult + + fun getCandidate(request: GetCandidateRequest): PortResult + + fun validate(request: ValidateCandidateRequest): PortResult + + fun startCanary(request: StartCanaryRequest): PortResult + + fun promoteProduction(request: PromoteProductionRequest): PortResult +} + +data class GetReleaseRequest(val releaseId: ReleaseId? = null, val candidateId: CandidateId? = null) + +data class GetReleaseResponse(val releases: List) + +data class CreateReleaseRequest( + val releaseId: ReleaseId, + val candidateId: CandidateId, +) + +data class CreateReleaseResponse( + val release: Release, + override val change: ChangeReceipt, +) : ChangeResponse + +data class ValidateReleaseRequest(val releaseId: ReleaseId, val actor: Actor? = null) + +data class ValidateReleaseResponse( + val release: Release, + override val change: ChangeReceipt, +) : ChangeResponse + +data class StartReleaseRequest(val releaseId: ReleaseId, val actor: Actor) + +data class StartReleaseResponse( + val release: Release, + override val change: ChangeReceipt, +) : ChangeResponse + +data class ContinueReleaseRequest(val releaseId: ReleaseId) + +data class ContinueReleaseResponse( + val release: Release, + override val change: ChangeReceipt, +) : ChangeResponse + +interface ReleasePort { + fun get(request: GetReleaseRequest): PortResult + + fun create(request: CreateReleaseRequest): PortResult + + fun validateInternal(request: ValidateReleaseRequest): PortResult + + fun start(request: StartReleaseRequest): PortResult + + fun continueRollout(request: ContinueReleaseRequest): PortResult +} + +enum class Capability { + READ, + WRITE, + READY, + APPROVE, + DEPLOY, + RELEASE, +} + +data class CurrentActorRequest(val invocationId: String? = null) + +data class CurrentActorResponse(val actor: Actor) + +data class AuthorizeRequest( + val actor: Actor, + val capability: Capability, + val target: String, +) + +data class AuthorizeResponse(val allowed: Boolean, val reason: String? = null) + +interface IdentityPort { + fun currentActor( + request: CurrentActorRequest = CurrentActorRequest() + ): PortResult + + fun authorize(request: AuthorizeRequest): PortResult +} + +data class NowRequest(val requestId: String? = null) + +data class NowResponse(val epochMillis: Long) + +interface ClockPort { + fun now(request: NowRequest = NowRequest()): PortResult +} + +enum class IdKind { + TASK, + SUBTASK, + WORKSPACE, + BRANCH, + PULL_REQUEST, + REVIEW_REVISION, + CHANGE_REVISION, + THREAD, + COMMENT, + VALIDATION, + CHECK, + MERGE_QUEUE, + CANDIDATE, + RELEASE, + TRANSACTION, + EVENT, + AUDIT, + COMPENSATION, +} + +data class IssueIdRequest( + val kind: IdKind, + val requestId: String? = null, + val count: Int = 1, +) { + init { + require(count > 0) { "id count must be positive" } + } +} + +data class IssuedId(val kind: IdKind, val value: String) + +data class IssueIdResponse(val ids: List) + +interface IdPort { + fun issue(request: IssueIdRequest): PortResult +} + +data class CompensateRequest( + val change: ChangeReceipt, + val reason: String, +) { + init { + require(reason.isNotBlank()) { "compensation reason must not be blank" } + } +} + +data class CompensateResponse(val change: ChangeReceipt) + +/* + * 이전 변경 포트 응답에 포함된 보상 계획을 실행합니다. + */ +interface CompensationPort { + fun compensate(request: CompensateRequest): PortResult +} + +enum class ExternalEventKind { + TASK_CHANGED, + REVIEW_CHANGED, + CI_CHANGED, + AI_REVIEW_CHANGED, + MERGE_QUEUE_CHANGED, + MAIN_MERGED, + DEPLOYMENT_CHANGED, + CANARY_CHANGED, + RELEASE_CHANGED, +} + +data class ExternalEvent( + val id: String, + val kind: ExternalEventKind, + val targetId: String, + val occurredAtEpochMillis: Long, + val attributes: Map = emptyMap(), +) { + init { + require(id.isNotBlank()) { "external event id must not be blank" } + require(targetId.isNotBlank()) { "external event target must not be blank" } + } +} + +data class ReceiveEventRequest(val event: ExternalEvent) + +data class ReceiveEventResponse(val event: ExternalEvent, val accepted: Boolean) + +data class AcknowledgeEventRequest( + val eventId: String, + val accepted: Boolean, + val message: String? = null, +) + +data class AcknowledgeEventResponse(val eventId: String) + +/* + * 인바운드 이벤트를 공급자와 무관하게 멱등적으로 전달합니다. + */ +interface WorkflowEventPort { + fun receive(request: ReceiveEventRequest): PortResult + + fun acknowledge(request: AcknowledgeEventRequest): PortResult +} + +interface ChangeResponse { + val change: ChangeReceipt +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/application/PostMergeCleanupUseCase.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/PostMergeCleanupUseCase.kt new file mode 100644 index 00000000..62ade7db --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/PostMergeCleanupUseCase.kt @@ -0,0 +1,751 @@ +package io.springkit.workflow.application + +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.IntegrationState +import io.springkit.workflow.domain.NextAction +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskCleanupState +import io.springkit.workflow.domain.SubTaskId +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.WorkflowResult + +/* + * Merge 이후 정리 단계에서 차단된 동작을 기록합니다. + */ +data class PostMergeCleanupBlock( + val phase: String, + val target: String, + val code: String, + val message: String, +) + +/* + * Merge 이후 정리 요청입니다. + */ +data class PostMergeCleanupRequest( + val mergedSubTaskId: SubTaskId, + val expectedStoreRevision: String? = null, +) { + init { + require(mergedSubTaskId.isNotBlank()) { "merged subtask id must not be blank" } + } +} + +/* + * Merge 이후 정리 결과의 상태입니다. + */ +enum class PostMergeCleanupState { + COMPLETED, + BLOCKED, +} + +/* + * Merge 이후 정리 결과입니다. + */ +data class PostMergeCleanupResponse( + val mergedSubTaskId: SubTaskId, + val state: PostMergeCleanupState, + val mainRevision: String? = null, + val restackedSubTaskIds: List = emptyList(), + val removedRemoteBranches: List = emptyList(), + val removedWorkspaces: List = emptyList(), + val removedLocalBranches: List = emptyList(), + val blocks: List = emptyList(), +) + +/* + * 성공한 원격 Merge를 유지하면서 Stack, 원격 Branch와 로컬 Worktree를 순서대로 정리합니다. + */ +class PostMergeCleanupUseCase( + private val gitPort: GitPort, + private val reviewPort: ReviewPort, + private val storePort: WorkflowStorePort, + private val idPort: IdPort? = null, + private val clockPort: ClockPort? = null, +) { + /* + * 기존 Application 호출부의 Store, Git, Review 포트 순서를 지원합니다. + */ + constructor( + storePort: WorkflowStorePort, + gitPort: GitPort, + reviewPort: ReviewPort, + idPort: IdPort? = null, + clockPort: ClockPort? = null, + ) : this(gitPort, reviewPort, storePort, idPort, clockPort) + + /* + * Merge 이후 정리를 실행합니다. 정리 실패는 결과의 blocks에 남깁니다. + */ + fun execute(request: PostMergeCleanupRequest): WorkflowResult { + val initial = loadSnapshot() ?: return lastFailure + if ( + request.expectedStoreRevision != null && request.expectedStoreRevision != initial.revision + ) { + return failure( + FailureCode.STALE_REVISION, + "workflow store revision is stale", + request.mergedSubTaskId, + ) + } + val merged = initial.subTasks.firstOrNull { it.id == request.mergedSubTaskId } + if (merged == null) { + return failure( + FailureCode.SUBTASK_NOT_FOUND, + "merged subtask was not found", + request.mergedSubTaskId, + ) + } + if (!isMerged(initial, merged)) { + return failure( + FailureCode.STATE_CONFLICT, + "subtask is not merged", + request.mergedSubTaskId, + ) + } + + val blocks = mutableListOf() + val restacked = mutableListOf() + val children = + initial.subTasks.filter { + it.state != SubTaskState.MERGED && it.requires == request.mergedSubTaskId + } + val stackSync = + StackSyncUseCases( + gitPort, + reviewPort, + storePort, + idPort, + null, + clockPort, + ) + children.forEach { child -> + when (val result = stackSync.sync(SyncRequest(child.id))) { + is WorkflowResult.Success -> restacked += child.id + is WorkflowResult.Failure -> + blocks += + result.data.toCleanupBlocks( + phase = "restack", + fallbackTarget = child.id, + ) + } + } + + val current = loadSnapshot() + if (current == null) { + blocks += + PostMergeCleanupBlock( + phase = "remote-branch", + target = request.mergedSubTaskId, + code = "STORE_FAILURE", + message = lastFailure.data.message, + ) + } + var removedRemote = emptyList() + var removedWorkspaces = emptyList() + var removedLocal = emptyList() + if (blocks.none { it.phase == "restack" } && current != null) { + var currentSnapshot = current + val candidate = + currentSnapshot.subTasks.singleOrNull { + it.id == request.mergedSubTaskId && isMerged(currentSnapshot, it) + } + if (candidate == null) { + blocks += + PostMergeCleanupBlock( + phase = "remote-branch", + target = request.mergedSubTaskId, + code = FailureCode.SUBTASK_NOT_FOUND.name, + message = "정리할 merged SubTask를 찾을 수 없습니다.", + ) + } else { + var cleanupState = cleanupState(currentSnapshot, candidate) + if (cleanupState == SubTaskCleanupState.COMPLETED) { + return completedResponse(request.mergedSubTaskId, restacked) + } + var expectedRevision = cleanupRevision(currentSnapshot, candidate) + if (cleanupState < SubTaskCleanupState.REMOTE_BRANCH_REMOVED) { + when (val remoteResult = gitPort.listRemoteBranches(ListRemoteBranchesRequest())) { + is PortResult.Failure -> + blocks += remoteResult.toCleanupBlock("remote-branch", candidate.branch) + is PortResult.Success -> { + val remoteBranch = + remoteResult.value.branches.singleOrNull { it.branch == candidate.branch } + if (remoteBranch == null) { + val inspection = + inspectLocalArtifact( + currentSnapshot, + candidate, + blocks, + allowMissing = expectedRevision != null, + ) + if (expectedRevision == null && inspection != null) { + val expectedFingerprint = expectedLocalFingerprint(currentSnapshot, candidate) + if ( + expectedFingerprint == null || + inspection.status.fingerprint != expectedFingerprint + ) { + blocks += + PostMergeCleanupBlock( + phase = "worktree", + target = inspection.workspaceId, + code = "LOCAL_FINGERPRINT_MISMATCH", + message = "Worktree fingerprint가 저장된 diff identity와 달라 정리하지 않았습니다.", + ) + } else { + expectedRevision = inspection.status.revision + } + } else if ( + expectedRevision != null && + inspection != null && + inspection.status.revision != expectedRevision + ) { + blocks += + PostMergeCleanupBlock( + phase = "worktree", + target = inspection.workspaceId, + code = "UNPUSHED_COMMIT", + message = "Worktree HEAD가 저장된 기대 revision과 달라 정리하지 않았습니다.", + ) + } + if (expectedRevision == null) { + blocks += + PostMergeCleanupBlock( + phase = "remote-branch", + target = candidate.branch, + code = "EXPECTED_REVISION_UNAVAILABLE", + message = "원격 Branch가 없고 로컬 정리를 검증할 기대 revision을 확인할 수 없습니다.", + ) + } else if (blocks.none { it.phase == "worktree" }) { + currentSnapshot = + persistCleanupRevision(currentSnapshot, candidate, expectedRevision, blocks) + ?: currentSnapshot + if (blocks.isEmpty()) { + currentSnapshot = + persistCleanupState( + currentSnapshot, + candidate, + SubTaskCleanupState.REMOTE_BRANCH_REMOVED, + blocks, + ) ?: currentSnapshot + cleanupState = cleanupState(currentSnapshot, candidate) + } + } + } else if (remoteBranch.revision.isNullOrBlank()) { + blocks += + PostMergeCleanupBlock( + phase = "remote-branch", + target = candidate.branch, + code = "REMOTE_REVISION_UNAVAILABLE", + message = "원격 Branch revision을 확인할 수 없습니다.", + ) + } else { + expectedRevision = remoteBranch.revision + val providerRevision = providerRevision(currentSnapshot, candidate) + val checkpointRevision = + currentSnapshot.subTasks.firstOrNull { it.id == candidate.id }?.cleanupRevision + if ( + (providerRevision != null && providerRevision != expectedRevision) || + (checkpointRevision != null && checkpointRevision != expectedRevision) + ) { + blocks += + PostMergeCleanupBlock( + phase = "remote-branch", + target = candidate.branch, + code = "PROVIDER_REVISION_MISMATCH", + message = "PR provider revision과 원격 Branch revision이 달라 정리하지 않았습니다.", + ) + } + val inspection = inspectLocalArtifact(currentSnapshot, candidate, blocks) + if (inspection != null && inspection.status.revision != expectedRevision) { + blocks += + PostMergeCleanupBlock( + phase = "worktree", + target = inspection.workspaceId, + code = "UNPUSHED_COMMIT", + message = "Worktree HEAD가 원격 Branch revision과 달라 정리하지 않았습니다.", + ) + } + if (blocks.none { it.phase == "worktree" || it.phase == "remote-branch" }) { + currentSnapshot = + persistCleanupRevision(currentSnapshot, candidate, expectedRevision, blocks) + ?: currentSnapshot + } + if ( + blocks.none { + it.phase == "store" || it.phase == "worktree" || it.phase == "remote-branch" + } + ) { + when ( + val removed = + gitPort.removeRemoteBranch( + RemoveRemoteBranchRequest( + remote = remoteBranch.remote, + branch = remoteBranch.branch, + expectedRevision = remoteBranch.revision, + ) + ) + ) { + is PortResult.Failure -> + blocks += removed.toCleanupBlock("remote-branch", candidate.branch) + is PortResult.Success -> { + removedRemote = listOf(candidate.branch) + currentSnapshot = + persistCleanupState( + currentSnapshot, + candidate, + SubTaskCleanupState.REMOTE_BRANCH_REMOVED, + blocks, + ) ?: currentSnapshot + cleanupState = cleanupState(currentSnapshot, candidate) + } + } + } + } + } + } + } + if (blocks.isEmpty() && cleanupState == SubTaskCleanupState.REMOTE_BRANCH_REMOVED) { + val inspection = + inspectLocalArtifact(currentSnapshot, candidate, blocks, allowMissing = true) + if (expectedRevision == null && inspection != null) { + val expectedFingerprint = expectedLocalFingerprint(currentSnapshot, candidate) + if ( + expectedFingerprint == null || inspection.status.fingerprint != expectedFingerprint + ) { + blocks += + PostMergeCleanupBlock( + phase = "worktree", + target = inspection.workspaceId, + code = "LOCAL_FINGERPRINT_MISMATCH", + message = "Worktree fingerprint가 저장된 diff identity와 달라 정리하지 않았습니다.", + ) + } else { + expectedRevision = inspection.status.revision + } + } else if (inspection != null && inspection.status.revision != expectedRevision) { + blocks += + PostMergeCleanupBlock( + phase = "worktree", + target = inspection.workspaceId, + code = "UNPUSHED_COMMIT", + message = "Worktree HEAD가 저장된 기대 revision과 달라 정리하지 않았습니다.", + ) + } + if (expectedRevision == null && blocks.none { it.phase == "worktree" }) { + blocks += + PostMergeCleanupBlock( + phase = "worktree", + target = candidate.branch, + code = "EXPECTED_REVISION_UNAVAILABLE", + message = "Worktree가 없고 정리 재시도에 사용할 Git revision이 저장되어 있지 않습니다.", + ) + } + if (expectedRevision != null && blocks.none { it.phase == "worktree" }) { + currentSnapshot = + persistCleanupRevision(currentSnapshot, candidate, expectedRevision, blocks) + ?: currentSnapshot + } + if (blocks.none { it.phase == "worktree" }) { + if (inspection != null) { + when ( + val removed = + gitPort.removeWorktree( + RemoveWorktreeRequest(inspection.workspaceId, inspection.path) + ) + ) { + is PortResult.Failure -> + blocks += removed.toCleanupBlock("worktree", inspection.workspaceId) + is PortResult.Success -> removedWorkspaces = listOf(inspection.workspaceId) + } + } + if (blocks.none { it.phase == "worktree" }) { + currentSnapshot = + persistCleanupState( + currentSnapshot, + candidate, + SubTaskCleanupState.WORKTREE_REMOVED, + blocks, + ) ?: currentSnapshot + cleanupState = cleanupState(currentSnapshot, candidate) + } + } + } + if (blocks.isEmpty() && cleanupState == SubTaskCleanupState.WORKTREE_REMOVED) { + val localRevision = expectedRevision + if (localRevision == null) { + blocks += + PostMergeCleanupBlock( + phase = "local-branch", + target = candidate.branch, + code = "EXPECTED_REVISION_UNAVAILABLE", + message = "로컬 Branch 정리를 검증할 기대 revision을 확인할 수 없습니다.", + ) + } else { + when ( + val removed = + gitPort.removeBranch(RemoveBranchRequest(candidate.branch, localRevision)) + ) { + is PortResult.Failure -> + blocks += removed.toCleanupBlock("local-branch", candidate.branch) + is PortResult.Success -> { + removedLocal = listOf(candidate.branch) + currentSnapshot = + persistCleanupState( + currentSnapshot, + candidate, + SubTaskCleanupState.LOCAL_BRANCH_REMOVED, + blocks, + ) ?: currentSnapshot + cleanupState = cleanupState(currentSnapshot, candidate) + } + } + } + } + } + } + + val refreshed = gitPort.refreshMain(MainRevisionRequest()) + val mainRevision = + when (refreshed) { + is PortResult.Success -> refreshed.value.revision + is PortResult.Failure -> { + blocks += refreshed.toCleanupBlock("refresh-main", "origin/main") + null + } + } + if (mainRevision != null && blocks.isEmpty()) { + val completionSnapshot = loadSnapshot() + if (completionSnapshot == null) { + blocks += + PostMergeCleanupBlock( + phase = "store", + target = request.mergedSubTaskId, + code = "STORE_FAILURE", + message = lastFailure.data.message, + ) + } else { + val completionCandidate = + completionSnapshot.subTasks.singleOrNull { it.id == request.mergedSubTaskId } + when { + completionCandidate == null -> + blocks += + PostMergeCleanupBlock( + phase = "store", + target = request.mergedSubTaskId, + code = FailureCode.SUBTASK_NOT_FOUND.name, + message = "완료 저장할 SubTask를 찾을 수 없습니다.", + ) + cleanupState(completionSnapshot, completionCandidate) != + SubTaskCleanupState.LOCAL_BRANCH_REMOVED -> + blocks += + PostMergeCleanupBlock( + phase = "store", + target = request.mergedSubTaskId, + code = FailureCode.STATE_CONFLICT.name, + message = "SubTask의 cleanup 상태가 완료 저장 단계와 일치하지 않습니다.", + ) + else -> + persistCleanupState( + completionSnapshot, + completionCandidate, + SubTaskCleanupState.COMPLETED, + blocks, + ) + } + } + } + val state = + if (blocks.isEmpty()) PostMergeCleanupState.COMPLETED else PostMergeCleanupState.BLOCKED + val response = + PostMergeCleanupResponse( + mergedSubTaskId = request.mergedSubTaskId, + state = state, + mainRevision = mainRevision, + restackedSubTaskIds = restacked, + removedRemoteBranches = removedRemote, + removedWorkspaces = removedWorkspaces, + removedLocalBranches = removedLocal, + blocks = blocks, + ) + return WorkflowResult.Success( + response, + next = + if (blocks.isEmpty()) { + emptyList() + } else { + listOf(NextAction(ActorKind.WORKFLOW, "retry_cleanup")) + }, + ) + } + + /* + * Merge 이후 정리를 명령 형태로 실행합니다. + */ + fun cleanup(request: PostMergeCleanupRequest): WorkflowResult = + execute(request) + + /* + * Merge 이후 정리를 공통 진입점으로 실행합니다. + */ + fun handle(request: PostMergeCleanupRequest): WorkflowResult = + execute(request) + + private fun inspectLocalArtifact( + snapshot: WorkflowStoreSnapshot, + subTask: SubTask, + blocks: MutableList, + allowMissing: Boolean = false, + ): LocalInspection? { + val storedWorkspace = + snapshot.subTasks.firstOrNull { it.id == subTask.id }?.workspace + ?: snapshot.workspaces.firstOrNull { it.subTaskId == subTask.id } + ?: subTask.workspace + val workspace = storedWorkspace?.takeIf { + cleanupState(snapshot, subTask) < SubTaskCleanupState.WORKTREE_REMOVED + } + if (workspace == null) return null + return when (val inspected = gitPort.inspect(GitInspectRequest(workspace.id))) { + is PortResult.Failure -> { + if (!allowMissing || inspected.error.code != "WORKSPACE_NOT_FOUND") { + blocks += inspected.toCleanupBlock("worktree", workspace.id) + } + null + } + is PortResult.Success -> { + if (inspected.value.status.dirty) { + blocks += + PostMergeCleanupBlock( + phase = "worktree", + target = workspace.id, + code = FailureCode.WORKTREE_DIRTY.name, + message = "게시되지 않은 변경이 있는 Worktree를 제거하지 않았습니다.", + ) + null + } else { + LocalInspection(workspace.id, workspace.path, inspected.value.status) + } + } + } + } + + private fun cleanupState(snapshot: WorkflowStoreSnapshot, subTask: SubTask) = + snapshot.subTasks.firstOrNull { it.id == subTask.id }?.cleanupState ?: subTask.cleanupState + + private fun persistCleanupState( + snapshot: WorkflowStoreSnapshot, + subTask: SubTask, + state: SubTaskCleanupState, + blocks: MutableList, + ): WorkflowStoreSnapshot? { + val storedSubTask = snapshot.subTasks.firstOrNull { it.id == subTask.id } ?: subTask + if (storedSubTask.cleanupState >= state) return snapshot + val updatedSubTasks = + snapshot.subTasks.map { + if (it.id != subTask.id) it else it.copy(cleanupState = state) + } + val updated = snapshot.copy(subTasks = updatedSubTasks) + val transaction = + StoreTransactionRequest( + transactionId = "tx-cleanup-${subTask.id}-${snapshot.revision}-${state.name}", + expectedRevision = snapshot.revision, + idempotencyKey = "cleanup:${subTask.id}:${snapshot.revision}:${state.name}", + ) + when (val begun = storePort.begin(transaction)) { + is PortResult.Failure -> { + blocks += begun.toCleanupBlock("store", subTask.id) + return null + } + is PortResult.Success -> Unit + } + val writtenRevision = + when ( + val write = + storePort.write( + StoreWriteRequest(transaction.transactionId, snapshot.revision, updated) + ) + ) { + is PortResult.Failure -> { + storePort.rollback(transaction) + blocks += write.toCleanupBlock("store", subTask.id) + return null + } + is PortResult.Success -> write.value.revision + } + when (val committed = storePort.commit(transaction)) { + is PortResult.Failure -> { + storePort.rollback(transaction) + blocks += committed.toCleanupBlock("store", subTask.id) + return null + } + is PortResult.Success -> return updated.copy(revision = writtenRevision) + } + } + + private fun persistCleanupRevision( + snapshot: WorkflowStoreSnapshot, + subTask: SubTask, + revision: String, + blocks: MutableList, + ): WorkflowStoreSnapshot? { + val storedSubTask = snapshot.subTasks.firstOrNull { it.id == subTask.id } ?: subTask + val existingRevision = storedSubTask.cleanupRevision + if (existingRevision == revision) return snapshot + if (existingRevision != null) { + blocks += + PostMergeCleanupBlock( + phase = "store", + target = subTask.id, + code = "CLEANUP_REVISION_CONFLICT", + message = "정리 checkpoint의 Git revision이 기존 저장값과 다릅니다.", + ) + return null + } + val updatedSubTasks = + snapshot.subTasks.map { + if (it.id != subTask.id) it else it.copy(cleanupRevision = revision) + } + val updated = snapshot.copy(subTasks = updatedSubTasks) + val transaction = + StoreTransactionRequest( + transactionId = "tx-cleanup-${subTask.id}-${snapshot.revision}-REVISION", + expectedRevision = snapshot.revision, + idempotencyKey = "cleanup:${subTask.id}:${snapshot.revision}:REVISION", + ) + when (val begun = storePort.begin(transaction)) { + is PortResult.Failure -> { + blocks += begun.toCleanupBlock("store", subTask.id) + return null + } + is PortResult.Success -> Unit + } + val writtenRevision = + when ( + val write = + storePort.write( + StoreWriteRequest(transaction.transactionId, snapshot.revision, updated) + ) + ) { + is PortResult.Failure -> { + storePort.rollback(transaction) + blocks += write.toCleanupBlock("store", subTask.id) + return null + } + is PortResult.Success -> write.value.revision + } + when (val committed = storePort.commit(transaction)) { + is PortResult.Failure -> { + storePort.rollback(transaction) + blocks += committed.toCleanupBlock("store", subTask.id) + return null + } + is PortResult.Success -> return updated.copy(revision = writtenRevision) + } + } + + private fun completedResponse( + subTaskId: SubTaskId, + restacked: List, + ): WorkflowResult.Success = + WorkflowResult.Success( + PostMergeCleanupResponse( + mergedSubTaskId = subTaskId, + state = PostMergeCleanupState.COMPLETED, + restackedSubTaskIds = restacked, + ) + ) + + private fun isMerged(snapshot: WorkflowStoreSnapshot, subTask: SubTask): Boolean = + subTask.state == SubTaskState.MERGED || + snapshot.integrations.any { + it.subTaskId == subTask.id && it.state == IntegrationState.MERGED + } + + private fun loadSnapshot(): WorkflowStoreSnapshot? = + when (val result = storePort.snapshot(StoreSnapshotRequest(StoreScope.ALL))) { + is PortResult.Success -> result.value.snapshot + is PortResult.Failure -> { + lastFailure = result.toWorkflowFailure(FailureCode.STORE_FAILURE) + null + } + } + + private fun failure( + code: FailureCode, + message: String, + target: String, + ): WorkflowResult.Failure = + WorkflowResult.Failure( + FailureData( + code = code, + message = message, + blockedBy = listOf(BlockedBy(code.name, message, target)), + ) + ) + + private data class LocalInspection( + val workspaceId: String, + val path: io.springkit.workflow.domain.WorkspacePath, + val status: GitStatus, + ) + + private var lastFailure: WorkflowResult.Failure = + WorkflowResult.Failure( + FailureData(FailureCode.STORE_FAILURE, "workflow store operation failed") + ) +} + +private fun FailureData.toCleanupBlocks( + phase: String, + fallbackTarget: String, +): List = + if (blockedBy.isEmpty()) { + listOf(PostMergeCleanupBlock(phase, fallbackTarget, code.name, message)) + } else { + blockedBy.map { + PostMergeCleanupBlock(phase, it.target ?: fallbackTarget, it.code, it.message) + } + } + +private fun PortResult.Failure.toCleanupBlock( + phase: String, + fallbackTarget: String, +): PostMergeCleanupBlock = + PostMergeCleanupBlock(phase, error.target ?: fallbackTarget, error.code, error.message) + +private fun cleanupRevision( + snapshot: WorkflowStoreSnapshot, + subTask: SubTask, +): String? = + snapshot.subTasks.firstOrNull { it.id == subTask.id }?.cleanupRevision + ?: providerRevision(snapshot, subTask) + +private fun providerRevision( + snapshot: WorkflowStoreSnapshot, + subTask: SubTask, +): String? = + subTask.pullRequestId + ?.let { pullRequestId -> + snapshot.pullRequests + .firstOrNull { it.id == pullRequestId } + ?.changeRevision + ?.providerRevision + } + ?.takeIf { it.isNotBlank() } + +private fun expectedLocalFingerprint( + snapshot: WorkflowStoreSnapshot, + subTask: SubTask, +): String? = + subTask.pullRequestId + ?.let { pullRequestId -> + snapshot.pullRequests + .firstOrNull { it.id == pullRequestId } + ?.changeRevision + ?.diff + ?.identity + } + ?.takeIf { it.isNotBlank() } diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/application/ReleaseLifecycleUseCases.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/ReleaseLifecycleUseCases.kt new file mode 100644 index 00000000..cc84892f --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/ReleaseLifecycleUseCases.kt @@ -0,0 +1,793 @@ +package io.springkit.workflow.application + +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.CandidateId +import io.springkit.workflow.domain.DeploymentCandidate +import io.springkit.workflow.domain.DeploymentCandidateState +import io.springkit.workflow.domain.DeploymentRecorded +import io.springkit.workflow.domain.DomainEvent +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.NextAction +import io.springkit.workflow.domain.Release +import io.springkit.workflow.domain.ReleaseId +import io.springkit.workflow.domain.ReleaseRecorded +import io.springkit.workflow.domain.ReleaseState +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.nextReleaseAction + +/* + * Production 후보에서 Release를 만드는 요청입니다. + */ +data class CreateReleaseLifecycleRequest( + val candidateId: CandidateId, + val releaseId: ReleaseId? = null, + val expectedStoreRevision: String? = null, + val requestId: String = "release-create-$candidateId", +) { + init { + require(candidateId.isNotBlank()) { "release candidate id must not be blank" } + require(releaseId == null || releaseId.isNotBlank()) { "release id must not be blank" } + require(expectedStoreRevision == null || expectedStoreRevision.isNotBlank()) { + "expected store revision must not be blank" + } + require(requestId.isNotBlank()) { "release request id must not be blank" } + } +} + +/* + * Release 공급자와 Workflow Store에서 공개 대상을 조회하는 요청입니다. + */ +data class GetReleaseLifecycleRequest( + val releaseId: ReleaseId? = null, + val candidateId: CandidateId? = null, +) { + init { + require(releaseId != null || candidateId != null) { + "release id or candidate id is required" + } + require(releaseId == null || releaseId.isNotBlank()) { "release id must not be blank" } + require(candidateId == null || candidateId.isNotBlank()) { "candidate id must not be blank" } + require(releaseId == null || candidateId == null) { + "release id and candidate id cannot be used together" + } + } +} + +/* + * 내부 검수 결과를 Release에 반영하는 요청입니다. + */ +data class ValidateReleaseLifecycleRequest( + val releaseId: ReleaseId, + val requestId: String = "release-validate-$releaseId", + val expectedStoreRevision: String? = null, +) { + init { + require(releaseId.isNotBlank()) { "release id must not be blank" } + require(requestId.isNotBlank()) { "release validation request id must not be blank" } + require(expectedStoreRevision == null || expectedStoreRevision.isNotBlank()) { + "expected store revision must not be blank" + } + } +} + +/* + * 사람 Release Gate 이후 점진 공개 결과를 반영하는 요청입니다. + */ +data class ContinueRolloutLifecycleRequest( + val releaseId: ReleaseId, + val requestId: String = "release-rollout-$releaseId", + val expectedStoreRevision: String? = null, +) { + init { + require(releaseId.isNotBlank()) { "release id must not be blank" } + require(requestId.isNotBlank()) { "release rollout request id must not be blank" } + require(expectedStoreRevision == null || expectedStoreRevision.isNotBlank()) { + "expected store revision must not be blank" + } + } +} + +/* + * Production 후보와 연결된 Release입니다. + */ +data class ReleaseLifecycleResponse( + val release: Release, + val candidate: DeploymentCandidate, + val next: List = nextReleaseAction(release)?.let(::listOf).orEmpty(), +) + +/* + * Release Event 처리 결과입니다. Release가 아직 만들어지지 않은 Production Event는 null을 반환합니다. + */ +data class ReleaseEventResponse(val response: ReleaseLifecycleResponse?) + +/* + * Release를 생성하고 내부 검수부터 rollout 완료까지 조정합니다. + */ +class ReleaseLifecycleUseCases( + private val storePort: WorkflowStorePort, + private val releasePort: ReleasePort, + private val idPort: IdPort? = null, + private val clockPort: ClockPort? = null, + private val compensationPort: CompensationPort? = null, +) { + /* + * Production 후보에 Release를 만들고 내부 검수를 시작합니다. + */ + fun create(request: CreateReleaseLifecycleRequest): WorkflowResult { + val snapshot = loadSnapshot() ?: return lastFailure + if ( + request.expectedStoreRevision != null && request.expectedStoreRevision != snapshot.revision + ) { + return failure( + FailureCode.STALE_REVISION, + "workflow store revision is stale", + request.candidateId, + ) + } + val candidate = + snapshot.candidates.firstOrNull { it.id == request.candidateId } + ?: return failure( + FailureCode.DEPLOYMENT_NOT_FOUND, + "deployment candidate was not found", + request.candidateId, + ) + val existing = snapshot.releases.firstOrNull { it.candidateId == candidate.id } + if (existing != null) { + if (request.releaseId != null && request.releaseId != existing.id) { + return failure( + FailureCode.IDEMPOTENCY_CONFLICT, + "release request targets a different existing release", + candidate.id, + ) + } + return responseFor(snapshot, existing) + } + if (candidate.state != DeploymentCandidateState.PRODUCTION) { + return failure( + FailureCode.RELEASE_GATE_BLOCKED, + "release requires a production deployment candidate", + candidate.id, + ) + } + if (request.releaseId != null && snapshot.releases.any { it.id == request.releaseId }) { + return failure( + FailureCode.IDEMPOTENCY_CONFLICT, + "release request targets a different existing release", + candidate.id, + ) + } + val releaseId = request.releaseId ?: issueReleaseId(request.requestId) + if (releaseId == null) { + return failure(FailureCode.EXTERNAL_FAILURE, "release id could not be issued", candidate.id) + } + val latestSnapshot = + if (request.releaseId == null) { + loadSnapshot() ?: return lastFailure + } else { + snapshot + } + val latestCandidate = + if (request.releaseId == null) { + latestSnapshot.candidates.firstOrNull { it.id == request.candidateId } + ?: return failure( + FailureCode.DEPLOYMENT_NOT_FOUND, + "deployment candidate was not found", + request.candidateId, + ) + } else { + candidate + } + val latestExisting = + latestSnapshot.releases.firstOrNull { it.candidateId == latestCandidate.id } + if (latestExisting != null) { + if (request.releaseId != null && request.releaseId != latestExisting.id) { + return failure( + FailureCode.IDEMPOTENCY_CONFLICT, + "release request targets a different existing release", + latestCandidate.id, + ) + } + return responseFor(latestSnapshot, latestExisting) + } + if (request.releaseId != null && latestSnapshot.releases.any { it.id == request.releaseId }) { + return failure( + FailureCode.IDEMPOTENCY_CONFLICT, + "release request targets a different existing release", + latestCandidate.id, + ) + } + if (latestCandidate.state != DeploymentCandidateState.PRODUCTION) { + return failure( + FailureCode.RELEASE_GATE_BLOCKED, + "release requires a production deployment candidate", + latestCandidate.id, + ) + } + val transaction = + transaction( + latestSnapshot.revision, + "release-create:${request.requestId}", + ) + return executeReleaseTransaction(transaction) { current, changes -> + createAndValidate(current, request, latestCandidate, releaseId, changes) + } + } + + /* + * Release를 Store와 공급자에서 조회합니다. + */ + fun get(request: GetReleaseLifecycleRequest): WorkflowResult { + val snapshot = loadSnapshot() ?: return lastFailure + val stored = + if (request.releaseId != null) { + snapshot.releases.firstOrNull { it.id == request.releaseId } + } else { + snapshot.releases.firstOrNull { it.candidateId == request.candidateId } + } + ?: return failure( + FailureCode.RELEASE_NOT_FOUND, + "release was not found", + request.releaseId ?: request.candidateId!!, + ) + val provider = + when (val result = releasePort.get(GetReleaseRequest(releaseId = stored.id))) { + is PortResult.Failure -> return result.toWorkflowFailure(FailureCode.EXTERNAL_FAILURE) + is PortResult.Success -> + result.value.releases.singleOrNull { it.id == stored.id } + ?: return failure( + FailureCode.RELEASE_NOT_FOUND, + "release provider did not return the requested release", + stored.id, + ) + } + if (provider.candidateId != stored.candidateId) { + return failure( + FailureCode.INVARIANT_VIOLATION, + "release provider returned a different release target", + stored.id, + ) + } + return responseFor(snapshot, provider) + } + + /* + * 내부 검수 결과를 확인하고 production 공개 승인 대기 상태로 전이합니다. + */ + fun validateInternal( + request: ValidateReleaseLifecycleRequest + ): WorkflowResult { + val snapshot = loadSnapshot() ?: return lastFailure + if ( + request.expectedStoreRevision != null && request.expectedStoreRevision != snapshot.revision + ) { + return failure( + FailureCode.STALE_REVISION, + "workflow store revision is stale", + request.releaseId, + ) + } + val stored = + snapshot.releases.firstOrNull { it.id == request.releaseId } + ?: return failure( + FailureCode.RELEASE_NOT_FOUND, + "release was not found", + request.releaseId, + ) + if ( + stored.state == ReleaseState.AWAITING_RELEASE_APPROVAL || + stored.state == ReleaseState.ROLLOUT || + stored.state == ReleaseState.CLEANUP_REQUIRED + ) { + return responseFor(snapshot, stored) + } + val candidate = + snapshot.candidates.firstOrNull { it.id == stored.candidateId } + ?: return failure( + FailureCode.DEPLOYMENT_NOT_FOUND, + "deployment candidate was not found", + stored.candidateId, + ) + val transaction = transaction(snapshot.revision, "release-validate:${request.requestId}") + return executeReleaseTransaction(transaction) { current, changes -> + validateIn(current, request, stored, candidate, changes) + } + } + + /* + * Release Gate 이후 공급자의 점진 공개 결과를 반영합니다. + */ + fun continueRollout( + request: ContinueRolloutLifecycleRequest + ): WorkflowResult { + val snapshot = loadSnapshot() ?: return lastFailure + if ( + request.expectedStoreRevision != null && request.expectedStoreRevision != snapshot.revision + ) { + return failure( + FailureCode.STALE_REVISION, + "workflow store revision is stale", + request.releaseId, + ) + } + val stored = + snapshot.releases.firstOrNull { it.id == request.releaseId } + ?: return failure( + FailureCode.RELEASE_NOT_FOUND, + "release was not found", + request.releaseId, + ) + if (stored.state == ReleaseState.CLEANUP_REQUIRED) { + return responseFor(snapshot, stored) + } + if (stored.state == ReleaseState.RELEASED) { + val cleaned = stored.copy(state = ReleaseState.CLEANUP_REQUIRED) + val transaction = transaction(snapshot.revision, "release-cleanup:${request.requestId}") + return executeReleaseTransaction(transaction) { current, _ -> + PortResult.Success( + TransactionMutation( + data = responseData(current, cleaned), + snapshot = + current.copy( + releases = current.releases.replaceById(cleaned) { value -> value.id }, + eventLog = + current.eventLog.append( + ReleaseRecorded(cleaned.id, cleaned.state, now(request.requestId)), + ), + ), + ), + ) + } + } + if (stored.state != ReleaseState.ROLLOUT) { + return failure(FailureCode.RELEASE_GATE_BLOCKED, "release is not in rollout", stored.id) + } + val candidate = + snapshot.candidates.firstOrNull { it.id == stored.candidateId } + ?: return failure( + FailureCode.DEPLOYMENT_NOT_FOUND, + "deployment candidate was not found", + stored.candidateId, + ) + val transaction = transaction(snapshot.revision, "release-rollout:${request.requestId}") + return executeReleaseTransaction(transaction) { current, changes -> + val continued = + when (val result = releasePort.continueRollout(ContinueReleaseRequest(stored.id))) { + is PortResult.Failure -> { + changes += result.change + return@executeReleaseTransaction result + } + is PortResult.Success -> { + changes += result.change + result.value + } + } + if ( + continued.release.id != stored.id || continued.release.candidateId != stored.candidateId + ) { + return@executeReleaseTransaction failurePort( + FailureCode.INVARIANT_VIOLATION, + "release provider returned a different rollout target", + stored.id, + continued.change, + ) + } + val updated = normalizeRolloutResult(continued.release) + val event = ReleaseRecorded(updated.id, updated.state, now(request.requestId)) + PortResult.Success( + TransactionMutation( + data = responseData(current, updated, candidate), + snapshot = + current.copy( + releases = current.releases.replaceById(updated) { value -> value.id }, + eventLog = current.eventLog.append(event), + ), + changes = listOf(continued.change), + ) + ) + } + } + + /* + * Production 배포 Event를 반영하고 필요한 Release를 멱등적으로 생성합니다. + */ + fun handleDeployment(event: DeploymentRecorded): WorkflowResult { + return when (val result = handleDeploymentAll(event)) { + is WorkflowResult.Failure -> result + is WorkflowResult.Success -> WorkflowResult.Success(result.data.firstOrNull()) + } + } + + /* + * Production 배포 Event를 반영해 candidate Release를 멱등적으로 생성합니다. + */ + fun handleDeploymentAll( + event: DeploymentRecorded + ): WorkflowResult> { + val snapshot = loadSnapshot() ?: return lastFailure + val candidate = + snapshot.candidates.firstOrNull { it.id == event.targetId } + ?: return failure( + FailureCode.DEPLOYMENT_NOT_FOUND, + "deployment candidate was not found", + event.targetId, + ) + if (event.state != DeploymentCandidateState.PRODUCTION) { + return WorkflowResult.Success(emptyList()) + } + return when ( + val result = + create( + CreateReleaseLifecycleRequest( + candidateId = candidate.id, + requestId = "event-${event.targetId}-${event.occurredAtEpochMillis}", + ) + ) + ) { + is WorkflowResult.Failure -> result + is WorkflowResult.Success -> WorkflowResult.Success(listOf(result.data)) + } + } + + /* + * Release Event를 반영해 내부 검수, rollout과 cleanup 다음 행동을 계산합니다. + */ + fun handleRelease(event: ReleaseRecorded): WorkflowResult { + val snapshot = loadSnapshot() ?: return lastFailure + val stored = + snapshot.releases.firstOrNull { it.id == event.targetId } + ?: return failure( + FailureCode.RELEASE_NOT_FOUND, + "release was not found", + event.targetId, + ) + val candidate = + snapshot.candidates.firstOrNull { it.id == stored.candidateId } + ?: return failure( + FailureCode.DEPLOYMENT_NOT_FOUND, + "deployment candidate was not found", + stored.candidateId, + ) + val updated = normalizeEventResult(stored, event.state) + if (updated == stored) return responseFor(snapshot, stored, candidate) + val transaction = + transaction( + snapshot.revision, + "release-event:${event.targetId}-${event.occurredAtEpochMillis}", + ) + return executeReleaseTransaction(transaction) { current, _ -> + PortResult.Success( + TransactionMutation( + data = responseData(current, updated, candidate), + snapshot = + current.copy( + releases = current.releases.replaceById(updated) { value -> value.id }, + eventLog = current.eventLog.append(event), + ), + ) + ) + } + } + + /* + * 외부 Event의 종류에 따라 Release lifecycle을 처리합니다. + */ + fun handle(event: DomainEvent): WorkflowResult<*> = + when (event) { + is DeploymentRecorded -> handleDeployment(event) + is ReleaseRecorded -> handleRelease(event) + else -> + failure( + FailureCode.INVALID_ARGUMENT, + "unsupported release lifecycle event", + event.targetId, + ) + } + + private fun createAndValidate( + snapshot: WorkflowStoreSnapshot, + request: CreateReleaseLifecycleRequest, + candidate: DeploymentCandidate, + releaseId: ReleaseId, + changes: MutableList, + ): PortResult> { + val created = + when (val result = releasePort.create(CreateReleaseRequest(releaseId, candidate.id))) { + is PortResult.Failure -> { + changes += result.change + return result + } + is PortResult.Success -> { + changes += result.change + result.value + } + } + if (created.release.id != releaseId || created.release.candidateId != candidate.id) { + return failurePort( + FailureCode.INVARIANT_VIOLATION, + "release provider returned an invalid release", + releaseId, + ) + } + val validating = + when (val result = releasePort.validateInternal(ValidateReleaseRequest(releaseId))) { + is PortResult.Failure -> { + changes += result.change + return PortResult.Failure(result.error, result.change ?: created.change) + } + is PortResult.Success -> { + changes += result.change + result.value + } + } + if (validating.release.id != releaseId || validating.release.candidateId != candidate.id) { + return failurePort( + FailureCode.INVARIANT_VIOLATION, + "internal validation returned an invalid release", + releaseId, + ) + } + val updated = normalizeValidationResult(validating.release, candidate) + val event = ReleaseRecorded(updated.id, updated.state, now(request.requestId)) + return PortResult.Success( + TransactionMutation( + data = responseData(snapshot, updated, candidate), + snapshot = + snapshot.copy( + releases = snapshot.releases.replaceOrAdd(updated) { value -> value.id }, + eventLog = snapshot.eventLog.append(event), + ), + changes = listOf(created.change, validating.change), + ) + ) + } + + private fun validateIn( + snapshot: WorkflowStoreSnapshot, + request: ValidateReleaseLifecycleRequest, + stored: Release, + candidate: DeploymentCandidate, + changes: MutableList, + ): PortResult> { + val validated = + when (val result = releasePort.validateInternal(ValidateReleaseRequest(stored.id))) { + is PortResult.Failure -> { + changes += result.change + return result + } + is PortResult.Success -> { + changes += result.change + result.value + } + } + if (validated.release.id != stored.id || validated.release.candidateId != candidate.id) { + return failurePort( + FailureCode.INVARIANT_VIOLATION, + "internal validation returned an invalid release", + stored.id, + ) + } + val updated = normalizeValidationResult(validated.release, candidate) + val event = ReleaseRecorded(updated.id, updated.state, now(request.requestId)) + return PortResult.Success( + TransactionMutation( + data = responseData(snapshot, updated, candidate), + snapshot = + snapshot.copy( + releases = snapshot.releases.replaceById(updated) { value -> value.id }, + eventLog = snapshot.eventLog.append(event), + ), + changes = listOf(validated.change), + ) + ) + } + + private fun normalizeValidationResult( + release: Release, + candidate: DeploymentCandidate, + ): Release { + val productionReady = candidate.state == DeploymentCandidateState.PRODUCTION + val passed = release.internalValidationPassed + return release.copy( + state = + if (productionReady && passed) ReleaseState.AWAITING_RELEASE_APPROVAL + else ReleaseState.INTERNAL_VALIDATION, + productionReady = productionReady, + ) + } + + private fun normalizeRolloutResult(release: Release): Release = + if (release.state == ReleaseState.RELEASED) { + release.copy(state = ReleaseState.CLEANUP_REQUIRED) + } else { + release + } + + private fun normalizeEventResult(release: Release, state: ReleaseState): Release = + if (releaseStateRank(state) < releaseStateRank(release.state)) { + release + } else { + when (state) { + ReleaseState.RELEASED -> release.copy(state = ReleaseState.CLEANUP_REQUIRED) + ReleaseState.AWAITING_RELEASE_APPROVAL -> + release.copy(state = state, internalValidationPassed = true, productionReady = true) + else -> release.copy(state = state) + } + } + + private fun releaseStateRank(state: ReleaseState): Int = + when (state) { + ReleaseState.NOT_APPLICABLE -> -1 + ReleaseState.SAFE_DEFAULT -> 0 + ReleaseState.INTERNAL_VALIDATION -> 1 + ReleaseState.AWAITING_RELEASE_APPROVAL -> 2 + ReleaseState.ROLLOUT -> 3 + ReleaseState.RELEASED, + ReleaseState.CLEANUP_REQUIRED, + -> 4 + } + + private fun responseFor( + snapshot: WorkflowStoreSnapshot, + release: Release, + candidate: DeploymentCandidate? = + snapshot.candidates.firstOrNull { it.id == release.candidateId }, + ): WorkflowResult { + if (candidate == null) + return failure( + FailureCode.DEPLOYMENT_NOT_FOUND, + "deployment candidate was not found", + release.candidateId, + ) + return WorkflowResult.Success(responseData(snapshot, release, candidate)) + } + + private fun responseData( + snapshot: WorkflowStoreSnapshot, + release: Release, + candidate: DeploymentCandidate? = + snapshot.candidates.firstOrNull { it.id == release.candidateId }, + ): ReleaseLifecycleResponse = + ReleaseLifecycleResponse( + release = release, + candidate = requireNotNull(candidate), + ) + + private var lastFailure: WorkflowResult.Failure = + failure(FailureCode.STORE_FAILURE, "workflow store lookup failed", "store") + + private fun loadSnapshot(): WorkflowStoreSnapshot? = + when (val result = storePort.snapshot(StoreSnapshotRequest(StoreScope.ALL))) { + is PortResult.Failure -> { + lastFailure = result.toWorkflowFailure(FailureCode.STORE_FAILURE) + null + } + is PortResult.Success -> result.value.snapshot + } + + private fun issueReleaseId(requestId: String): ReleaseId? = + when (val result = idPort?.issue(IssueIdRequest(IdKind.RELEASE, requestId))) { + is PortResult.Success -> result.value.ids.firstOrNull()?.value + is PortResult.Failure, + null -> null + } + + private fun transaction(revision: String, operation: String): StoreTransactionRequest = + StoreTransactionRequest( + transactionId = issueTransactionId(operation), + expectedRevision = revision, + idempotencyKey = operation, + ) + + private fun issueTransactionId(requestId: String): String = + when (val result = idPort?.issue(IssueIdRequest(IdKind.TRANSACTION, requestId))) { + is PortResult.Success -> result.value.ids.firstOrNull()?.value ?: "tx-$requestId" + is PortResult.Failure, + null -> "tx-$requestId" + } + + private fun executeReleaseTransaction( + request: StoreTransactionRequest, + operation: + (WorkflowStoreSnapshot, MutableList) -> PortResult< + TransactionMutation + >, + ): WorkflowResult { + val begun = storePort.begin(request) + if (begun is PortResult.Failure) { + return begun.toWorkflowFailure(FailureCode.STORE_FAILURE) + } + val snapshotResult = storePort.snapshot(StoreSnapshotRequest()) + if (snapshotResult is PortResult.Failure) { + storePort.rollback(request) + return snapshotResult.toWorkflowFailure(FailureCode.STORE_FAILURE) + } + val snapshot = (snapshotResult as PortResult.Success).value.snapshot + val changes = mutableListOf() + return when (val result = operation(snapshot, changes)) { + is PortResult.Failure -> { + result.change?.let { changes += it } + compensate(changes, result.error.message) + storePort.rollback(request) + result.toWorkflowFailure(FailureCode.EXTERNAL_FAILURE) + } + is PortResult.Success -> { + val mutation = result.value + val allChanges = + (changes.filterNotNull() + mutation.changes).distinctBy { change -> change.id } + when ( + val write = + storePort.write( + StoreWriteRequest( + transactionId = request.transactionId, + expectedRevision = mutation.snapshot.revision, + snapshot = mutation.snapshot, + ) + ) + ) { + is PortResult.Failure -> { + compensate(allChanges, write.error.message) + storePort.rollback(request) + write.toWorkflowFailure(FailureCode.STORE_FAILURE) + } + is PortResult.Success -> { + when (val committed = storePort.commit(request)) { + is PortResult.Failure -> committed.toWorkflowFailure(FailureCode.STORE_FAILURE) + is PortResult.Success -> WorkflowResult.Success(mutation.data) + } + } + } + } + } + } + + private fun compensate(changes: List, reason: String) { + changes + .filterNotNull() + .asReversed() + .distinctBy { change -> change.id } + .forEach { change -> + if (change.compensation != null) { + compensationGateway().compensate(CompensateRequest(change, reason)) + } + } + } + + private fun now(requestId: String): Long = + when (val result = clockPort?.now(NowRequest(requestId))) { + is PortResult.Success -> result.value.epochMillis + is PortResult.Failure, + null -> 0L + } + + private fun compensationGateway(): CompensationPort = + compensationPort + ?: object : CompensationPort { + override fun compensate(request: CompensateRequest): PortResult = + PortResult.Success(CompensateResponse(request.change)) + } + + private fun failure( + code: FailureCode, + message: String, + target: String, + ): WorkflowResult.Failure = + WorkflowResult.Failure( + FailureData(code, message, blockedBy = listOf(BlockedBy(code.name, message, target))), + ) + + private fun failurePort( + code: FailureCode, + message: String, + target: String, + change: ChangeReceipt? = null, + ): PortResult.Failure = PortResult.Failure(PortError(code.name, message, target = target), change) +} + +private fun List.replaceById(value: T, id: (T) -> String): List = map { current -> + if (id(current) == id(value)) value else current +} + +private fun List.replaceOrAdd(value: T, id: (T) -> String): List = + if (any { id(it) == id(value) }) replaceById(value, id) else this + value diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/application/ReviewGateUseCases.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/ReviewGateUseCases.kt new file mode 100644 index 00000000..34c7ef6f --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/ReviewGateUseCases.kt @@ -0,0 +1,818 @@ +package io.springkit.workflow.application + +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.Approval +import io.springkit.workflow.domain.AuditEntry +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.ChangeRevisionId +import io.springkit.workflow.domain.CiStatus +import io.springkit.workflow.domain.DiffIdentity +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.GateRecorded +import io.springkit.workflow.domain.GateType +import io.springkit.workflow.domain.IntegrationState +import io.springkit.workflow.domain.MergeQueueEntry +import io.springkit.workflow.domain.MergeQueueState +import io.springkit.workflow.domain.NextAction +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.ReviewRevisionId +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskId +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.WorkflowRules +import io.springkit.workflow.domain.toSubTaskState + +/* + * 사람이 리뷰 Gate를 기록할 때 사용하는 Ready 요청입니다. + */ +data class ReadyGateRequest( + val subTaskId: SubTaskId, + val reviewRevisionId: ReviewRevisionId, + val requestId: String = "gate-ready-" + subTaskId + "-" + reviewRevisionId, + val expectedStoreRevision: String? = null, +) + +/* + * 사람이 코드 변경을 승인할 때 사용하는 Approve 요청입니다. + */ +data class ApproveGateRequest( + val subTaskId: SubTaskId, + val changeRevisionId: ChangeRevisionId, + val diffIdentity: DiffIdentity? = null, + val requestId: String = "gate-approve-" + subTaskId + "-" + changeRevisionId, + val expectedStoreRevision: String? = null, +) + +/* + * Ready Gate 기록 결과와 저장된 리뷰 상태입니다. + */ +data class ReadyGateResponse( + val subTask: SubTask, + val pullRequest: PullRequest, + val audit: AuditEntry, +) + +/* + * Approve Gate 기록과 Merge Queue 등록 결과입니다. + */ +data class ApproveGateResponse( + val subTask: SubTask, + val pullRequest: PullRequest, + val approval: Approval, + val mergeQueue: MergeQueueEntry, + val audit: AuditEntry, +) + +/* + * 인바운드 Adapter가 사용할 수 있는 Ready 요청 이름입니다. + */ +typealias ReviewReadyUseCaseRequest = ReadyGateRequest + +/* + * 인바운드 Adapter가 사용할 수 있는 Approve 요청 이름입니다. + */ +typealias ReviewApproveUseCaseRequest = ApproveGateRequest + +/* + * 인바운드 Adapter가 사용할 수 있는 Ready 결과 이름입니다. + */ +typealias ReviewReadyUseCaseResponse = ReadyGateResponse + +/* + * 인바운드 Adapter가 사용할 수 있는 Approve 결과 이름입니다. + */ +typealias ReviewApproveUseCaseResponse = ApproveGateResponse + +/* + * 사람의 Ready와 Approve 결정을 조정하는 Application 유스케이스입니다. + * + * 현재 사람 주체와 권한을 Identity Port에서 확인하고, Workflow Store의 일관된 snapshot을 기준으로 Gate 조건을 검증합니다. 외부 리뷰와 + * Merge Queue 변경은 WorkflowTransaction의 보상 계약으로 감싸며, 성공한 상태와 감사 기록은 하나의 저장소 쓰기로 반영합니다. + */ +class ReviewGateUseCases( + private val identityPort: IdentityPort, + private val reviewPort: ReviewPort, + private val storePort: WorkflowStorePort, + private val mergeQueuePort: MergeQueuePort? = null, + private val idPort: IdPort? = null, + private val clockPort: ClockPort? = null, + private val compensationPort: CompensationPort? = null, +) { + /* + * 기존 Gate 유스케이스와 같은 포트 순서로 Application 경계를 생성합니다. + */ + constructor( + orderedStorePort: WorkflowStorePort, + orderedReviewPort: ReviewPort, + orderedMergeQueuePort: MergeQueuePort?, + orderedIdentityPort: IdentityPort, + orderedIdPort: IdPort? = null, + orderedClockPort: ClockPort? = null, + orderedCompensationPort: CompensationPort? = null, + ) : this( + orderedIdentityPort, + orderedReviewPort, + orderedStorePort, + orderedMergeQueuePort, + orderedIdPort, + orderedClockPort, + orderedCompensationPort, + ) + + /* + * 저장소의 현재 review revision이 요청과 일치하면 PR을 Ready로 전환합니다. + */ + fun ready(request: ReadyGateRequest): WorkflowResult { + val invalid = validateReadyRequest(request) + if (invalid != null) return WorkflowResult.Failure(invalid) + + return withHuman( + target = request.subTaskId, + capability = Capability.READY, + requestId = request.requestId, + expectedStoreRevision = request.expectedStoreRevision, + ) { actor, transaction -> + when (val result = findTarget(transaction.snapshot, request.subTaskId)) { + is PortResult.Failure -> result + is PortResult.Success -> { + val target = result.value + val stateFailure = WorkflowRules.humanGateCondition(GateType.READY, actor, target.pr) + if (stateFailure != null) { + failure(stateFailure) + } else if (target.pr.reviewRevision.id != request.reviewRevisionId) { + failure( + FailureCode.STALE_REVISION, + "review revision is stale", + request.subTaskId, + ) + } else { + readyReview(actor, request, transaction, target) + } + } + } + } + } + + /* + * Ready Gate의 명시적 실행 이름입니다. + */ + fun recordReady(request: ReadyGateRequest): WorkflowResult = ready(request) + + /* + * Ready 요청을 인바운드 Adapter의 공통 실행 이름으로 처리합니다. + */ + fun execute(request: ReadyGateRequest): WorkflowResult = ready(request) + + /* + * Ready 요청을 인바운드 Adapter의 공통 진입점으로 처리합니다. + */ + fun handle(request: ReadyGateRequest): WorkflowResult = ready(request) + + /* + * 현재 코드 변경과 필수 조건을 확인한 뒤 Approve와 Queue 등록을 수행합니다. + */ + fun approve(request: ApproveGateRequest): WorkflowResult { + val invalid = validateApproveRequest(request) + if (invalid != null) return WorkflowResult.Failure(invalid) + + return withHuman( + target = request.subTaskId, + capability = Capability.APPROVE, + requestId = request.requestId, + expectedStoreRevision = request.expectedStoreRevision, + ) { actor, transaction -> + when (val result = findTarget(transaction.snapshot, request.subTaskId)) { + is PortResult.Failure -> result + is PortResult.Success -> { + val target = result.value + validateApprovalConditions(transaction.snapshot, target, actor, request)?.let(::failure) + ?: approveReview(actor, request, transaction, target) + } + } + } + } + + /* + * Approve Gate의 명시적 실행 이름입니다. + */ + fun recordApproval(request: ApproveGateRequest): WorkflowResult = + approve(request) + + /* + * Approve 요청을 인바운드 Adapter의 공통 실행 이름으로 처리합니다. + */ + fun execute(request: ApproveGateRequest): WorkflowResult = approve(request) + + /* + * Approve 요청을 인바운드 Adapter의 공통 진입점으로 처리합니다. + */ + fun handle(request: ApproveGateRequest): WorkflowResult = approve(request) + + private fun readyReview( + actor: Actor, + request: ReadyGateRequest, + transaction: GateTransaction, + target: ReviewTarget, + ): PortResult> { + val result = + reviewPort.ready( + ReadyReviewRequest( + pullRequestId = target.pr.id, + reviewRevisionId = request.reviewRevisionId, + actor = actor, + ), + ) + return when (result) { + is PortResult.Failure -> result + is PortResult.Success -> { + if (!validReadyResponse(target.pr, request, result.value.pullRequest)) { + PortResult.Failure( + PortError( + FailureCode.INVARIANT_VIOLATION.name, + "review provider returned an invalid ready response", + ), + result.value.change, + ) + } else { + val occurredAt = now(request.requestId + ":ready") + val audit = + audit( + requestId = request.requestId, + gate = GateType.READY, + actor = actor, + targetId = target.subTask.id, + reviewRevisionId = result.value.pullRequest.reviewRevision.id, + occurredAt = occurredAt, + ) + val storedPullRequest = result.value.pullRequest + val storedSubTask = + target.subTask.copy( + state = storedPullRequest.state.toSubTaskState(), + pullRequestId = storedPullRequest.id, + ) + val storedSnapshot = + transaction.snapshot.withReviewGate( + subTask = storedSubTask, + pullRequest = storedPullRequest, + event = GateRecorded(target.subTask.id, GateType.READY, actor, occurredAt), + audit = audit, + ) + PortResult.Success( + TransactionMutation( + ReadyGateResponse(storedSubTask, storedPullRequest, audit), + storedSnapshot, + changes = listOf(result.value.change), + ), + ) + } + } + } + } + + private fun approveReview( + actor: Actor, + request: ApproveGateRequest, + transaction: GateTransaction, + target: ReviewTarget, + ): PortResult> { + val requestedDiff = request.diffIdentity ?: target.pr.changeRevision.diff.identity + val approvalResult = + reviewPort.approve( + ApproveReviewRequest( + pullRequestId = target.pr.id, + changeRevisionId = request.changeRevisionId, + diffIdentity = requestedDiff, + actor = actor, + ), + ) + return when (approvalResult) { + is PortResult.Failure -> approvalResult + is PortResult.Success -> { + val response = approvalResult.value + if (!validApprovalResponse(target.pr, request, requestedDiff, actor, response)) { + PortResult.Failure( + PortError( + FailureCode.INVARIANT_VIOLATION.name, + "review provider returned an invalid approval response", + ), + response.change, + ) + } else { + val changes = mutableListOf(response.change) + if (!canEnterMergeQueue(transaction.snapshot, target, response)) { + return compensatedFailure( + PortResult.Failure( + PortError( + FailureCode.MERGE_QUEUE_FAILED.name, + "merge queue conditions are no longer satisfied", + target = target.subTask.id, + ), + ), + changes, + ) + } + val queuePort = mergeQueuePort + if (queuePort == null) { + compensatedFailure( + PortResult.Failure( + PortError( + FailureCode.MERGE_QUEUE_FAILED.name, + "merge queue port is not configured", + target = target.subTask.id, + ), + ), + changes, + ) + } else { + when ( + val queueResult = + queuePort.enqueue( + EnqueueMergeRequest( + subTaskId = target.subTask.id, + pullRequestId = target.pr.id, + changeRevisionId = request.changeRevisionId, + expectedRevision = transaction.snapshot.revision, + ), + ) + ) { + is PortResult.Failure -> compensatedFailure(queueResult, changes) + is PortResult.Success -> { + val entry = queueResult.value.entry + changes += queueResult.value.change + if (!validQueueResponse(target, request, entry, transaction.snapshot)) { + compensatedFailure( + PortResult.Failure( + PortError( + FailureCode.INVARIANT_VIOLATION.name, + "merge queue provider returned an invalid queue entry", + ), + ), + changes, + ) + } else { + val occurredAt = now(request.requestId + ":approve") + val audit = + audit( + requestId = request.requestId, + gate = GateType.APPROVE, + actor = actor, + targetId = target.subTask.id, + reviewRevisionId = response.pullRequest.reviewRevision.id, + changeRevisionId = response.approval.changeRevisionId, + occurredAt = occurredAt, + ) + val queuedPullRequest = + response.pullRequest.copy( + state = PullRequestState.QUEUED, + approval = response.approval, + ) + val queuedSubTask = + target.subTask.copy( + state = SubTaskState.QUEUED, + pullRequestId = queuedPullRequest.id, + ) + val storedSnapshot = + transaction.snapshot.withReviewGate( + subTask = queuedSubTask, + pullRequest = queuedPullRequest, + mergeQueue = entry, + event = + GateRecorded(target.subTask.id, GateType.APPROVE, actor, occurredAt), + audit = audit, + ) + PortResult.Success( + TransactionMutation( + ApproveGateResponse( + queuedSubTask, + queuedPullRequest, + response.approval, + entry, + audit, + ), + storedSnapshot, + changes, + ), + ) + } + } + } + } + } + } + } + } + + private fun validateApprovalConditions( + snapshot: WorkflowStoreSnapshot, + target: ReviewTarget, + actor: Actor, + request: ApproveGateRequest, + ): FailureData? { + val stateFailure = WorkflowRules.humanGateCondition(GateType.APPROVE, actor, target.pr) + if (stateFailure != null) return stateFailure + if (target.pr.changeRevision.id != request.changeRevisionId) { + return failureData( + FailureCode.STALE_REVISION, + "change revision is stale", + request.subTaskId, + ) + } + val currentDiff = target.pr.changeRevision.diff.identity + if (request.diffIdentity != null && request.diffIdentity != currentDiff) { + return failureData( + FailureCode.STALE_DIFF_IDENTITY, + "approval diff identity is stale", + request.subTaskId, + ) + } + if (target.pr.ci != CiStatus.PASSED) { + return failureData( + FailureCode.CI_NOT_PASSED, + "required pull request CI has not passed", + request.subTaskId, + ) + } + val checkSummary = snapshot.checks[target.subTask.id] + if (checkSummary != null && !checkSummary.passed) { + return failureData( + FailureCode.CHECK_NOT_PASSED, + "required checks have not passed", + request.subTaskId, + ) + } + val openThread = + WorkflowRules.openRequiredThreads(target.pr.reviewRevision.threads).firstOrNull() + if (openThread != null) { + return failureData( + FailureCode.REQUIRED_THREAD_OPEN, + "an unresolved required review thread remains", + openThread.id, + ) + } + val dependency = target.subTask.requires + if (dependency != null) { + val parent = snapshot.subTasks.firstOrNull { it.id == dependency } + if (parent == null) { + return failureData( + FailureCode.DEPENDENCY_NOT_FOUND, + "direct dependency was not found", + dependency, + ) + } + val integrated = + parent.state == SubTaskState.MERGED || + snapshot.integrations.any { + it.subTaskId == dependency && it.state == IntegrationState.MERGED + } + if (!integrated) { + return failureData( + FailureCode.DEPENDENCY_NOT_MERGED, + "direct dependency has not been integrated into main", + dependency, + ) + } + } + return null + } + + private fun validReadyResponse( + before: PullRequest, + request: ReadyGateRequest, + after: PullRequest, + ): Boolean = + after.id == before.id && + after.subTaskId == before.subTaskId && + after.reviewRevision.id == request.reviewRevisionId && + after.changeRevision == before.changeRevision && + after.state in setOf(PullRequestState.READY, PullRequestState.REVIEW) + + private fun validApprovalResponse( + before: PullRequest, + request: ApproveGateRequest, + diffIdentity: DiffIdentity, + actor: Actor, + response: ApproveReviewResponse, + ): Boolean { + val after = response.pullRequest + val approval = response.approval + return after.id == before.id && + after.subTaskId == before.subTaskId && + after.reviewRevision.id == before.reviewRevision.id && + after.changeRevision.id == request.changeRevisionId && + after.changeRevision.diff.identity == diffIdentity && + after.ci == CiStatus.PASSED && + after.state in + setOf( + PullRequestState.READY, + PullRequestState.REVIEW, + PullRequestState.APPROVED, + PullRequestState.QUEUED, + ) && + approval.actor == actor && + approval.actor.isHuman && + approval.active && + approval.changeRevisionId == request.changeRevisionId && + approval.diffIdentity == diffIdentity + } + + private fun canEnterMergeQueue( + snapshot: WorkflowStoreSnapshot, + target: ReviewTarget, + response: ApproveReviewResponse, + ): Boolean { + val dependencyIntegrated = + target.subTask.requires?.let { dependency -> + snapshot.subTasks + .firstOrNull { it.id == dependency } + ?.let { parent -> + parent.state == SubTaskState.MERGED || + snapshot.integrations.any { + it.subTaskId == dependency && it.state == IntegrationState.MERGED + } + } + } ?: true + val validations = + snapshot.checks[target.subTask.id]?.checks.orEmpty().map { check -> + io.springkit.workflow.domain.Validation( + id = check.id, + name = check.name, + status = check.status, + revision = check.revision, + message = check.message, + ) + } + return WorkflowRules.canEnterMergeQueue( + response.pullRequest.copy(state = target.pr.state, approval = response.approval), + validations, + dependencyIntegrated, + ) + } + + private fun validQueueResponse( + target: ReviewTarget, + request: ApproveGateRequest, + entry: MergeQueueEntry, + snapshot: WorkflowStoreSnapshot, + ): Boolean = + entry.subTaskId == target.subTask.id && + entry.pullRequestId == target.pr.id && + entry.changeRevisionId == request.changeRevisionId && + entry.state in + setOf( + MergeQueueState.QUEUED, + MergeQueueState.VALIDATING, + MergeQueueState.PASSED, + ) && + snapshot.mergeQueue.none { it.id == entry.id && it.subTaskId != target.subTask.id } + + private fun withHuman( + target: String, + capability: Capability, + requestId: String, + expectedStoreRevision: String?, + operation: (Actor, GateTransaction) -> PortResult>, + ): WorkflowResult { + val actorResult = identityPort.currentActor(CurrentActorRequest(requestId)) + val actor = + when (actorResult) { + is PortResult.Failure -> + return actorResult.toWorkflowFailure(FailureCode.EXTERNAL_FAILURE) + is PortResult.Success -> actorResult.value.actor + } + val authorization = identityPort.authorize(AuthorizeRequest(actor, capability, target)) + when (authorization) { + is PortResult.Failure -> return authorization.toWorkflowFailure(FailureCode.HUMAN_REQUIRED) + is PortResult.Success -> { + if (!authorization.value.allowed || !actor.isHuman) { + return WorkflowResult.Failure( + FailureData( + FailureCode.HUMAN_REQUIRED, + authorization.value.reason ?: "a human actor is required", + blockedBy = + listOf(BlockedBy("HUMAN_REQUIRED", "a human decision is required", target)), + next = + listOf( + NextAction( + ActorKind.HUMAN, + "perform_human_gate", + "workflow gate " + capability.name.lowercase() + " " + target, + ), + ), + ), + ) + } + } + } + val transactionRequest = + StoreTransactionRequest( + transactionId = issueId(IdKind.TRANSACTION, requestId, "tx-" + requestId), + expectedRevision = expectedStoreRevision, + idempotencyKey = requestId, + ) + val transaction = WorkflowTransaction(storePort, compensationGateway()) + return transaction.execute(transactionRequest) { snapshot -> + operation(actor, GateTransaction(snapshot, transactionRequest)) + } + } + + private fun findTarget( + snapshot: WorkflowStoreSnapshot, + subTaskId: SubTaskId, + ): PortResult { + val subTask = + snapshot.subTasks.firstOrNull { it.id == subTaskId } + ?: return PortResult.Failure( + PortError( + FailureCode.SUBTASK_NOT_FOUND.name, + "subtask was not found", + target = subTaskId, + ), + ) + val pullRequestId = + subTask.pullRequestId + ?: return PortResult.Failure( + PortError( + FailureCode.REVIEW_NOT_FOUND.name, + "subtask does not have a pull request", + target = subTask.id, + ), + ) + val pullRequest = + snapshot.pullRequests.firstOrNull { it.id == pullRequestId } + ?: return PortResult.Failure( + PortError( + FailureCode.REVIEW_NOT_FOUND.name, + "pull request was not found", + target = pullRequestId, + ), + ) + if (pullRequest.subTaskId != subTask.id) { + return PortResult.Failure( + PortError( + FailureCode.INVARIANT_VIOLATION.name, + "pull request does not belong to the subtask", + target = pullRequest.id, + ), + ) + } + return PortResult.Success(ReviewTarget(subTask, pullRequest)) + } + + private fun validateReadyRequest(request: ReadyGateRequest): FailureData? = + if ( + request.subTaskId.isBlank() || + request.reviewRevisionId.isBlank() || + request.requestId.isBlank() + ) { + failureData(FailureCode.INVALID_ARGUMENT, "ready gate request values must not be blank") + } else null + + private fun validateApproveRequest(request: ApproveGateRequest): FailureData? = + if ( + request.subTaskId.isBlank() || + request.changeRevisionId.isBlank() || + request.diffIdentity?.isBlank() == true || + request.requestId.isBlank() + ) { + failureData(FailureCode.INVALID_ARGUMENT, "approve gate request values must not be blank") + } else null + + private fun audit( + requestId: String, + gate: GateType, + actor: Actor, + targetId: String, + reviewRevisionId: ReviewRevisionId, + changeRevisionId: ChangeRevisionId? = null, + occurredAt: Long, + ): AuditEntry = + AuditEntry( + id = + issueId( + IdKind.AUDIT, + requestId + ":" + gate.name.lowercase(), + "audit-" + requestId, + ), + actor = actor, + action = gate.name.lowercase(), + targetId = targetId, + reviewRevisionId = reviewRevisionId, + changeRevisionId = changeRevisionId, + occurredAtEpochMillis = occurredAt, + ) + + private fun now(requestId: String): Long = + when (val result = clockPort?.now(NowRequest(requestId))) { + is PortResult.Success -> result.value.epochMillis + is PortResult.Failure, + null -> 0L + } + + private fun issueId(kind: IdKind, requestId: String, fallback: String): String = + when (val result = idPort?.issue(IssueIdRequest(kind, requestId))) { + is PortResult.Success -> result.value.ids.firstOrNull()?.value ?: fallback + is PortResult.Failure, + null -> fallback + } + + private fun compensationGateway(): CompensationPort = + compensationPort + ?: object : CompensationPort { + override fun compensate(request: CompensateRequest): PortResult = + PortResult.Success(CompensateResponse(request.change)) + } + + private fun compensatedFailure( + result: PortResult.Failure, + changes: List, + ): PortResult.Failure { + val allChanges = (changes + listOfNotNull(result.change)).asReversed() + val failures = allChanges.mapNotNull { change -> + if (change.compensation == null) { + null + } else { + when ( + val compensation = + compensationPort?.compensate( + CompensateRequest(change, "review gate operation failed"), + ) + ) { + is PortResult.Success -> null + is PortResult.Failure -> compensation.error + null -> + PortError( + "COMPENSATION_REQUIRED", + "compensation port is not configured", + target = change.id, + ) + } + } + } + if (failures.isEmpty()) return PortResult.Failure(result.error) + return PortResult.Failure( + PortError( + result.error.code, + result.error.message + "; " + failures.joinToString("; ") { it.message }, + result.error.retryable, + result.error.target, + ), + ) + } + + private fun failure(data: FailureData): PortResult.Failure = + PortResult.Failure( + PortError( + data.code.name, + data.message, + target = data.blockedBy.firstOrNull()?.target, + ), + ) + + private fun failure( + code: FailureCode, + message: String, + target: String? = null, + ): PortResult.Failure = PortResult.Failure(PortError(code.name, message, target = target)) + + private fun failureData( + code: FailureCode, + message: String, + target: String? = null, + ): FailureData = + FailureData( + code = code, + message = message, + blockedBy = listOfNotNull(target?.let { BlockedBy(code.name, message, it) }), + ) + + private data class ReviewTarget(val subTask: SubTask, val pr: PullRequest) + + private data class GateTransaction( + val snapshot: WorkflowStoreSnapshot, + val request: StoreTransactionRequest, + ) +} + +private fun WorkflowStoreSnapshot.withReviewGate( + subTask: SubTask, + pullRequest: PullRequest, + mergeQueue: MergeQueueEntry? = null, + event: GateRecorded, + audit: AuditEntry, +): WorkflowStoreSnapshot = + copy( + subTasks = subTasks.replaceById(subTask) { it.id }, + pullRequests = pullRequests.replaceById(pullRequest) { it.id }, + mergeQueue = + if (mergeQueue == null) this.mergeQueue + else this.mergeQueue.filterNot { it.subTaskId == subTask.id } + mergeQueue, + eventLog = eventLog.append(event, audit), + ) + +private fun List.replaceById(value: T, id: (T) -> String): List = map { current -> + if (id(current) == id(value)) value else current +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/application/ReviewLifecycleUseCases.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/ReviewLifecycleUseCases.kt new file mode 100644 index 00000000..1b8a2efb --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/ReviewLifecycleUseCases.kt @@ -0,0 +1,835 @@ +package io.springkit.workflow.application + +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.AiReviewStatus +import io.springkit.workflow.domain.AuditEntry +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.ChangeRevisionId +import io.springkit.workflow.domain.CheckResult +import io.springkit.workflow.domain.CheckSummary +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.DomainEvent +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.NextAction +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.ReviewBodyTemplate +import io.springkit.workflow.domain.ReviewChanged +import io.springkit.workflow.domain.ReviewOpened +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.ReviewRevisionId +import io.springkit.workflow.domain.Risk +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskId +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspaceId +import io.springkit.workflow.domain.toSubTaskState + +/* + * 처음 Draft PR을 게시하는 애플리케이션 요청입니다. + */ +data class OpenReviewLifecycleRequest( + val workspaceId: WorkspaceId, + val subTaskId: SubTaskId, + val body: String, + val risk: Risk, + val expectedStoreRevision: String? = null, + val requestId: String = "review-open-$subTaskId", +) { + init { + require(workspaceId.isNotBlank()) { "review workspace id must not be blank" } + require(subTaskId.isNotBlank()) { "review subtask id must not be blank" } + require(body.isNotBlank()) { "review body must not be blank" } + require(requestId.isNotBlank()) { "review request id must not be blank" } + } +} + +/* + * Review 이후 코드 또는 PR 본문을 다시 게시하는 애플리케이션 요청입니다. + */ +data class UpdateReviewLifecycleRequest( + val workspaceId: WorkspaceId, + val subTaskId: SubTaskId, + val pullRequestId: String, + val expectedReviewRevisionId: ReviewRevisionId, + val body: String? = null, + val expectedStoreRevision: String? = null, + val requestId: String = "review-update-$pullRequestId-$expectedReviewRevisionId", +) { + init { + require(workspaceId.isNotBlank()) { "review workspace id must not be blank" } + require(subTaskId.isNotBlank()) { "review subtask id must not be blank" } + require(pullRequestId.isNotBlank()) { "pull request id must not be blank" } + require(expectedReviewRevisionId.isNotBlank()) { + "expected review revision id must not be blank" + } + require(body == null || body.isNotBlank()) { "review body must not be blank" } + require(requestId.isNotBlank()) { "review request id must not be blank" } + } +} + +/* + * Draft PR 게시 결과와 해당 코드 검증 결과입니다. + */ +data class OpenReviewLifecycleResponse( + val pullRequest: PullRequest, + val check: CheckSummary, + val publishedRevision: String, +) + +/* + * Review 갱신 결과와 코드 변경 여부입니다. + */ +data class UpdateReviewLifecycleResponse( + val pullRequest: PullRequest, + val codeChanged: Boolean, + val check: CheckSummary, + val publishedRevision: String?, +) + +/* + * Review 게시, 외부 게이트 시작과 Workflow Store 반영을 하나의 생명주기로 조정합니다. + */ +class ReviewLifecycleUseCases( + private val workspacePort: WorkspacePort, + private val gitPort: GitPort, + private val gitPublishPort: GitPublishPort, + private val reviewPort: ReviewPort, + private val ciPort: CiPort, + private val aiReviewPort: AiReviewPort? = null, + private val storePort: WorkflowStorePort, + private val clockPort: ClockPort? = null, + private val compensationPort: CompensationPort? = null, +) { + /* + * 현재 Worktree를 검증하고 Draft PR과 초기 게이트를 게시합니다. + */ + fun open(request: OpenReviewLifecycleRequest): WorkflowResult { + ReviewBodyTemplate.validate(request.body)?.let { message -> + return failure(FailureCode.INVALID_ARGUMENT, message, request.subTaskId) + } + val context = loadContext(request.workspaceId, request.subTaskId) + if (context is ContextFailure) return context.failure + context as ContextSuccess + val snapshot = context.snapshot + if ( + request.expectedStoreRevision != null && request.expectedStoreRevision != snapshot.revision + ) { + return failure( + FailureCode.STALE_REVISION, + "Workflow Store revision이 현재 상태와 다릅니다.", + snapshot.revision, + ) + } + val workspace = context.workspace + val subTask = context.subTask + if (snapshot.pullRequests.any { it.subTaskId == subTask.id }) { + return failure(FailureCode.REVIEW_ALREADY_OPEN, "SubTask의 Review가 이미 열려 있습니다.", subTask.id) + } + if (subTask.state != SubTaskState.DEVELOPMENT) { + return failure( + FailureCode.INVALID_GATE_STATE, + "Development 상태의 SubTask만 Review를 열 수 있습니다.", + subTask.id, + ) + } + val status = context.status + val check = requirePassedCheck(snapshot, subTask.id, status.fingerprint, status.revision) + if (check is WorkflowResult.Failure) return check + check as WorkflowResult.Success + val validationSummary = check.data + val title = "[${subTask.id}] ${subTask.title}" + val base = + if (subTask.requires == null) { + "main" + } else { + snapshot.subTasks.firstOrNull { it.id == subTask.requires }?.branch + ?: return failure( + FailureCode.DEPENDENCY_NOT_FOUND, + "직접 필요한 선행 SubTask를 찾을 수 없습니다.", + subTask.requires, + ) + } + val transaction = begin(snapshot.revision, "review-open:${request.requestId}") + if (transaction is BeginFailure) return transaction.failure + transaction as BeginSuccess + val changes = mutableListOf() + val now = now(request.requestId) + val reviewNumber = snapshot.sequence.review + 1 + val changeNumber = snapshot.sequence.change + 1 + val reviewRevision = + ReviewRevision( + id = "rv-$reviewNumber", + number = reviewNumber, + body = request.body, + createdAtEpochMillis = now, + ) + val provisionalChange = + ChangeRevision( + id = "cr-$changeNumber", + number = changeNumber, + diff = Diff(identity = status.fingerprint, files = status.conflicts), + createdAtEpochMillis = now, + ) + val published = + remember( + gitPublishPort.publish( + PublishBranchRequest( + workspaceId = workspace.id, + branch = subTask.branch, + commitTitle = title, + expectedRevision = status.revision, + expectedFingerprint = status.fingerprint, + ) + ), + changes, + ) ?: return recover(transaction.request, changes, lastFailure) + val changeRevision = + provisionalChange.copy(diff = provisionalChange.diff.copy(identity = published.fingerprint)) + val opened = + remember( + reviewPort.open( + OpenReviewRequest( + subTaskId = subTask.id, + title = title, + body = request.body, + base = base, + branch = subTask.branch, + risk = request.risk, + changeRevision = changeRevision, + reviewRevision = reviewRevision, + ) + ), + changes, + ) ?: return recover(transaction.request, changes, lastFailure) + if ( + !validOpen( + opened.pullRequest, + subTask, + title, + base, + request, + reviewRevision, + changeRevision, + ) + ) { + return recover( + transaction.request, + changes, + failureData( + FailureCode.INVARIANT_VIOLATION, + "Review provider가 초기 PR metadata를 다르게 반환했습니다.", + ), + ) + } + val ci = + remember( + ciPort.start( + StartCiRequest( + pullRequestId = opened.pullRequest.id, + revision = published.revision, + checks = validationSummary.checks.map { it.toValidation(published.revision) }, + ) + ), + changes, + ) ?: return recover(transaction.request, changes, lastFailure) + val aiStatus = + aiReviewPort?.let { port -> + remember( + port.start( + StartAiReviewRequest( + pullRequestId = opened.pullRequest.id, + reviewRevisionId = reviewRevision.id, + changeRevisionId = changeRevision.id, + diff = changeRevision.diff, + ) + ), + changes, + ) + ?.status ?: return recover(transaction.request, changes, lastFailure) + } ?: AiReviewStatus.PENDING + val storedCheck = validationSummary.retarget(published.fingerprint, published.revision) + val pullRequest = + opened.pullRequest.copy( + title = title, + body = request.body, + base = base, + state = PullRequestState.DRAFT, + reviewRevision = reviewRevision, + changeRevision = opened.pullRequest.changeRevision, + ci = ci.run.status, + aiReview = aiStatus, + ) + val storedSnapshot = + snapshot.copy( + sequence = snapshot.sequence.copy(review = reviewNumber, change = changeNumber), + checks = snapshot.checks + (subTask.id to storedCheck), + pullRequests = snapshot.pullRequests.replaceReview(pullRequest), + subTasks = + snapshot.subTasks.replaceSubTask( + subTask.copy( + pullRequestId = pullRequest.id, + state = pullRequest.state.toSubTaskState(), + risk = request.risk, + ) + ), + ) + val event = + ReviewOpened( + targetId = pullRequest.id, + subTaskId = subTask.id, + reviewRevisionId = reviewRevision.id, + changeRevisionId = changeRevision.id, + occurredAtEpochMillis = now, + ) + val saved = + persist( + transaction.request, + storedSnapshot, + event, + audit(event, reviewRevision.id, changeRevision.id, now), + changes, + ) + return when (saved) { + is WorkflowResult.Failure -> saved + is WorkflowResult.Success -> + WorkflowResult.Success( + OpenReviewLifecycleResponse(pullRequest, storedCheck, published.revision), + next = listOf(NextAction(ActorKind.HUMAN, "review")), + ) + } + } + + /* + * 현재 Worktree의 코드 또는 PR 본문을 같은 Review에 다시 게시합니다. + */ + fun update(request: UpdateReviewLifecycleRequest): WorkflowResult { + request.body?.let { body -> + ReviewBodyTemplate.validate(body)?.let { message -> + return failure(FailureCode.INVALID_ARGUMENT, message, request.pullRequestId) + } + } + val context = loadContext(request.workspaceId, request.subTaskId) + if (context is ContextFailure) return context.failure + context as ContextSuccess + val snapshot = context.snapshot + if ( + request.expectedStoreRevision != null && request.expectedStoreRevision != snapshot.revision + ) { + return failure( + FailureCode.STALE_REVISION, + "Workflow Store revision이 현재 상태와 다릅니다.", + snapshot.revision, + ) + } + val workspace = context.workspace + val subTask = context.subTask + val stored = + snapshot.pullRequests.firstOrNull { it.id == request.pullRequestId } + ?: return failure( + FailureCode.REVIEW_NOT_FOUND, + "SubTask의 PR을 찾을 수 없습니다.", + request.pullRequestId, + ) + if (stored.subTaskId != subTask.id) { + return failure( + FailureCode.INVALID_TARGET_SELECTION, + "PR이 현재 SubTask에 속하지 않습니다.", + request.pullRequestId, + ) + } + val remote = + when (val result = reviewPort.get(GetReviewRequest(stored.id))) { + is PortResult.Failure -> return portFailure(result) + is PortResult.Success -> result.value.pullRequest + } + if ( + remote.reviewRevision.id != request.expectedReviewRevisionId || + stored.reviewRevision.id != request.expectedReviewRevisionId + ) { + return failure( + FailureCode.STALE_REVISION, + "Review revision이 현재 상태와 다릅니다.", + request.expectedReviewRevisionId, + ) + } + val status = context.status + val check = requirePassedCheck(snapshot, subTask.id, status.fingerprint, status.revision) + if (check is WorkflowResult.Failure) return check + val codeChanged = stored.changeRevision.diff.identity != status.fingerprint + if (!codeChanged && request.body == null) { + return failure(FailureCode.INVALID_ARGUMENT, "Review update에는 본문 또는 코드 변경이 필요합니다.") + } + val transaction = begin(snapshot.revision, "review-update:${request.requestId}") + if (transaction is BeginFailure) return transaction.failure + transaction as BeginSuccess + val changes = mutableListOf() + val now = now(request.requestId) + val reviewNumber = snapshot.sequence.review + 1 + val changeNumber = snapshot.sequence.change + if (codeChanged) 1 else 0 + val reviewRevision = + ReviewRevision( + id = "rv-$reviewNumber", + number = reviewNumber, + body = request.body ?: stored.body, + threads = stored.reviewRevision.threads, + createdAtEpochMillis = now, + ) + val published = + if (codeChanged) { + remember( + gitPublishPort.publish( + PublishBranchRequest( + workspaceId = workspace.id, + branch = subTask.branch, + commitTitle = stored.title, + expectedRevision = status.revision, + expectedFingerprint = status.fingerprint, + ) + ), + changes, + ) ?: return recover(transaction.request, changes, lastFailure) + } else { + null + } + val changeRevision = + if (codeChanged) { + val publishedResult = + published + ?: return recover( + transaction.request, + changes, + failureData(FailureCode.INVARIANT_VIOLATION, "게시 결과가 없습니다."), + ) + ChangeRevision( + id = "cr-$changeNumber", + number = changeNumber, + diff = stored.changeRevision.diff.copy(identity = publishedResult.fingerprint), + createdAtEpochMillis = now, + ) + } else { + stored.changeRevision + } + val updated = + remember( + reviewPort.update( + UpdateReviewRequest( + pullRequestId = stored.id, + expectedReviewRevisionId = request.expectedReviewRevisionId, + body = request.body, + changeRevision = changeRevision.takeIf { codeChanged }, + reviewRevision = reviewRevision, + ) + ), + changes, + ) ?: return recover(transaction.request, changes, lastFailure) + if ( + updated.pullRequest.id != stored.id || + updated.pullRequest.reviewRevision.id != reviewRevision.id + ) { + return recover( + transaction.request, + changes, + failureData( + FailureCode.INVARIANT_VIOLATION, + "Review provider가 갱신된 revision을 반환하지 않았습니다.", + ), + ) + } + var ciStatus = stored.ci + var aiStatus = stored.aiReview + if (codeChanged) { + val check = + snapshot.checks[subTask.id] + ?: return recover( + transaction.request, + changes, + failureData(FailureCode.CHECK_NOT_PASSED, "현재 코드의 검증 결과가 없습니다."), + ) + val publishedResult = + published + ?: return recover( + transaction.request, + changes, + failureData(FailureCode.INVARIANT_VIOLATION, "게시 결과가 없습니다."), + ) + val ci = + remember( + ciPort.start( + StartCiRequest( + pullRequestId = stored.id, + revision = publishedResult.revision, + checks = check.checks.map { it.toValidation(publishedResult.revision) }, + ) + ), + changes, + ) ?: return recover(transaction.request, changes, lastFailure) + ciStatus = ci.run.status + aiStatus = + aiReviewPort?.let { port -> + remember( + port.start( + StartAiReviewRequest( + stored.id, + reviewRevision.id, + changeRevision.id, + changeRevision.diff, + ) + ), + changes, + ) + ?.status ?: return recover(transaction.request, changes, lastFailure) + } ?: AiReviewStatus.PENDING + } + val storedCheck = + if (codeChanged) { + val publishedResult = + published + ?: return recover( + transaction.request, + changes, + failureData(FailureCode.INVARIANT_VIOLATION, "게시 결과가 없습니다."), + ) + snapshot.checks + .getValue(subTask.id) + .retarget(publishedResult.fingerprint, publishedResult.revision) + } else { + snapshot.checks.getValue(subTask.id) + } + val pullRequest = + updated.pullRequest.copy( + body = request.body ?: stored.body, + reviewRevision = reviewRevision, + changeRevision = updated.pullRequest.changeRevision, + approval = if (codeChanged) null else stored.approval, + ci = ciStatus, + aiReview = aiStatus, + ) + val storedSnapshot = + snapshot.copy( + sequence = snapshot.sequence.copy(review = reviewNumber, change = changeNumber), + checks = snapshot.checks + (subTask.id to storedCheck), + pullRequests = snapshot.pullRequests.replaceReview(pullRequest), + subTasks = + snapshot.subTasks.replaceSubTask( + subTask.copy(state = pullRequest.state.toSubTaskState()) + ), + ) + val event = + ReviewChanged( + targetId = pullRequest.id, + reviewRevisionId = reviewRevision.id, + changeRevisionId = changeRevision.id.takeIf { codeChanged }, + diffChanged = codeChanged, + occurredAtEpochMillis = now, + ) + val saved = + persist( + transaction.request, + storedSnapshot, + event, + audit(event, reviewRevision.id, changeRevision.id.takeIf { codeChanged }, now), + changes, + ) + return when (saved) { + is WorkflowResult.Failure -> saved + is WorkflowResult.Success -> + WorkflowResult.Success( + UpdateReviewLifecycleResponse( + pullRequest, + codeChanged, + storedCheck, + published?.revision, + ), + next = listOf(NextAction(ActorKind.HUMAN, "review")), + ) + } + } + + private fun loadContext(workspaceId: WorkspaceId, subTaskId: SubTaskId): ContextResult { + val workspace = + when ( + val result = + workspacePort.get( + WorkspaceLookupRequest(workspaceId = workspaceId, subTaskId = subTaskId) + ) + ) { + is PortResult.Failure -> return ContextFailure(portFailure(result)) + is PortResult.Success -> result.value.workspace + } + if (!workspace.managed) { + return ContextFailure( + failure(FailureCode.WORKTREE_REQUIRED, "관리 Worktree만 Review를 게시할 수 있습니다.", workspace.id) + ) + } + if (workspace.subTaskId != subTaskId) { + return ContextFailure( + failure( + FailureCode.INVALID_TARGET_SELECTION, + "Workspace가 현재 SubTask에 연결되지 않았습니다.", + workspace.id, + ) + ) + } + val snapshot = + when ( + val result = + storePort.snapshot(StoreSnapshotRequest(StoreScope.ALL, subTaskId = subTaskId)) + ) { + is PortResult.Failure -> return ContextFailure(portFailure(result)) + is PortResult.Success -> result.value.snapshot + } + val subTask = + snapshot.subTasks.firstOrNull { it.id == subTaskId } + ?: return ContextFailure( + failure(FailureCode.SUBTASK_NOT_FOUND, "SubTask를 찾을 수 없습니다.", subTaskId) + ) + val status = + when (val result = gitPort.inspect(GitInspectRequest(workspace.id))) { + is PortResult.Failure -> return ContextFailure(portFailure(result)) + is PortResult.Success -> result.value.status + } + return ContextSuccess(snapshot, workspace, subTask, status) + } + + private fun requirePassedCheck( + snapshot: WorkflowStoreSnapshot, + subTaskId: SubTaskId, + fingerprint: String, + revision: String, + ): WorkflowResult { + val check = + snapshot.checks[subTaskId] + ?: return failure( + FailureCode.CHECK_NOT_PASSED, + "현재 fingerprint의 check 결과가 없습니다.", + subTaskId, + ) + if (!check.appliesTo(fingerprint, revision)) { + return failure( + FailureCode.STALE_DIFF_IDENTITY, + "현재 fingerprint에 적용되는 check 결과가 없습니다.", + subTaskId, + ) + } + if (!check.passed) { + return failure(FailureCode.CHECK_NOT_PASSED, "필수 check가 모두 PASSED가 아닙니다.", subTaskId) + } + return WorkflowResult.Success(check) + } + + private fun begin(expectedRevision: String, key: String): BeginResult { + val request = StoreTransactionRequest("tx-$key", expectedRevision, key) + return when (val result = storePort.begin(request)) { + is PortResult.Failure -> BeginFailure(portFailure(result)) + is PortResult.Success -> + if (result.value.state == StoreTransactionState.OPEN) BeginSuccess(request) + else + BeginFailure( + failure(FailureCode.STORE_FAILURE, "Workflow Store transaction이 열리지 않았습니다.") + ) + } + } + + private fun remember( + result: PortResult, + changes: MutableList, + ): T? { + return when (result) { + is PortResult.Success -> { + changes += result.value.change + lastFailure = failure(FailureCode.EXTERNAL_FAILURE, "외부 동작이 실패했습니다.") + result.value + } + is PortResult.Failure -> { + result.change?.let(changes::add) + lastFailure = portFailure(result) + null + } + } + } + + private var lastFailure: WorkflowResult.Failure = + failure(FailureCode.EXTERNAL_FAILURE, "외부 동작이 실패했습니다.") + + private fun recover( + transaction: StoreTransactionRequest, + changes: List, + failure: WorkflowResult.Failure, + ): WorkflowResult.Failure { + storePort.rollback(transaction) + val compensationFailures = + changes.asReversed().mapNotNull { change -> + val compensation = + compensationPort + ?: return@mapNotNull if (change.compensation == null) { + null + } else { + PortError( + "COMPENSATION_PORT_UNAVAILABLE", + "외부 변경을 보상할 CompensationPort가 구성되지 않았습니다.", + ) + } + when ( + val result = compensation.compensate(CompensateRequest(change, failure.data.message)) + ) { + is PortResult.Failure -> result.error + is PortResult.Success -> null + } + } + return if (compensationFailures.isEmpty()) failure + else + failure.copy( + data = + failure.data.copy( + blockedBy = + failure.data.blockedBy + + BlockedBy( + "COMPENSATION_REQUIRED", + compensationFailures.joinToString("; ") { it.message }, + ), + next = failure.data.next + NextAction(ActorKind.WORKFLOW, "reconcile_review"), + ) + ) + } + + private fun persist( + transaction: StoreTransactionRequest, + snapshot: WorkflowStoreSnapshot, + event: DomainEvent, + audit: AuditEntry, + changes: List, + ): WorkflowResult { + val expectedRevision = requireNotNull(transaction.expectedRevision) + when ( + val result = + storePort.write( + StoreWriteRequest(transaction.transactionId, expectedRevision, snapshot) + ) + ) { + is PortResult.Failure -> return recover(transaction, changes, portFailure(result)) + is PortResult.Success -> Unit + } + when ( + val result = storePort.append(StoreEventRequest(transaction.transactionId, event, audit)) + ) { + is PortResult.Failure -> return recover(transaction, changes, portFailure(result)) + is PortResult.Success -> Unit + } + return when (val result = storePort.commit(transaction)) { + is PortResult.Failure -> recover(transaction, changes, portFailure(result)) + is PortResult.Success -> WorkflowResult.Success(Unit) + } + } + + private fun validOpen( + pullRequest: PullRequest, + subTask: SubTask, + title: String, + base: String, + request: OpenReviewLifecycleRequest, + reviewRevision: ReviewRevision, + changeRevision: ChangeRevision, + ): Boolean = + pullRequest.subTaskId == subTask.id && + pullRequest.title == title && + pullRequest.body == request.body && + pullRequest.base == base && + pullRequest.state == PullRequestState.DRAFT && + pullRequest.risk == request.risk && + pullRequest.reviewRevision.id == reviewRevision.id && + pullRequest.changeRevision.id == changeRevision.id + + private fun audit( + event: DomainEvent, + reviewRevisionId: ReviewRevisionId, + changeRevisionId: ChangeRevisionId?, + occurredAt: Long, + ): AuditEntry = + AuditEntry( + id = "audit-${event.targetId}-${occurredAt}-${reviewRevisionId}", + actor = Actor("workflow", ActorKind.WORKFLOW), + action = event::class.simpleName ?: "review-change", + targetId = event.targetId, + reviewRevisionId = reviewRevisionId, + changeRevisionId = changeRevisionId, + occurredAtEpochMillis = occurredAt, + ) + + private fun now(requestId: String): Long = + when (val result = clockPort?.now(NowRequest(requestId))) { + is PortResult.Success -> result.value.epochMillis + is PortResult.Failure, + null -> 0L + } + + private fun portFailure(result: PortResult.Failure): WorkflowResult.Failure = + failure( + FailureCode.entries.firstOrNull { it.name == result.error.code.uppercase() } + ?: FailureCode.EXTERNAL_FAILURE, + result.error.message, + result.error.target, + ) + + private fun failure( + code: FailureCode, + message: String, + target: String? = null, + ): WorkflowResult.Failure = + WorkflowResult.Failure( + FailureData( + code = code, + message = message, + blockedBy = target?.let { listOf(BlockedBy(code.name, message, it)) }.orEmpty(), + ) + ) + + private fun failureData(code: FailureCode, message: String): WorkflowResult.Failure = + failure(code, message) + + private sealed interface ContextResult + + private data class ContextSuccess( + val snapshot: WorkflowStoreSnapshot, + val workspace: Workspace, + val subTask: SubTask, + val status: GitStatus, + ) : ContextResult + + private data class ContextFailure(val failure: WorkflowResult.Failure) : ContextResult + + private sealed interface BeginResult + + private data class BeginSuccess(val request: StoreTransactionRequest) : BeginResult + + private data class BeginFailure(val failure: WorkflowResult.Failure) : BeginResult +} + +private fun CheckResult.toValidation(revisionOverride: String? = null): Validation = + Validation( + id = id, + name = name, + status = status, + required = true, + revision = revisionOverride ?: revision, + message = message, + ) + +private fun CheckSummary.retarget(fingerprint: String, revision: String): CheckSummary = + copy( + fingerprint = fingerprint, + revision = revision, + checks = checks.map { it.copy(fingerprint = fingerprint, revision = revision) }, + ) + +private fun List.replaceReview(value: PullRequest): List = + filterNot { it.id == value.id } + value + +private fun List.replaceSubTask(value: SubTask): List = + filterNot { it.id == value.id } + value diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/application/ReviewUseCases.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/ReviewUseCases.kt new file mode 100644 index 00000000..aa09b4df --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/ReviewUseCases.kt @@ -0,0 +1,619 @@ +package io.springkit.workflow.application + +import io.springkit.workflow.domain.AiReviewStatus +import io.springkit.workflow.domain.CiStatus +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.ReviewRevisionId +import io.springkit.workflow.domain.ReviewThread +import io.springkit.workflow.domain.ThreadId +import io.springkit.workflow.domain.WorkflowResult +import java.util.UUID + +/* + * 리뷰 생명주기를 처리하는 애플리케이션 경계입니다. + * + * 공급자 중립적인 포트 계약만 주고받습니다. 외부 식별자와 전송 방식은 어댑터가 소유합니다. 이 클래스는 낙관적 revision 검사와 리뷰 revision 및 코드 변경 + * revision을 구분하는 규칙을 소유합니다. + */ +class ReviewUseCases( + private val reviewPort: ReviewPort, + private val idPort: IdPort? = null, + private val clockPort: ClockPort? = null, + private val compensationPort: CompensationPort? = null, + private val storePort: WorkflowStorePort? = null, +) { + fun open(request: OpenReviewRequest): WorkflowResult { + val invalid = validateOpen(request) + if (invalid != null) return WorkflowResult.Failure(invalid) + return when (val result = reviewPort.open(request)) { + is PortResult.Failure -> failureFrom(result) + is PortResult.Success -> + if (isValidOpenResponse(request, result.value)) WorkflowResult.Success(result.value) + else + invalidProvider( + result.value.change, + "review provider returned an invalid open response", + ) + } + } + + fun show(request: GetReviewRequest): WorkflowResult = + when (val result = reviewPort.get(request)) { + is PortResult.Failure -> failureFrom(result) + is PortResult.Success -> + if (result.value.pullRequest.id == request.pullRequestId) { + WorkflowResult.Success(result.value) + } else { + failure( + FailureCode.INVARIANT_VIOLATION, + "review provider returned a different pull request", + ) + } + } + + /* + * 문서만 변경하면 CI, AI 리뷰, 승인과 코드 변경 revision을 유지합니다. + * + * 코드가 변경되면 새 변경 revision을 부여하고 이전 코드를 가리키는 검사와 승인을 무효화합니다. + */ + fun update(request: UpdateReviewRequest): WorkflowResult { + if (request.body?.isBlank() == true) { + return failure(FailureCode.INVALID_ARGUMENT, "review body must not be blank") + } + if (request.body == null && request.changeRevision == null && request.reviewRevision == null) { + return failure(FailureCode.INVALID_ARGUMENT, "review update must change the body or revision") + } + + return when (val current = reviewPort.get(GetReviewRequest(request.pullRequestId))) { + is PortResult.Failure -> failureFrom(current) + is PortResult.Success -> { + val pullRequest = current.value.pullRequest + val stale = requireCurrentReview(pullRequest, request.expectedReviewRevisionId) + if (stale != null) { + WorkflowResult.Failure(stale) + } else { + when (val updated = reviewPort.update(request)) { + is PortResult.Failure -> failureFrom(updated) + is PortResult.Success -> { + val response = normalizeUpdate(pullRequest, request, updated.value) + if (response == null) { + invalidProvider( + updated.value.change, + "review provider returned an invalid revision transition", + ) + } else { + WorkflowResult.Success(response) + } + } + } + } + } + } + } + + fun comment(request: AddReviewCommentRequest): WorkflowResult { + if (request.author != request.comment.author) { + return failure(FailureCode.INVALID_ARGUMENT, "comment author must match the review actor") + } + return when (val current = currentReview(request.pullRequestId, request.reviewRevisionId)) { + is WorkflowResult.Failure -> current + is WorkflowResult.Success -> { + val markedRequest = request.copy(comment = request.comment.withAgentMark()) + persistRevisionMutation( + current = current.data, + operation = "comment", + idempotencyKey = + "${request.pullRequestId}:${request.reviewRevisionId}:${request.comment.id}", + action = { reviewPort.comment(markedRequest) }, + reviewRevision = { it.reviewRevision }, + valid = { + isValidRevisionChange(current.data, it.reviewRevision) && + it.reviewRevision.threads.any { thread -> thread.id == it.threadId } + }, + invalidMessage = "review provider returned an invalid comment revision", + ) + } + } + } + + fun reply(request: ReplyReviewThreadRequest): WorkflowResult = + when (val current = reviewPort.get(GetReviewRequest(request.pullRequestId))) { + is PortResult.Failure -> failureFrom(current) + is PortResult.Success -> { + val pullRequest = current.value.pullRequest + if (pullRequest.reviewRevision.id != request.reviewRevisionId) { + val markedRequest = request.copy(comment = request.comment.withAgentMark()) + when (val recovered = reviewPort.recoverReply(markedRequest)) { + is PortResult.Failure -> failureFrom(recovered) + is PortResult.Success -> { + val response = recovered.value + if (response == null) { + WorkflowResult.Failure( + requireCurrentReview(pullRequest, request.reviewRevisionId)!!, + ) + } else { + persistRecoveredReply(request, response) + } + } + } + } else { + val thread = + pullRequest.reviewRevision.threads.firstOrNull { it.id == request.threadId } + when { + thread == null -> + failure( + FailureCode.THREAD_NOT_FOUND, + "review thread does not belong to the revision", + ) + !thread.isOpen -> + failure( + FailureCode.STATE_CONFLICT, + "a resolved review thread cannot receive a reply", + ) + else -> { + val markedRequest = request.copy(comment = request.comment.withAgentMark()) + persistRevisionMutation( + current = pullRequest, + operation = "reply", + idempotencyKey = + "${request.pullRequestId}:${request.reviewRevisionId}:${request.comment.id}", + action = { reviewPort.reply(markedRequest) }, + reviewRevision = { it.reviewRevision }, + valid = { + isValidRevisionChange(pullRequest, it.reviewRevision) && + it.reviewRevision.threads.any { item -> item.id == thread.id } + }, + invalidMessage = "review provider returned an invalid reply revision", + ) + } + } + } + } + } + + /* + * 이미 게시된 원격 답글의 revision만 저장하며 Store 실패 시 원격 답글을 보상하지 않습니다. + */ + private fun persistRecoveredReply( + request: ReplyReviewThreadRequest, + response: ReplyReviewThreadResponse, + ): WorkflowResult { + val store = storePort ?: return WorkflowResult.Success(response) + val idempotencyKey = + "${request.pullRequestId}:${request.reviewRevisionId}:${request.comment.id}" + val snapshot = + when (val result = store.snapshot(StoreSnapshotRequest(StoreScope.ALL))) { + is PortResult.Failure -> return result.toWorkflowFailure(FailureCode.STORE_FAILURE) + is PortResult.Success -> result.value.snapshot + } + val stored = snapshot.pullRequests.firstOrNull { it.id == request.pullRequestId } + if (stored == null) { + return failure( + FailureCode.REVIEW_NOT_FOUND, + "pull request was not found", + request.pullRequestId, + ) + } + if (stored.reviewRevision.id != request.reviewRevisionId) + return staleReview(request.pullRequestId) + if (!isValidRevisionChange(stored, response.reviewRevision)) { + return invalidProvider(response.change, "review provider returned an invalid reply revision") + } + val transactionRequest = + StoreTransactionRequest( + transactionId = "review-reply-recovery-${UUID.randomUUID()}", + expectedRevision = snapshot.revision, + idempotencyKey = idempotencyKey, + ) + return WorkflowTransaction(store, compensationGateway()).execute(transactionRequest) { + transactionSnapshot -> + val transactionStored = + transactionSnapshot.pullRequests.firstOrNull { it.id == request.pullRequestId } + when { + transactionStored == null -> + failurePort( + FailureCode.REVIEW_NOT_FOUND, + "pull request was not found", + request.pullRequestId, + ) + transactionStored.reviewRevision.id != request.reviewRevisionId -> + failurePort( + FailureCode.STALE_REVISION, + "review revision is stale", + request.pullRequestId, + ) + else -> + PortResult.Success( + TransactionMutation( + data = response, + snapshot = + transactionSnapshot.replacePullRequest( + transactionStored.copy(reviewRevision = response.reviewRevision) + ), + changes = emptyList(), + ) + ) + } + } + } + + fun resolve(request: ResolveReviewThreadRequest): WorkflowResult = + withThread(request.pullRequestId, request.reviewRevisionId, request.threadId) { + current, + thread -> + if (!thread.isOpen) { + failure(FailureCode.STATE_CONFLICT, "review thread is already resolved") + } else if (thread.requiresHumanResolution && !request.actor.isHuman) { + failure( + FailureCode.HUMAN_REQUIRED, + "a review thread containing a human comment requires a human resolver", + target = thread.id, + blockedByCode = "HUMAN_REVIEW_REQUIRED", + ) + } else { + persistRevisionMutation( + current = current, + operation = "resolve", + idempotencyKey = + "${request.pullRequestId}:${request.reviewRevisionId}:${request.threadId}", + action = { reviewPort.resolve(request) }, + reviewRevision = { it.reviewRevision }, + valid = { + isValidRevisionChange(current, it.reviewRevision) && + it.reviewRevision.threads.any { item -> + item.id == thread.id && + item.state == io.springkit.workflow.domain.ThreadState.RESOLVED + } + }, + invalidMessage = "review provider returned an unresolved thread", + ) + } + } + + private fun validateOpen(request: OpenReviewRequest): FailureData? { + if (request.subTaskId.isBlank() || request.title.isBlank() || request.body.isBlank()) { + return FailureData(FailureCode.INVALID_ARGUMENT, "review identity and body must not be blank") + } + if (request.base.isBlank() || request.branch.isBlank()) { + return FailureData(FailureCode.INVALID_ARGUMENT, "review base and branch must not be blank") + } + return null + } + + private fun currentReview( + pullRequestId: String, + expectedReviewRevisionId: ReviewRevisionId, + ): WorkflowResult = + when (val result = reviewPort.get(GetReviewRequest(pullRequestId))) { + is PortResult.Failure -> failureFrom(result) + is PortResult.Success -> { + val stale = requireCurrentReview(result.value.pullRequest, expectedReviewRevisionId) + if (stale != null) WorkflowResult.Failure(stale) + else WorkflowResult.Success(result.value.pullRequest) + } + } + + private fun withThread( + pullRequestId: String, + expectedReviewRevisionId: ReviewRevisionId, + threadId: ThreadId, + action: (PullRequest, ReviewThread) -> WorkflowResult, + ): WorkflowResult = + when (val current = currentReview(pullRequestId, expectedReviewRevisionId)) { + is WorkflowResult.Failure -> current + is WorkflowResult.Success -> { + val thread = current.data.reviewRevision.threads.firstOrNull { it.id == threadId } + if (thread == null) { + failure(FailureCode.THREAD_NOT_FOUND, "review thread does not belong to the revision") + } else { + action(current.data, thread) + } + } + } + + private fun isValidOpenResponse( + request: OpenReviewRequest, + response: OpenReviewResponse, + ): Boolean { + val pullRequest = response.pullRequest + return pullRequest.subTaskId == request.subTaskId && + pullRequest.title == request.title && + pullRequest.body == request.body && + pullRequest.base == request.base && + pullRequest.state == io.springkit.workflow.domain.PullRequestState.DRAFT && + pullRequest.risk == request.risk && + pullRequest.reviewRevision.id == request.reviewRevision.id && + pullRequest.changeRevision.id == request.changeRevision.id + } + + private fun isValidRevisionChange( + before: PullRequest, + after: io.springkit.workflow.domain.ReviewRevision, + ): Boolean = before.reviewRevision.id != after.id + + /* + * 외부 Review 변경을 검증하고 최신 Review revision을 Store에 원자적으로 반영합니다. + */ + private fun persistRevisionMutation( + current: PullRequest, + operation: String, + idempotencyKey: String, + action: () -> PortResult, + reviewRevision: (T) -> io.springkit.workflow.domain.ReviewRevision, + valid: (T) -> Boolean, + invalidMessage: String, + ): WorkflowResult { + val store = storePort + if (store == null) { + return completeMutation(action(), reviewRevision, valid, invalidMessage) + } + val snapshot = + when (val result = store.snapshot(StoreSnapshotRequest(StoreScope.ALL))) { + is PortResult.Failure -> return result.toWorkflowFailure(FailureCode.STORE_FAILURE) + is PortResult.Success -> result.value.snapshot + } + val stored = snapshot.pullRequests.firstOrNull { it.id == current.id } + if (stored == null) { + return failure(FailureCode.REVIEW_NOT_FOUND, "pull request was not found", current.id) + } + if (stored.reviewRevision.id != current.reviewRevision.id) { + return staleReview(current.id) + } + val transactionRequest = + StoreTransactionRequest( + transactionId = "review-$operation-$idempotencyKey", + expectedRevision = snapshot.revision, + idempotencyKey = idempotencyKey, + ) + return WorkflowTransaction(store, compensationGateway()).execute(transactionRequest) { + transactionSnapshot -> + val transactionStored = transactionSnapshot.pullRequests.firstOrNull { it.id == current.id } + if (transactionStored == null) { + failurePort( + FailureCode.REVIEW_NOT_FOUND, + "pull request was not found", + current.id, + ) + } else if (transactionStored.reviewRevision.id != current.reviewRevision.id) { + failurePort( + FailureCode.STALE_REVISION, + "review revision is stale", + current.id, + ) + } else { + when (val result = action()) { + is PortResult.Failure -> result + is PortResult.Success -> { + val nextRevision = reviewRevision(result.value) + if (!valid(result.value)) { + PortResult.Failure( + PortError( + FailureCode.INVARIANT_VIOLATION.name, + invalidMessage, + target = current.id, + ), + result.value.change, + ) + } else { + val nextPullRequest = transactionStored.copy(reviewRevision = nextRevision) + PortResult.Success( + TransactionMutation( + data = result.value, + snapshot = transactionSnapshot.replacePullRequest(nextPullRequest), + changes = listOf(result.value.change), + ) + ) + } + } + } + } + } + } + + private fun completeMutation( + result: PortResult, + reviewRevision: (T) -> io.springkit.workflow.domain.ReviewRevision, + valid: (T) -> Boolean, + invalidMessage: String, + ): WorkflowResult = + when (result) { + is PortResult.Failure -> failureFrom(result) + is PortResult.Success -> + if (valid(result.value) && reviewRevision(result.value).id.isNotBlank()) + WorkflowResult.Success(result.value) + else invalidProvider(result.value.change, invalidMessage) + } + + private fun staleReview(target: String): WorkflowResult.Failure = + WorkflowResult.Failure( + FailureData( + code = FailureCode.STALE_REVISION, + message = "review revision is stale", + blockedBy = + listOf( + io.springkit.workflow.domain.BlockedBy( + code = "STALE_REVISION", + message = "the review changed after it was read", + target = target, + ) + ), + ) + ) + + private fun failurePort( + code: FailureCode, + message: String, + target: String, + ): PortResult = PortResult.Failure(PortError(code.name, message, target = target)) + + private fun WorkflowStoreSnapshot.replacePullRequest(value: PullRequest): WorkflowStoreSnapshot = + copy(pullRequests = pullRequests.filterNot { it.id == value.id } + value) + + private fun compensationGateway(): CompensationPort = + compensationPort + ?: object : CompensationPort { + override fun compensate(request: CompensateRequest): PortResult = + PortResult.Success(CompensateResponse(request.change)) + } + + private fun requireCurrentReview( + pullRequest: PullRequest, + expectedReviewRevisionId: ReviewRevisionId, + ): FailureData? = + if (pullRequest.reviewRevision.id == expectedReviewRevisionId) null + else + FailureData( + code = FailureCode.STALE_REVISION, + message = "review revision is stale", + blockedBy = + listOf( + io.springkit.workflow.domain.BlockedBy( + code = "STALE_REVISION", + message = "the review changed after it was read", + target = pullRequest.reviewRevision.id, + ), + ), + ) + + private fun normalizeUpdate( + before: PullRequest, + request: UpdateReviewRequest, + response: UpdateReviewResponse, + ): UpdateReviewResponse? { + val after = response.pullRequest + if (after.id != before.id || response.codeChanged != (request.changeRevision != null)) { + return null + } + if (request.body != null && after.body != request.body) return null + if (request.body == null && after.body != before.body) return null + if (request.reviewRevision != null && after.reviewRevision.id != request.reviewRevision.id) { + return null + } + if (after.reviewRevision.id == before.reviewRevision.id) return null + if (response.codeChanged) { + if ( + request.changeRevision == null || + after.changeRevision.id == before.changeRevision.id || + after.changeRevision.id != request.changeRevision.id + ) { + return null + } + return response.copy( + pullRequest = + after.copy( + approval = null, + ci = CiStatus.PENDING, + aiReview = AiReviewStatus.PENDING, + ), + ) + } + + if (after.changeRevision.id != before.changeRevision.id) return null + return response.copy( + pullRequest = + after.copy( + changeRevision = before.changeRevision, + approval = before.approval, + ci = before.ci, + aiReview = before.aiReview, + ), + ) + } + + private fun io.springkit.workflow.domain.ReviewComment.withAgentMark() = + if ( + author.kind != io.springkit.workflow.domain.ActorKind.AGENT || body.startsWith("[Agent]") + ) { + this + } else { + copy(body = "[Agent] $body") + } + + private fun failureFrom(result: PortResult.Failure): WorkflowResult { + compensate(result.change, "review operation failed: ${result.error.code}") + return failure( + failureCode(result.error.code), + result.error.message, + result.error.target, + ) + } + + private fun invalidProvider(change: ChangeReceipt, message: String): WorkflowResult.Failure { + compensate(change, "review provider invariant violated") + return WorkflowResult.Failure( + FailureData( + code = FailureCode.INVARIANT_VIOLATION, + message = message, + ), + ) + } + + private fun compensate(change: ChangeReceipt?, reason: String) { + if (change != null && change.status != ChangeStatus.COMPENSATED) { + compensationPort?.compensate(CompensateRequest(change, reason)) + } + } + + private fun failureCode(code: String): FailureCode = + runCatching { FailureCode.valueOf(code.uppercase()) } + .getOrDefault( + when (code.uppercase()) { + "CONFLICT", + "REVISION_CONFLICT" -> FailureCode.STATE_CONFLICT + "FORBIDDEN", + "UNAUTHORIZED" -> FailureCode.HUMAN_REQUIRED + else -> FailureCode.STATE_CONFLICT + }, + ) + + private fun failure( + code: FailureCode, + message: String, + target: String? = null, + blockedByCode: String = code.name, + ): WorkflowResult = + WorkflowResult.Failure( + FailureData( + code = code, + message = message, + blockedBy = + if (target == null) emptyList() + else + listOf( + io.springkit.workflow.domain.BlockedBy(blockedByCode, message, target) + ), + ), + ) +} + +/* + * 인바운드 어댑터가 별도 공급자 요청 모델 없이 사용하는 리뷰 생성 요청 별칭입니다. + */ +typealias ReviewOpenUseCaseRequest = OpenReviewRequest + +/* + * 인바운드 어댑터가 별도 공급자 요청 모델 없이 사용하는 리뷰 조회 요청 별칭입니다. + */ +typealias ReviewShowUseCaseRequest = GetReviewRequest + +/* + * 인바운드 어댑터가 별도 공급자 요청 모델 없이 사용하는 리뷰 갱신 요청 별칭입니다. + */ +typealias ReviewUpdateUseCaseRequest = UpdateReviewRequest + +/* + * 인바운드 어댑터가 별도 공급자 요청 모델 없이 사용하는 리뷰 댓글 요청 별칭입니다. + */ +typealias ReviewCommentUseCaseRequest = AddReviewCommentRequest + +/* + * 인바운드 어댑터가 별도 공급자 요청 모델 없이 사용하는 리뷰 답글 요청 별칭입니다. + */ +typealias ReviewReplyUseCaseRequest = ReplyReviewThreadRequest + +/* + * 인바운드 어댑터가 별도 공급자 요청 모델 없이 사용하는 리뷰 스레드 해결 요청 별칭입니다. + */ +typealias ReviewResolveUseCaseRequest = ResolveReviewThreadRequest diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/application/StackSyncUseCases.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/StackSyncUseCases.kt new file mode 100644 index 00000000..edd4f4a9 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/StackSyncUseCases.kt @@ -0,0 +1,895 @@ +package io.springkit.workflow.application + +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.BranchName +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.Dependency +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureConflict +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.FailureWorkspace +import io.springkit.workflow.domain.IntegrationState +import io.springkit.workflow.domain.NextAction +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskId +import io.springkit.workflow.domain.SyncConflict +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspacePath + +/* + * 관리 SubTask의 직접 의존성 하나를 변경합니다. + */ +data class StackRequest( + val subTaskId: SubTaskId, + val requires: SubTaskId? = null, + val clear: Boolean = false, + val expectedStoreRevision: String? = null, +) + +data class StackResponse( + val subTask: SubTask, + val workspace: Workspace, + val baseBranch: BranchName, + val baseRevision: String, + val pullRequest: PullRequest? = null, + val diffIdentitySame: Boolean = true, + val changeRevisionRetained: Boolean = true, + val approvalRetained: Boolean = true, + /* + * WorkflowStoreSnapshot에 없어 다시 확인해야 하는 저장소 필드입니다. + */ + val invalidatedState: List = emptyList(), +) + +/* + * 관리 Worktree를 main 또는 직접 의존성과 동기화합니다. + */ +data class SyncRequest( + val subTaskId: SubTaskId, + val continueSync: Boolean = false, + val abort: Boolean = false, + val expectedStoreRevision: String? = null, +) + +data class SyncConflictRecovery( + val subTaskId: SubTaskId, + val paths: List, + val canContinue: Boolean = true, + val canAbort: Boolean = true, +) + +data class SyncResponse( + val subTask: SubTask, + val workspace: Workspace, + val baseBranch: BranchName, + val baseRevision: String, + val pullRequest: PullRequest? = null, + val diffIdentitySame: Boolean = true, + val changeRevisionRetained: Boolean = true, + val approvalRetained: Boolean = true, + val invalidatedState: List = emptyList(), + val recovery: SyncConflictRecovery? = null, +) + +typealias StackCommand = StackRequest + +typealias SyncCommand = SyncRequest + +/* + * stack과 sync 명령을 위한 애플리케이션 진입점입니다. + */ +class StackSyncUseCases( + private val gitPort: GitPort, + private val reviewPort: ReviewPort, + private val storePort: WorkflowStorePort, + private val idPort: IdPort? = null, + private val compensationPort: CompensationPort? = null, + private val clockPort: ClockPort? = null, +) { + constructor( + storePort: WorkflowStorePort, + gitPort: GitPort, + reviewPort: ReviewPort, + idPort: IdPort? = null, + compensationPort: CompensationPort? = null, + clockPort: ClockPort? = null, + ) : this(gitPort, reviewPort, storePort, idPort, compensationPort, clockPort) + + fun stack(request: StackRequest): WorkflowResult = executeStack(request) + + fun execute(request: StackRequest): WorkflowResult = executeStack(request) + + fun sync(request: SyncRequest): WorkflowResult = executeSync(request) + + fun execute(request: SyncRequest): WorkflowResult = executeSync(request) + + fun handle(request: StackRequest): WorkflowResult = executeStack(request) + + fun handle(request: SyncRequest): WorkflowResult = executeSync(request) + + private fun executeStack(request: StackRequest): WorkflowResult { + if ((request.requires == null) == !request.clear) { + return failure( + FailureCode.INVALID_ARGUMENT, + "stack requires exactly one of requires or clear", + ) + } + val snapshot = snapshot() ?: return lastFailure + staleIfNeeded(request.expectedStoreRevision, snapshot.revision)?.let { + return it + } + val target = findTarget(snapshot, request.subTaskId) ?: return lastFailure + val desiredParent = + if (request.clear) null + else resolveDependency(snapshot, target.subTask, request.requires!!) ?: return lastFailure + val base = resolveBase(snapshot, desiredParent) ?: return lastFailure + if (target.subTask.requires == desiredParent.idOrNull()) { + return WorkflowResult.Success( + StackResponse( + target.subTask, + target.workspace, + base.branch, + base.revision, + snapshot.pullRequests.firstOrNull { it.subTaskId == target.subTask.id }, + ), + ) + } + + val childStatus = inspect(target.workspace) ?: return lastFailure + val tx = transaction("stack:${request.subTaskId}:${desiredParent.idOrNull()}", snapshot) + if (!begin(tx)) return lastFailure + + val changes = mutableListOf() + val restackResult = + gitPort.restack( + RestackRequest( + target.workspace.id, + target.subTask.branch, + base.branch, + base.revision, + childStatus.revision, + ), + ) + val restack = + when (val result = restackResult) { + is PortResult.Success -> { + changes += result.value.change + result.value + } + is PortResult.Failure -> { + result.change?.let(changes::add) + return recover(tx, changes, failureFrom(result.error)) + } + } + if (restack.conflicts.isNotEmpty()) { + return conflict( + tx, + request.subTaskId, + target.workspace.path, + restack.conflicts, + target.subTask, + changes, + ) + } + + val review = synchronizeReview(snapshot, target.subTask, base, restack.diffChanged, changes) + if (review is PortResult.Failure) return recover(tx, changes, failureFrom(review.error)) + review as PortResult.Success + val updatedSubTask = target.subTask.copy(requires = desiredParent.idOrNull()) + val updatedSnapshot = + snapshot.copy( + subTasks = snapshot.subTasks.replaceById(updatedSubTask) { it.id }, + dependencies = + snapshot.dependencies.filterNot { it.subTaskId == updatedSubTask.id } + + listOfNotNull( + updatedSubTask.requires?.let { Dependency(updatedSubTask.id, it) } + ), + pullRequests = + snapshot.pullRequests.replaceByIdOrKeep(review.value.pullRequest) { it.id }, + syncConflicts = snapshot.syncConflicts - request.subTaskId, + ) + if (!writeAndCommit(tx, snapshot, updatedSnapshot)) { + return recover(tx, changes, lastFailure.data) + } + return WorkflowResult.Success( + StackResponse( + updatedSubTask, + target.workspace, + base.branch, + base.revision, + review.value.pullRequest, + diffIdentitySame = !restack.diffChanged, + changeRevisionRetained = !restack.diffChanged, + approvalRetained = !restack.diffChanged, + invalidatedState = invalidatedStateFor(restack.diffChanged), + ), + ) + } + + private fun executeSync(request: SyncRequest): WorkflowResult { + if (request.continueSync && request.abort) { + return failure(FailureCode.INVALID_ARGUMENT, "sync accepts at most one recovery action") + } + val snapshot = snapshot() ?: return lastFailure + staleIfNeeded(request.expectedStoreRevision, snapshot.revision)?.let { + return it + } + val pending = snapshot.syncConflicts[request.subTaskId] + if (request.abort) return abortSync(request, snapshot) + val target = findTarget(snapshot, request.subTaskId) ?: return lastFailure + if (pending != null && !request.continueSync) { + return conflictFailure(request.subTaskId, target.workspace.path, pending.conflictPaths) + } + if (request.continueSync && pending == null) { + return failure( + FailureCode.STATE_CONFLICT, + "sync --continue is only available for an active SYNC_CONFLICT recovery", + ) + } + val parent = + target.subTask.requires?.let { id -> snapshot.subTasks.firstOrNull { it.id == id } } + if (target.subTask.requires != null && parent == null) { + return failure( + FailureCode.DEPENDENCY_NOT_FOUND, + "direct dependency is missing", + target.subTask.requires, + ) + } + val base = resolveBase(snapshot, parent) ?: return lastFailure + val childStatus = inspect(target.workspace) ?: return lastFailure + if (childStatus.conflicts.isNotEmpty()) { + return conflictFailure(request.subTaskId, target.workspace.path, childStatus.conflicts) + } + if (request.continueSync) { + return continueAndPersist( + snapshot, + target, + base, + request.subTaskId, + pending?.conflictPaths.orEmpty(), + ) + } + return restackAndPersist(snapshot, target, base, request.subTaskId) + } + + private fun abortSync( + request: SyncRequest, + snapshot: WorkflowStoreSnapshot, + ): WorkflowResult { + if (snapshot.syncConflicts[request.subTaskId] == null) { + return failure( + FailureCode.STATE_CONFLICT, + "sync --abort is only available for SYNC_CONFLICT", + ) + } + val target = findTarget(snapshot, request.subTaskId) ?: return lastFailure + val parent = + target.subTask.requires?.let { id -> snapshot.subTasks.firstOrNull { it.id == id } } + val base = resolveBase(snapshot, parent) ?: return lastFailure + val aborted = + when (val result = gitPort.abortRestack(AbortRestackRequest(target.workspace.id))) { + is PortResult.Success -> result + is PortResult.Failure -> return WorkflowResult.Failure(failureFrom(result.error)) + } + val tx = transaction("sync-abort:${request.subTaskId}", snapshot) + if (!begin(tx)) return lastFailure + val updatedSnapshot = snapshot.copy(syncConflicts = snapshot.syncConflicts - request.subTaskId) + if (!writeAndCommit(tx, snapshot, updatedSnapshot)) { + return recover(tx, listOf(aborted.value.change), lastFailure.data) + } + return WorkflowResult.Success( + SyncResponse( + target.subTask, + target.workspace, + base.branch, + base.revision, + snapshot.pullRequests.firstOrNull { it.subTaskId == target.subTask.id }, + recovery = null, + ), + next = listOf(NextAction(ActorKind.AGENT, "check")), + ) + } + + private fun continueAndPersist( + snapshot: WorkflowStoreSnapshot, + target: Target, + base: Base, + subTaskId: SubTaskId, + previousConflictPaths: List, + ): WorkflowResult { + val continued = + when (val result = gitPort.continueRestack(ContinueRestackRequest(target.workspace.id))) { + is PortResult.Success -> result + is PortResult.Failure -> + return if (result.error.code.uppercase() == "SYNC_CONFLICT") { + conflictFailure( + subTaskId, + target.workspace.path, + (previousConflictPaths + conflictPaths(target.workspace, result.error)) + .distinct(), + ) + } else { + WorkflowResult.Failure(failureFrom(result.error)) + } + } + val status = inspect(target.workspace) ?: return lastFailure + if (status.conflicts.isNotEmpty()) { + return conflictFailure(subTaskId, target.workspace.path, status.conflicts) + } + val tx = transaction("sync-continue:$subTaskId", snapshot) + if (!begin(tx)) return lastFailure + val changes = mutableListOf(continued.value.change) + val review = synchronizeReview(snapshot, target.subTask, base, true, changes) + if (review is PortResult.Failure) return recover(tx, changes, failureFrom(review.error)) + review as PortResult.Success + val parent = + target.subTask.requires?.let { id -> snapshot.subTasks.firstOrNull { it.id == id } } + val parentMerged = + parent != null && + (parent.state == io.springkit.workflow.domain.SubTaskState.MERGED || + snapshot.integrations.any { + it.subTaskId == parent.id && it.state == IntegrationState.MERGED + }) + val updatedSubTask = if (parentMerged) target.subTask.copy(requires = null) else target.subTask + val updatedSnapshot = + snapshot.copy( + subTasks = snapshot.subTasks.replaceById(updatedSubTask) { it.id }, + dependencies = + snapshot.dependencies.filterNot { it.subTaskId == updatedSubTask.id } + + listOfNotNull( + updatedSubTask.requires?.let { Dependency(updatedSubTask.id, it) } + ), + pullRequests = + snapshot.pullRequests.replaceByIdOrKeep(review.value.pullRequest) { it.id }, + syncConflicts = snapshot.syncConflicts - subTaskId, + ) + if (!writeAndCommit(tx, snapshot, updatedSnapshot)) { + return recover(tx, changes, lastFailure.data) + } + return WorkflowResult.Success( + SyncResponse( + updatedSubTask, + target.workspace, + base.branch, + base.revision, + review.value.pullRequest, + diffIdentitySame = false, + changeRevisionRetained = false, + approvalRetained = false, + invalidatedState = invalidatedStateFor(true), + ), + next = listOf(NextAction(ActorKind.AGENT, "check")), + ) + } + + private fun restackAndPersist( + snapshot: WorkflowStoreSnapshot, + target: Target, + base: Base, + subTaskId: SubTaskId, + ): WorkflowResult { + val status = inspect(target.workspace) ?: return lastFailure + val tx = transaction("sync:$subTaskId", snapshot) + if (!begin(tx)) return lastFailure + val changes = mutableListOf() + val restackResult = + gitPort.restack( + RestackRequest( + target.workspace.id, + target.subTask.branch, + base.branch, + base.revision, + status.revision, + ), + ) + val restack = + when (val result = restackResult) { + is PortResult.Success -> { + changes += result.value.change + result.value + } + is PortResult.Failure -> { + result.change?.let(changes::add) + return if (result.error.code.uppercase() == "SYNC_CONFLICT") { + conflict( + tx, + subTaskId, + target.workspace.path, + conflictPaths(target.workspace, result.error), + target.subTask, + changes, + ) + } else recover(tx, changes, failureFrom(result.error)) + } + } + if (restack.conflicts.isNotEmpty()) { + return conflict( + tx, + subTaskId, + target.workspace.path, + restack.conflicts, + target.subTask, + changes, + ) + } + val review = synchronizeReview(snapshot, target.subTask, base, restack.diffChanged, changes) + if (review is PortResult.Failure) return recover(tx, changes, failureFrom(review.error)) + review as PortResult.Success + val parent = + target.subTask.requires?.let { id -> snapshot.subTasks.firstOrNull { it.id == id } } + val parentMerged = + parent != null && + (parent.state == io.springkit.workflow.domain.SubTaskState.MERGED || + snapshot.integrations.any { + it.subTaskId == parent.id && it.state == IntegrationState.MERGED + }) + val updatedSubTask = if (parentMerged) target.subTask.copy(requires = null) else target.subTask + val updatedSnapshot = + snapshot.copy( + subTasks = snapshot.subTasks.replaceById(updatedSubTask) { it.id }, + dependencies = + snapshot.dependencies.filterNot { it.subTaskId == updatedSubTask.id } + + listOfNotNull( + updatedSubTask.requires?.let { Dependency(updatedSubTask.id, it) } + ), + pullRequests = + snapshot.pullRequests.replaceByIdOrKeep(review.value.pullRequest) { it.id }, + syncConflicts = snapshot.syncConflicts - subTaskId, + ) + if (!writeAndCommit(tx, snapshot, updatedSnapshot)) + return recover(tx, changes, lastFailure.data) + return WorkflowResult.Success( + SyncResponse( + updatedSubTask, + target.workspace, + base.branch, + base.revision, + review.value.pullRequest, + diffIdentitySame = !restack.diffChanged, + changeRevisionRetained = !restack.diffChanged, + approvalRetained = !restack.diffChanged, + invalidatedState = invalidatedStateFor(restack.diffChanged), + ), + next = listOf(NextAction(ActorKind.AGENT, "check")), + ) + } + + private fun synchronizeReview( + snapshot: WorkflowStoreSnapshot, + subTask: SubTask, + base: Base, + diffChanged: Boolean, + changes: MutableList, + ): PortResult { + val stored = snapshot.pullRequests.firstOrNull { it.subTaskId == subTask.id } + if (stored == null || subTask.pullRequestId == null) { + return PortResult.Success(ReviewSync(null, true)) + } + val current = + when (val result = reviewPort.get(GetReviewRequest(stored.id))) { + is PortResult.Success -> result.value.pullRequest + is PortResult.Failure -> return result + } + val nextChange = + if (!diffChanged) current.changeRevision + else newChangeRevision(current.changeRevision, subTask.id) + val updatedResult = + reviewPort.update( + UpdateReviewRequest( + pullRequestId = current.id, + expectedReviewRevisionId = current.reviewRevision.id, + body = current.body, + base = base.branch, + changeRevision = if (diffChanged) nextChange else null, + ), + ) + val updated = + when (val result = updatedResult) { + is PortResult.Success -> { + changes += result.value.change + result.value + } + is PortResult.Failure -> return result + } + val normalized = + if (diffChanged) { + updated.pullRequest.copy( + base = base.branch, + changeRevision = nextChange, + approval = null, + ci = io.springkit.workflow.domain.CiStatus.PENDING, + aiReview = io.springkit.workflow.domain.AiReviewStatus.PENDING, + ) + } else { + updated.pullRequest.copy( + base = base.branch, + changeRevision = current.changeRevision, + approval = current.approval, + ci = current.ci, + aiReview = current.aiReview, + ) + } + return PortResult.Success(ReviewSync(normalized, !diffChanged)) + } + + private fun newChangeRevision(before: ChangeRevision, subTaskId: SubTaskId): ChangeRevision { + val id = + when ( + val result = idPort?.issue(IssueIdRequest(IdKind.CHANGE_REVISION, "sync:$subTaskId")) + ) { + is PortResult.Success -> result.value.ids.firstOrNull()?.value + else -> null + } ?: "cr-${subTaskId}-${before.number + 1}" + val identity = "${before.diff.identity}:restack:${before.number + 1}" + return before.copy( + id = id, + number = before.number + 1, + diff = before.diff.copy(identity = identity), + ) + } + + private fun resolveDependency( + snapshot: WorkflowStoreSnapshot, + target: SubTask, + parentId: SubTaskId, + ): SubTask? { + val parent = + snapshot.subTasks.firstOrNull { it.id == parentId } + ?: return fail( + FailureCode.DEPENDENCY_NOT_FOUND, + "direct dependency does not exist", + parentId, + ) + if (parent.id == target.id || parent.taskId != target.taskId) { + return fail(FailureCode.INVALID_ARGUMENT, "dependency must belong to the same task", parentId) + } + if ( + parent.state == io.springkit.workflow.domain.SubTaskState.MERGED || + snapshot.integrations.any { + it.subTaskId == parent.id && it.state == IntegrationState.MERGED + } + ) { + return fail( + FailureCode.DEPENDENCY_NOT_MERGED, + "a merged subtask cannot be a stack parent", + parentId, + ) + } + val dependencies = + snapshot.dependencies.filterNot { it.subTaskId == target.id } + + Dependency(target.id, parent.id) + if (io.springkit.workflow.domain.WorkflowRules.hasDependencyCycle(dependencies)) { + return fail(FailureCode.DEPENDENCY_CYCLE, "stack dependency would create a cycle", parentId) + } + var current: SubTask? = parent + val visited = mutableSetOf() + while (current != null && visited.add(current.id)) { + if (current.requires == target.id) { + return fail(FailureCode.DEPENDENCY_CYCLE, "stack dependency would create a cycle", parentId) + } + current = current.requires?.let { id -> snapshot.subTasks.firstOrNull { it.id == id } } + } + return parent + } + + private fun resolveBase( + snapshot: WorkflowStoreSnapshot, + parent: SubTask?, + ): Base? { + if (parent == null) { + return when (val result = gitPort.refreshMain(MainRevisionRequest())) { + is PortResult.Success -> Base("main", result.value.revision) + is PortResult.Failure -> fail(FailureCode.EXTERNAL_FAILURE, result.error.message, "main") + } + } + val integration = snapshot.integrations.firstOrNull { it.subTaskId == parent.id } + if ( + parent.state == io.springkit.workflow.domain.SubTaskState.MERGED || + integration?.state == IntegrationState.MERGED + ) { + return when (val result = gitPort.refreshMain(MainRevisionRequest())) { + is PortResult.Success -> Base("main", result.value.revision) + is PortResult.Failure -> fail(FailureCode.EXTERNAL_FAILURE, result.error.message, "main") + } + } + val workspace = + parent.workspace ?: snapshot.workspaces.firstOrNull { it.subTaskId == parent.id } + if (workspace == null || !workspace.managed) { + return fail(FailureCode.WORKTREE_REQUIRED, "dependency worktree is not managed", parent.id) + } + return when (val result = gitPort.inspect(GitInspectRequest(workspace.id))) { + is PortResult.Success -> Base(parent.branch, result.value.status.revision) + is PortResult.Failure -> fail(FailureCode.EXTERNAL_FAILURE, result.error.message, parent.id) + } + } + + private fun findTarget(snapshot: WorkflowStoreSnapshot, id: SubTaskId): Target? { + val subTask = + snapshot.subTasks.firstOrNull { it.id == id } + ?: return fail(FailureCode.SUBTASK_NOT_FOUND, "subtask does not exist", id) + val workspace = subTask.workspace ?: snapshot.workspaces.firstOrNull { it.subTaskId == id } + if (workspace == null || !workspace.managed) { + return fail(FailureCode.WORKTREE_REQUIRED, "subtask does not have a managed worktree", id) + } + return Target(subTask, workspace) + } + + private fun inspect(workspace: Workspace): GitStatus? = + when (val result = gitPort.inspect(GitInspectRequest(workspace.id))) { + is PortResult.Success -> result.value.status + is PortResult.Failure -> + fail(FailureCode.EXTERNAL_FAILURE, result.error.message, workspace.id) + } + + private fun snapshot(): WorkflowStoreSnapshot? = + when (val result = storePort.snapshot(StoreSnapshotRequest(StoreScope.ALL))) { + is PortResult.Success -> result.value.snapshot + is PortResult.Failure -> + fail(FailureCode.STORE_FAILURE, result.error.message, result.error.target) + } + + private fun transaction(operation: String, snapshot: WorkflowStoreSnapshot) = + StoreTransactionRequest("tx-$operation", snapshot.revision, "$operation:${snapshot.revision}") + + private fun begin(transaction: StoreTransactionRequest): Boolean = + when (val result = storePort.begin(transaction)) { + is PortResult.Success -> + if (result.value.state == StoreTransactionState.OPEN) true + else { + failure(FailureCode.STATE_CONFLICT, "store transaction is not open") + false + } + is PortResult.Failure -> { + failure(FailureCode.STORE_FAILURE, result.error.message, result.error.target) + false + } + } + + private fun writeAndCommit( + transaction: StoreTransactionRequest, + before: WorkflowStoreSnapshot, + after: WorkflowStoreSnapshot, + ): Boolean { + when ( + val write = + storePort.write(StoreWriteRequest(transaction.transactionId, before.revision, after)) + ) { + is PortResult.Failure -> { + lastFailure = WorkflowResult.Failure(failureFrom(write.error)) + return false + } + is PortResult.Success -> Unit + } + return when (val commit = storePort.commit(transaction)) { + is PortResult.Success -> true + is PortResult.Failure -> { + lastFailure = WorkflowResult.Failure(failureFrom(commit.error)) + false + } + } + } + + private fun conflict( + transaction: StoreTransactionRequest, + subTaskId: SubTaskId, + workspacePath: WorkspacePath, + paths: List, + before: SubTask, + changes: List, + ): WorkflowResult.Failure { + storePort.rollback(transaction) + val conflictSnapshot = snapshot() ?: return lastFailure + val conflictTransaction = transaction("sync-conflict:$subTaskId", conflictSnapshot) + if (!begin(conflictTransaction)) return lastFailure + val conflict = + SyncConflict( + subTaskId = subTaskId, + before = before, + conflictPaths = paths.distinct(), + startedAtEpochMillis = now("sync-conflict:$subTaskId"), + ) + val updatedSnapshot = + conflictSnapshot.copy( + syncConflicts = conflictSnapshot.syncConflicts + (subTaskId to conflict) + ) + if (!writeAndCommit(conflictTransaction, conflictSnapshot, updatedSnapshot)) { + return recover(conflictTransaction, emptyList(), lastFailure.data) + } + return conflictFailure(subTaskId, workspacePath, paths) + } + + private fun conflictFailure( + subTaskId: SubTaskId, + workspacePath: WorkspacePath, + paths: List, + ): WorkflowResult.Failure = + WorkflowResult.Failure( + FailureData( + FailureCode.SYNC_CONFLICT, + "automatic synchronization has unresolved conflicts", + blockedBy = + paths.distinct().map { + BlockedBy("SYNC_CONFLICT", "conflict requires resolution", it) + }, + next = + listOf( + NextAction(ActorKind.AGENT, "resolve_conflicts"), + NextAction( + ActorKind.AGENT, + "continue_sync", + "./tools/workflow/bin/workflow sync --continue", + ), + NextAction( + ActorKind.AGENT, + "abort_sync", + "./tools/workflow/bin/workflow sync --abort", + ), + ), + workspace = FailureWorkspace(workspacePath), + conflicts = paths.distinct().map { FailureConflict(it) }, + ), + ) + + private fun conflictPaths(workspace: Workspace, error: PortError): List = + listOfNotNull(error.target).ifEmpty { listOf("${workspace.path.value}:unknown") } + + private fun recover( + transaction: StoreTransactionRequest, + changes: List, + data: FailureData, + ): WorkflowResult.Failure { + val rollback = storePort.rollback(transaction) + val failures = compensate(changes, data.message) + val all = listOfNotNull((rollback as? PortResult.Failure)?.error) + failures + return if (all.isEmpty()) WorkflowResult.Failure(data) + else + WorkflowResult.Failure( + data.copy( + blockedBy = + data.blockedBy + + BlockedBy("COMPENSATION_REQUIRED", all.joinToString { it.message }), + next = data.next + NextAction(ActorKind.WORKFLOW, "reconcile_sync"), + ), + ) + } + + private fun compensate(changes: List, reason: String): List = + changes.asReversed().mapNotNull { change -> + val compensation = change.compensation ?: return@mapNotNull null + val port = + compensationPort + ?: return@mapNotNull PortError( + "COMPENSATION_PORT_UNAVAILABLE", + "compensation port is not configured", + ) + when (val result = port.compensate(CompensateRequest(change, reason))) { + is PortResult.Success -> null + is PortResult.Failure -> result.error + } + } + + private fun now(requestId: String): Long = + when (val result = clockPort?.now(NowRequest(requestId))) { + is PortResult.Success -> result.value.epochMillis + is PortResult.Failure, + null -> 0L + } + + private fun failureFrom(error: PortError, target: String? = error.target): FailureData = + FailureData( + code = mapFailure(error.code), + message = error.message, + blockedBy = listOfNotNull(target?.let { BlockedBy(error.code, error.message, it) }), + next = if (error.retryable) listOf(NextAction(ActorKind.AGENT, "retry")) else emptyList(), + ) + + private fun staleIfNeeded(expected: String?, actual: String): WorkflowResult.Failure? = + if (expected != null && expected != actual) + WorkflowResult.Failure( + FailureData( + FailureCode.STALE_REVISION, + "workflow store revision is stale", + blockedBy = + listOf( + BlockedBy( + "STALE_REVISION", + "expected $expected but found $actual", + actual, + ) + ), + ) + ) + else null + + private fun failure( + code: FailureCode, + message: String, + target: String? = null, + next: List = emptyList(), + ): WorkflowResult.Failure { + lastFailure = + WorkflowResult.Failure( + FailureData( + code, + message, + listOfNotNull(target?.let { BlockedBy(code.name, message, it) }), + next, + ) + ) + return lastFailure + } + + private fun fail(code: FailureCode, message: String, target: String? = null): T? { + failure(code, message, target) + return null + } + + private fun mapFailure(code: String): FailureCode = + runCatching { FailureCode.valueOf(code.uppercase()) } + .getOrDefault( + when (code.uppercase()) { + "CONFLICT", + "MERGE_CONFLICT" -> FailureCode.SYNC_CONFLICT + "REVISION_CONFLICT", + "CONCURRENT_MODIFICATION" -> FailureCode.STALE_REVISION + else -> FailureCode.EXTERNAL_FAILURE + }, + ) + + private fun invalidatedStateFor(diffChanged: Boolean): List = + if (diffChanged) { + listOf("change_revision", "approval", "ci", "ai_review", "validation") + } else emptyList() + + private data class Base(val branch: BranchName, val revision: String) + + private data class Target(val subTask: SubTask, val workspace: Workspace) + + private data class ReviewSync(val pullRequest: PullRequest?, val same: Boolean) + + private var lastFailure: WorkflowResult.Failure = + WorkflowResult.Failure(FailureData(FailureCode.STATE_CONFLICT, "workflow operation failed")) +} + +class StackUseCase( + gitPort: GitPort, + reviewPort: ReviewPort, + storePort: WorkflowStorePort, + idPort: IdPort? = null, + compensationPort: CompensationPort? = null, + clockPort: ClockPort? = null, +) { + private val delegate = + StackSyncUseCases(gitPort, reviewPort, storePort, idPort, compensationPort, clockPort) + + fun execute(request: StackRequest): WorkflowResult = delegate.execute(request) + + fun stack(request: StackRequest): WorkflowResult = delegate.stack(request) +} + +class SyncUseCase( + gitPort: GitPort, + reviewPort: ReviewPort, + storePort: WorkflowStorePort, + idPort: IdPort? = null, + compensationPort: CompensationPort? = null, + clockPort: ClockPort? = null, +) { + private val delegate = + StackSyncUseCases(gitPort, reviewPort, storePort, idPort, compensationPort, clockPort) + + fun execute(request: SyncRequest): WorkflowResult = delegate.execute(request) + + fun sync(request: SyncRequest): WorkflowResult = delegate.sync(request) +} + +private fun SubTask?.idOrNull(): SubTaskId? = this?.id + +private fun List.replaceById(value: T, id: (T) -> String): List = + filterNot { id(it) == id(value) } + value + +private fun List.replaceByIdOrKeep(value: T?, id: (T) -> String): List = + if (value == null) this else replaceById(value, id) diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/application/StartCheckUseCases.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/StartCheckUseCases.kt new file mode 100644 index 00000000..a265858e --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/StartCheckUseCases.kt @@ -0,0 +1,905 @@ +package io.springkit.workflow.application + +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.CheckResult +import io.springkit.workflow.domain.CheckSummary +import io.springkit.workflow.domain.Dependency +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.NextAction +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskId +import io.springkit.workflow.domain.SubTaskStarted +import io.springkit.workflow.domain.Task +import io.springkit.workflow.domain.TaskState +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.ValidationStatus +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspaceId +import io.springkit.workflow.domain.WorkspacePath + +/* + * 공급자와 무관한 시작 애플리케이션 서비스의 입력입니다. + */ +data class StartRequest( + val taskId: io.springkit.workflow.domain.ExternalTaskId, + val requestId: String, + val title: String, + val requires: SubTaskId? = null, + val workspacePath: WorkspacePath? = null, + val expectedStoreRevision: String? = null, +) { + init { + require(requestId.isNotBlank()) { "start request id must not be blank" } + require(title.isNotBlank()) { "start title must not be blank" } + } + + val externalTaskId: io.springkit.workflow.domain.ExternalTaskId + get() = taskId +} + +/* + * SubTask, Branch, 관리 Worktree를 생성한 뒤 반환하는 결과입니다. + */ +data class StartResponse( + val task: Task, + val subTask: SubTask, + val workspace: Workspace, + val baseBranch: String, + val baseRevision: String, + val idempotent: Boolean = false, +) + +/* + * 관리 Worktree 하나를 검사하는 입력입니다. + */ +data class CheckRequest( + val workspaceId: WorkspaceId? = null, + val subTaskId: SubTaskId? = null, + val workspacePath: WorkspacePath? = null, + val expectedStoreRevision: String? = null, +) + +/* + * 모든 검사가 최종 콘텐츠에 적용된 경우에만 검사 결과를 게시할 수 있습니다. + */ +data class CheckResponse( + val workspace: Workspace, + val revision: String, + val fingerprint: String, + val validations: List, + val summary: CheckSummary, + val publishable: Boolean, + val publishabilityMessage: String? = null, +) + +typealias StartCommand = StartRequest + +typealias CheckCommand = CheckRequest + +/* + * wf-02 애플리케이션 서비스입니다. 도메인 객체와 외부 포트만 알고 어댑터의 공급자별 멱등성, 파일 시스템, 프로세스 세부 사항은 알지 않습니다. + */ +class StartUseCase( + private val taskPort: TaskPort, + private val gitPort: GitPort, + private val workspacePort: WorkspacePort, + private val storePort: WorkflowStorePort, + private val idPort: IdPort? = null, + private val compensationPort: CompensationPort? = null, + private val clockPort: ClockPort? = null, + private val projectPrefix: String, +) { + init { + require(projectPrefix.matches(Regex("[A-Za-z][A-Za-z0-9._-]*"))) { + "project prefix must be safe for a subtask id" + } + } + + fun start(request: StartRequest): WorkflowResult = execute(request) + + fun handle(request: StartRequest): WorkflowResult = execute(request) + + fun execute(request: StartRequest): WorkflowResult { + val snapshotResult = storePort.snapshot(StoreSnapshotRequest(StoreScope.ALL)) + val snapshot = + when (snapshotResult) { + is PortResult.Success -> snapshotResult.value.snapshot + is PortResult.Failure -> return failure(snapshotResult.error) + } + if ( + request.expectedStoreRevision != null && request.expectedStoreRevision != snapshot.revision + ) { + return stale("workflow store revision is stale", snapshot.revision) + } + + val taskResult = taskPort.get(TaskLookupRequest(externalId = request.taskId)) + val task = + when (taskResult) { + is PortResult.Success -> taskResult.value.task + is PortResult.Failure -> return failure(taskResult.error) + } + if (task.externalId != request.taskId) { + return WorkflowResult.Failure(invalid("task adapter returned a different external task")) + } + + val startKey = + io.springkit.workflow.domain.StartRequestKey(request.taskId.value, request.requestId) + val previousStart = snapshot.startRequests[startKey] + if (previousStart != null) { + if (previousStart.title != request.title || previousStart.requires != request.requires) { + return WorkflowResult.Failure( + idempotencyConflict("start request metadata differs from the original request") + ) + } + val previousSubTask = snapshot.subTasks.firstOrNull { it.id == previousStart.subTaskId } + val previousWorkspace = + snapshot.workspaces.firstOrNull { it.subTaskId == previousStart.subTaskId } + ?: previousSubTask?.workspace + if (previousSubTask == null || previousWorkspace == null) { + return stateConflict("start request refers to incomplete workflow state") + } + return WorkflowResult.Success( + StartResponse( + task = snapshot.tasks.firstOrNull { it.id == previousSubTask.taskId } ?: task, + subTask = previousSubTask, + workspace = previousWorkspace, + baseBranch = previousSubTask.requires ?: "main", + baseRevision = snapshot.revision, + idempotent = true, + ) + ) + } + + val parent = + if (request.requires == null) { + null + } else { + when (val result = taskPort.getSubTask(SubTaskLookupRequest(request.requires))) { + is PortResult.Success -> result.value.subTask + is PortResult.Failure -> return failure(result.error, target = request.requires) + } + } + if (parent != null && parent.taskId != task.id) { + return WorkflowResult.Failure( + invalid("a dependency must belong to the same task", request.requires) + ) + } + + val base = + if (parent == null) { + when (val result = gitPort.refreshMain(MainRevisionRequest())) { + is PortResult.Success -> Base("main", result.value.revision) + is PortResult.Failure -> return failure(result.error, target = "main") + } + } else { + val parentWorkspace = parent.workspace + if (parentWorkspace == null || !parentWorkspace.managed) { + return WorkflowResult.Failure( + io.springkit.workflow.domain.FailureData( + FailureCode.WORKTREE_REQUIRED, + "the direct dependency must have a managed worktree", + blockedBy = + listOf( + BlockedBy( + "WORKTREE_REQUIRED", + "dependency worktree is not available", + parent.id, + ), + ), + ), + ) + } + when (val result = gitPort.inspect(GitInspectRequest(parentWorkspace.id))) { + is PortResult.Success -> Base(parent.branch, result.value.status.revision) + is PortResult.Failure -> return failure(result.error, target = parent.id) + } + } + + val transactionId = issueId(IdKind.TRANSACTION, "start:${request.requestId}") + val transactionRequest = + StoreTransactionRequest( + transactionId = transactionId, + expectedRevision = snapshot.revision, + idempotencyKey = request.requestId, + ) + when (val result = storePort.begin(transactionRequest)) { + is PortResult.Success -> + if (result.value.state != StoreTransactionState.OPEN) { + return stateConflict("start transaction is not open") + } + is PortResult.Failure -> return failure(result.error) + } + + val changes = mutableListOf() + var lastPortFailure: PortError? = null + val nextSubTaskNumber = snapshot.sequence.subTask + 1 + if (nextSubTaskNumber <= snapshot.sequence.subTask) { + return recover( + transactionRequest, + changes, + failureData(PortError("SUBTASK_ID_UNAVAILABLE", "SubTask ID sequence is exhausted")), + ) + } + val issuedSubTaskId: SubTaskId = "$projectPrefix-$nextSubTaskNumber" + if (snapshot.subTasks.any { it.id == issuedSubTaskId }) { + return recover( + transactionRequest, + changes, + failureData( + PortError( + "SUBTASK_ID_CONFLICT", + "the next SubTask ID is already present in the workflow store", + target = issuedSubTaskId, + ), + ), + ) + } + fun remember(result: PortResult): T? = + when (result) { + is PortResult.Success -> { + changes += result.value.change + result.value + } + is PortResult.Failure -> { + lastPortFailure = result.error + result.change?.let(changes::add) + null + } + } + + val createdSubTask = + remember( + taskPort.createSubTask( + CreateSubTaskRequest( + externalTaskId = request.taskId, + subTaskId = issuedSubTaskId, + title = request.title, + requestId = request.requestId, + requires = request.requires, + ), + ), + ) + ?: return recover( + transactionRequest, + changes, + failureData( + portFailure( + lastPortFailure ?: taskPort.createSubTaskFailure(request), + "create subtask", + ), + ), + ) + + val subTask = createdSubTask.subTask + if (subTask.id != issuedSubTaskId) { + return recover( + transactionRequest, + changes, + invalid("subtask adapter returned a different issued ID", issuedSubTaskId), + ) + } + if ( + subTask.taskId != task.id || + subTask.title != request.title || + subTask.requires != request.requires + ) { + return recover( + transactionRequest, + changes, + invalid("subtask metadata does not match the start request"), + ) + } + val workspaceId = issueId(IdKind.WORKSPACE, "start:${request.requestId}:workspace") + val path = request.workspacePath ?: WorkspacePath("workspaces/${subTask.id}") + + val branch = + remember( + gitPort.createBranch( + CreateBranchRequest( + branch = subTask.branch, + baseBranch = base.branch, + baseRevision = base.revision, + ), + ), + ) + ?: return recover( + transactionRequest, + changes, + failureData( + portFailure( + lastPortFailure ?: gitPort.createBranchFailure(subTask, base), + "create branch", + ), + ), + ) + if (branch.branch != subTask.branch || branch.revision.isBlank()) { + return recover( + transactionRequest, + changes, + invalid("branch adapter returned invalid metadata"), + ) + } + + val worktree = + remember( + gitPort.createWorktree( + CreateWorktreeRequest(workspaceId, subTask.branch, path), + ), + ) + ?: return recover( + transactionRequest, + changes, + failureData( + portFailure( + lastPortFailure ?: gitPort.createWorktreeFailure(workspaceId), + "create worktree", + ), + ), + ) + if (worktree.path != path) { + return recover( + transactionRequest, + changes, + invalid("worktree adapter returned a different path"), + ) + } + + val workspace = + remember( + workspacePort.create( + CreateWorkspaceRequest( + workspaceId = workspaceId, + subTaskId = subTask.id, + branch = subTask.branch, + baseRevision = base.revision, + path = path, + ), + ), + ) + ?.workspace + ?: return recover( + transactionRequest, + changes, + failureData( + portFailure( + lastPortFailure ?: workspacePort.createFailure(workspaceId), + "record workspace", + ), + ), + ) + if ( + !workspace.managed || + workspace.subTaskId != subTask.id || + workspace.branch != subTask.branch + ) { + return recover(transactionRequest, changes, invalid("workspace metadata is not managed")) + } + + val storedSubTask = subTask.copy(workspace = workspace) + val storedTask = + task.copy( + state = if (task.state == TaskState.OPEN) TaskState.IN_PROGRESS else task.state, + subTaskIds = (task.subTaskIds + subTask.id).distinct(), + ) + val storedSnapshot = + snapshot.copy( + sequence = snapshot.sequence.copy(subTask = nextSubTaskNumber), + tasks = snapshot.tasks.replaceById(storedTask) { it.id }, + subTasks = snapshot.subTasks.replaceById(storedSubTask) { it.id }, + dependencies = + snapshot.dependencies.filterNot { it.subTaskId == subTask.id } + + listOfNotNull(request.requires?.let { Dependency(subTask.id, it) }), + workspaces = snapshot.workspaces.replaceById(workspace) { it.id }, + startRequests = + snapshot.startRequests + + (startKey to + io.springkit.workflow.domain.StartRequestRecord( + key = startKey, + subTaskId = subTask.id, + title = request.title, + requires = request.requires, + )), + ) + when ( + val result = + storePort.write(StoreWriteRequest(transactionId, snapshot.revision, storedSnapshot)) + ) { + is PortResult.Success -> Unit + is PortResult.Failure -> { + result.change?.let(changes::add) + return recover(transactionRequest, changes, failureData(result.error)) + } + } + val occurredAt = now("start:${request.requestId}") + when ( + val result = + storePort.append( + StoreEventRequest( + transactionId, + SubTaskStarted(subTask.id, task.id, occurredAt), + ), + ) + ) { + is PortResult.Success -> Unit + is PortResult.Failure -> { + result.change?.let(changes::add) + return recover(transactionRequest, changes, failureData(result.error)) + } + } + when (val result = storePort.commit(transactionRequest)) { + is PortResult.Success -> Unit + is PortResult.Failure -> + return recover(transactionRequest, changes, failureData(result.error)) + } + + return WorkflowResult.Success( + StartResponse( + task = storedTask, + subTask = storedSubTask, + workspace = workspace, + baseBranch = base.branch, + baseRevision = base.revision, + ), + next = listOf(NextAction(io.springkit.workflow.domain.ActorKind.AGENT, "implement_change")), + ) + } + + private fun issueId(kind: IdKind, requestId: String): String = + when (val result = idPort?.issue(IssueIdRequest(kind, requestId))) { + is PortResult.Success -> + result.value.ids.firstOrNull()?.value ?: fallbackId(kind, requestId) + is PortResult.Failure, + null -> fallbackId(kind, requestId) + } + + private fun fallbackId(kind: IdKind, requestId: String): String = + when (kind) { + IdKind.TRANSACTION -> "tx-$requestId" + IdKind.WORKSPACE -> "ws-$requestId" + else -> "$requestId-${kind.name.lowercase()}" + } + + private fun now(requestId: String): Long = + when (val result = clockPort?.now(NowRequest(requestId))) { + is PortResult.Success -> result.value.epochMillis + is PortResult.Failure, + null -> 0L + } + + private fun recover( + transaction: StoreTransactionRequest, + changes: List, + data: io.springkit.workflow.domain.FailureData, + ): WorkflowResult.Failure { + val rollback = storePort.rollback(transaction) + val rollbackFailure = (rollback as? PortResult.Failure)?.error + val compensationFailures = compensate(changes, data.message) + val failures = listOfNotNull(rollbackFailure) + compensationFailures + return if (failures.isEmpty()) { + WorkflowResult.Failure(data) + } else { + WorkflowResult.Failure( + data.copy( + blockedBy = + data.blockedBy + + BlockedBy( + "COMPENSATION_REQUIRED", + failures.joinToString("; ") { it.message }, + ), + next = + data.next + + NextAction( + io.springkit.workflow.domain.ActorKind.WORKFLOW, + "reconcile_start", + ), + ), + ) + } + } + + private fun compensate(changes: List, reason: String): List = + changes.asReversed().mapNotNull { change -> + if (change.compensation == null) return@mapNotNull null + val port = compensationPort + if (port == null) { + return@mapNotNull PortError( + "COMPENSATION_PORT_UNAVAILABLE", + "a compensating action is available but no compensation port is configured", + ) + } + when (val result = port.compensate(CompensateRequest(change, reason))) { + is PortResult.Success -> null + is PortResult.Failure -> result.error + } + } + + private fun portFailure(failure: PortError, stage: String) = + failure.copy(message = "$stage failed: ${failure.message}") + + private fun failure(error: PortError, target: String? = error.target): WorkflowResult.Failure = + WorkflowResult.Failure(failureData(error, target)) + + private fun failureData(error: PortError, target: String? = error.target) = + io.springkit.workflow.domain.FailureData( + code = mapFailureCode(error.code), + message = error.message, + blockedBy = + if (target == null) emptyList() + else listOf(BlockedBy(error.code, error.message, target)), + next = + if (error.retryable) + listOf(NextAction(io.springkit.workflow.domain.ActorKind.AGENT, "retry")) + else emptyList(), + ) + + private fun invalid(message: String, target: String? = null) = + io.springkit.workflow.domain.FailureData( + FailureCode.INVALID_ARGUMENT, + message, + blockedBy = listOfNotNull(target?.let { BlockedBy("INVALID_ARGUMENT", message, it) }), + ) + + private fun stateConflict(message: String) = + WorkflowResult.Failure( + io.springkit.workflow.domain.FailureData(FailureCode.STATE_CONFLICT, message), + ) + + private fun stale(message: String, target: String? = null) = + WorkflowResult.Failure( + io.springkit.workflow.domain.FailureData( + FailureCode.STALE_REVISION, + message, + blockedBy = listOfNotNull(target?.let { BlockedBy("STALE_REVISION", message, it) }), + ), + ) + + private fun mapFailureCode(code: String): FailureCode = + when (code.uppercase()) { + "STALE_REVISION", + "REVISION_CONFLICT", + "CONCURRENT_MODIFICATION" -> FailureCode.STALE_REVISION + "WORKTREE_REQUIRED", + "NOT_MANAGED_WORKTREE" -> FailureCode.WORKTREE_REQUIRED + "VALIDATION_REQUIRED", + "CHECK_FAILED" -> FailureCode.VALIDATION_REQUIRED + "DEPENDENCY_CYCLE" -> FailureCode.DEPENDENCY_CYCLE + "INVALID_ARGUMENT" -> FailureCode.INVALID_ARGUMENT + else -> FailureCode.STATE_CONFLICT + } + + private fun idempotencyConflict(message: String) = + io.springkit.workflow.domain.FailureData( + FailureCode.IDEMPOTENCY_CONFLICT, + message, + blockedBy = listOf(BlockedBy("IDEMPOTENCY_CONFLICT", message)), + ) + + private data class Base(val branch: String, val revision: String) +} + +/* + * wf-03 애플리케이션 서비스입니다. ValidationPort가 명령을 실행하고, 이 서비스는 저장소가 정의한 읽기 전용 검사를 제공하며 검사한 콘텐츠에 대한 결과만 + * 받습니다. + */ +class CheckUseCase( + private val workspacePort: WorkspacePort, + private val gitPort: GitPort, + private val storePort: WorkflowStorePort, + private val validationPort: ValidationPort, + private val taskPort: TaskPort? = null, +) { + fun check(request: CheckRequest): WorkflowResult = execute(request) + + fun handle(request: CheckRequest): WorkflowResult = execute(request) + + fun execute(request: CheckRequest): WorkflowResult { + if (request.workspaceId == null && request.subTaskId == null && request.workspacePath == null) { + return WorkflowResult.Failure( + io.springkit.workflow.domain.FailureData( + FailureCode.WORKTREE_REQUIRED, + "check requires a managed workspace target", + ), + ) + } + val workspaceResult = + workspacePort.get( + WorkspaceLookupRequest( + workspaceId = request.workspaceId, + subTaskId = request.subTaskId, + path = request.workspacePath, + ), + ) + val workspace = + when (workspaceResult) { + is PortResult.Success -> workspaceResult.value.workspace + is PortResult.Failure -> return failure(workspaceResult.error) + } + if (!workspace.managed) { + return WorkflowResult.Failure( + io.springkit.workflow.domain.FailureData( + FailureCode.WORKTREE_REQUIRED, + "check requires a managed workspace", + blockedBy = + listOf(BlockedBy("WORKTREE_REQUIRED", "workspace is not managed", workspace.id)), + ), + ) + } + if (request.subTaskId != null && request.subTaskId != workspace.subTaskId) { + return stale("workspace is not connected to the requested subtask", workspace.id) + } + + val snapshotResult = + storePort.snapshot( + StoreSnapshotRequest(scope = StoreScope.ALL, subTaskId = workspace.subTaskId), + ) + val snapshot = + when (snapshotResult) { + is PortResult.Success -> snapshotResult.value.snapshot + is PortResult.Failure -> return failure(snapshotResult.error) + } + if ( + request.expectedStoreRevision != null && request.expectedStoreRevision != snapshot.revision + ) { + return stale("workflow store revision is stale", snapshot.revision) + } + val subTask = snapshot.subTasks.firstOrNull { it.id == workspace.subTaskId } + if (subTask == null) { + return stateConflict("workspace subtask is not present in the workflow store") + } + if (subTask.branch != workspace.branch) { + return stateConflict("workspace branch does not match the subtask branch") + } + if (subTask.requires != null && snapshot.subTasks.none { it.id == subTask.requires }) { + return stateConflict("subtask dependency is not present in the workflow store") + } + if (taskPort != null && subTask.requires != null) { + when (val result = taskPort.getSubTask(SubTaskLookupRequest(subTask.requires))) { + is PortResult.Success -> Unit + is PortResult.Failure -> return failure(result.error, subTask.requires) + } + } + + val initialStatus = + when (val result = gitPort.inspect(GitInspectRequest(workspace.id))) { + is PortResult.Success -> result.value.status + is PortResult.Failure -> return failure(result.error, workspace.id) + } + if (initialStatus.conflicts.isNotEmpty()) { + return stateConflict("worktree contains unresolved conflicts") + } + val required = + listOf( + Validation("test", "test", ValidationStatus.PENDING, revision = initialStatus.revision), + Validation( + "build", + "build", + ValidationStatus.PENDING, + revision = initialStatus.revision, + ), + Validation( + "static", + "static", + ValidationStatus.PENDING, + revision = initialStatus.revision, + ), + ) + val run = + when ( + val result = + validationPort.run( + RunValidationRequest( + workspaceId = workspace.id, + revision = initialStatus.revision, + fingerprint = initialStatus.fingerprint, + required = required, + ), + ) + ) { + is PortResult.Success -> result.value + is PortResult.Failure -> return failure(result.error, workspace.id) + } + val summary = run.summary ?: summaryFrom(run.validations, initialStatus) + if (!summary.appliesTo(initialStatus.fingerprint, initialStatus.revision)) { + return stale("validation result does not apply to the inspected content", workspace.id) + } + val missingRequiredChecks = required.filterNot { validation -> + summary.checks.any { check -> + check.id == validation.id && + check.fingerprint == initialStatus.fingerprint && + check.revision == initialStatus.revision + } + } + if (missingRequiredChecks.isNotEmpty()) { + return WorkflowResult.Failure( + io.springkit.workflow.domain.FailureData( + FailureCode.INVARIANT_VIOLATION, + "validation result omitted required checks or returned checks for different content: " + + missingRequiredChecks.joinToString(", ") { it.name }, + ), + ) + } + val finalStatus = + when (val result = gitPort.inspect(GitInspectRequest(workspace.id))) { + is PortResult.Success -> result.value.status + is PortResult.Failure -> return failure(result.error, workspace.id) + } + if ( + finalStatus.revision != initialStatus.revision || + finalStatus.fingerprint != initialStatus.fingerprint + ) { + return stale("worktree changed while validation was running", workspace.id) + } + val contentValidated = + summary.passed && summary.appliesTo(finalStatus.fingerprint, finalStatus.revision) + var publishabilityMessage: String? = null + val remotePublishable = + if (contentValidated) { + when ( + val result = + gitPort.checkRemotePush(CheckRemotePushRequest(workspace.id, subTask.branch)) + ) { + is PortResult.Success -> { + publishabilityMessage = result.value.message + result.value.publishable + } + is PortResult.Failure -> { + publishabilityMessage = result.error.message + false + } + } + } else { + false + } + val publishable = contentValidated && remotePublishable + val transaction = + StoreTransactionRequest( + transactionId = "tx-check-${workspace.id}-${finalStatus.fingerprint}", + expectedRevision = snapshot.revision, + idempotencyKey = "check:${workspace.id}:${finalStatus.fingerprint}", + ) + when (val result = storePort.begin(transaction)) { + is PortResult.Failure -> return failure(result.error) + is PortResult.Success -> + if (result.value.state != StoreTransactionState.OPEN) { + return stateConflict("check transaction is not open") + } + } + val storedSnapshot = snapshot.copy(checks = snapshot.checks + (subTask.id to summary)) + when ( + val result = + storePort.write( + StoreWriteRequest(transaction.transactionId, snapshot.revision, storedSnapshot) + ) + ) { + is PortResult.Failure -> { + storePort.rollback(transaction) + return failure(result.error) + } + is PortResult.Success -> Unit + } + when (val result = storePort.commit(transaction)) { + is PortResult.Failure -> return failure(result.error) + is PortResult.Success -> Unit + } + return WorkflowResult.Success( + CheckResponse( + workspace = workspace, + revision = finalStatus.revision, + fingerprint = finalStatus.fingerprint, + validations = run.validations, + summary = summary, + publishable = publishable, + publishabilityMessage = publishabilityMessage, + ), + next = + if (publishable) + listOf(NextAction(io.springkit.workflow.domain.ActorKind.AGENT, "open_review")) + else listOf(NextAction(io.springkit.workflow.domain.ActorKind.AGENT, "fix_validation")), + ) + } + + private fun summaryFrom(validations: List, status: GitStatus): CheckSummary { + val checks = validations.map { + CheckResult( + id = it.id, + name = it.name, + status = it.status, + fingerprint = status.fingerprint, + revision = status.revision, + message = it.message, + ) + } + return CheckSummary(status.fingerprint, status.revision, checks) + } + + private fun failure(error: PortError, target: String? = error.target): WorkflowResult.Failure = + WorkflowResult.Failure( + io.springkit.workflow.domain.FailureData( + code = + when (error.code.uppercase()) { + "STALE_REVISION", + "REVISION_CONFLICT" -> FailureCode.STALE_REVISION + "WORKTREE_REQUIRED", + "NOT_MANAGED_WORKTREE" -> FailureCode.WORKTREE_REQUIRED + "VALIDATION_REQUIRED", + "CHECK_FAILED" -> FailureCode.VALIDATION_REQUIRED + else -> FailureCode.STATE_CONFLICT + }, + message = error.message, + blockedBy = + if (target == null) emptyList() + else listOf(BlockedBy(error.code, error.message, target)), + next = + if (error.retryable) + listOf(NextAction(io.springkit.workflow.domain.ActorKind.AGENT, "retry")) + else emptyList(), + ), + ) + + private fun stateConflict(message: String) = + WorkflowResult.Failure( + io.springkit.workflow.domain.FailureData(FailureCode.STATE_CONFLICT, message), + ) + + private fun stale(message: String, target: String? = null) = + WorkflowResult.Failure( + io.springkit.workflow.domain.FailureData( + FailureCode.STALE_REVISION, + message, + blockedBy = listOfNotNull(target?.let { BlockedBy("STALE_REVISION", message, it) }), + ), + ) +} + +/* + * 두 명령을 하나의 애플리케이션 경계로 노출하는 인바운드 어댑터용 진입점입니다. + */ +class StartCheckUseCases( + taskPort: TaskPort, + gitPort: GitPort, + workspacePort: WorkspacePort, + storePort: WorkflowStorePort, + validationPort: ValidationPort, + idPort: IdPort? = null, + compensationPort: CompensationPort? = null, + clockPort: ClockPort? = null, + projectPrefix: String, +) { + private val startUseCase = + StartUseCase( + taskPort, + gitPort, + workspacePort, + storePort, + idPort, + compensationPort, + clockPort, + projectPrefix, + ) + private val checkUseCase = + CheckUseCase(workspacePort, gitPort, storePort, validationPort, taskPort) + + fun execute(request: StartRequest): WorkflowResult = startUseCase.execute(request) + + fun execute(request: CheckRequest): WorkflowResult = checkUseCase.execute(request) + + fun start(request: StartRequest): WorkflowResult = startUseCase.execute(request) + + fun check(request: CheckRequest): WorkflowResult = checkUseCase.execute(request) +} + +private fun List.replaceById(value: T, id: (T) -> String): List = + filterNot { id(it) == id(value) } + value + +private fun TaskPort.createSubTaskFailure(request: StartRequest): PortError = + PortError("TASK_CREATE_FAILED", "could not create subtask for ${request.taskId.value}") + +private fun GitPort.createBranchFailure(subTask: SubTask, base: Any): PortError = + PortError("BRANCH_CREATE_FAILED", "could not create branch ${subTask.branch}") + +private fun GitPort.createWorktreeFailure(workspaceId: WorkspaceId): PortError = + PortError("WORKTREE_CREATE_FAILED", "could not create worktree $workspaceId") + +private fun WorkspacePort.createFailure(workspaceId: WorkspaceId): PortError = + PortError("WORKSPACE_CREATE_FAILED", "could not record workspace $workspaceId") diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/application/StatusUseCase.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/StatusUseCase.kt new file mode 100644 index 00000000..30b67645 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/StatusUseCase.kt @@ -0,0 +1,751 @@ +package io.springkit.workflow.application + +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.AiReviewStatus +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.CandidateId +import io.springkit.workflow.domain.ChangeRevisionId +import io.springkit.workflow.domain.CiStatus +import io.springkit.workflow.domain.DeploymentCandidate +import io.springkit.workflow.domain.DeploymentCandidateState +import io.springkit.workflow.domain.DiffIdentity +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.Integration +import io.springkit.workflow.domain.IntegrationState +import io.springkit.workflow.domain.MainRevision +import io.springkit.workflow.domain.MergeQueueEntry +import io.springkit.workflow.domain.MergeQueueState +import io.springkit.workflow.domain.NextAction +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.Release +import io.springkit.workflow.domain.ReleaseId +import io.springkit.workflow.domain.ReleaseState +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.ReviewThread +import io.springkit.workflow.domain.Risk +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskCleanupState +import io.springkit.workflow.domain.SubTaskId +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.Task +import io.springkit.workflow.domain.TaskId +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.ValidationStatus +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.Workspace + +/* + * 상태 조회 대상의 타입입니다. 요청마다 대상 하나만 평가합니다. + */ +sealed interface StatusSelector { + data object CurrentWorkspace : StatusSelector + + data class SubTask(val id: SubTaskId) : StatusSelector + + data class Task(val id: TaskId) : StatusSelector + + data class Candidate(val id: CandidateId) : StatusSelector + + data class Release(val id: ReleaseId) : StatusSelector +} + +/* + * 애플리케이션 경계와 CLI 어댑터가 받는 입력입니다. + */ +data class StatusRequest( + val selector: StatusSelector? = null, + val subTaskId: SubTaskId? = null, + val taskId: TaskId? = null, + val candidateId: CandidateId? = null, + val releaseId: ReleaseId? = null, +) { + fun selected(): StatusSelection = + if (selector != null) { + StatusSelection.Typed(selector) + } else { + StatusSelection.Values(subTaskId, taskId, candidateId, releaseId) + } +} + +/* + * CLI 방식의 선택적 값을 검증한 뒤 만든 모호하지 않은 선택자입니다. + */ +sealed interface StatusSelection { + data class Typed(val selector: StatusSelector) : StatusSelection + + data class Values( + val subTaskId: SubTaskId?, + val taskId: TaskId?, + val candidateId: CandidateId?, + val releaseId: ReleaseId?, + ) : StatusSelection +} + +data class StatusWorkspace( + val workspace: Workspace, + val content: ContentSnapshot? = null, + val git: GitStatus? = null, +) { + val path: String + get() = workspace.path.value +} + +data class StatusReview( + val pullRequest: PullRequest, + val state: PullRequestState = pullRequest.state, + val reviewRevision: String = pullRequest.reviewRevision.id, + val changeRevision: ChangeRevisionId = pullRequest.changeRevision.id, + val diffIdentity: DiffIdentity = pullRequest.changeRevision.diff.identity, + val risk: Risk = pullRequest.risk, + val threads: List = pullRequest.reviewRevision.threads, + val ready: Boolean = state != PullRequestState.DRAFT, + val approval: io.springkit.workflow.domain.Approval? = pullRequest.approval, + val ci: CiStatus = pullRequest.ci, + val aiReview: AiReviewStatus = pullRequest.aiReview, +) { + val reviewRevisionObject: ReviewRevision + get() = pullRequest.reviewRevision +} + +data class StatusIntegration( + val integration: Integration, + val mergeQueue: MergeQueueEntry? = integration.mergeQueue, + val mainRevision: MainRevision? = integration.mainRevision, + val state: IntegrationState = integration.state, +) + +data class StatusDeployment( + val candidate: DeploymentCandidate, + val candidateId: CandidateId = candidate.id, + val state: DeploymentCandidateState = candidate.state, + val mainRevision: MainRevision = candidate.mainRevision, + val includedSubTasks: List = candidate.includedSubTasks, + val risk: Risk = candidate.risk, + val gateRequired: Boolean = candidate.deployGateRequired, +) + +data class StatusRelease( + val release: Release, + val releaseId: ReleaseId = release.id, + val state: ReleaseState = release.state, + val gateRequired: Boolean = release.state == ReleaseState.AWAITING_RELEASE_APPROVAL, +) + +/* + * Workflow와 소유 시스템 포트로 구성한 공급자 독립적인 상태입니다. + */ +data class StatusResponse( + val task: Task? = null, + val subTask: SubTask? = null, + val subTasks: List = emptyList(), + val workspace: StatusWorkspace? = null, + val review: StatusReview? = null, + val integration: StatusIntegration? = null, + val deployment: StatusDeployment? = null, + val release: StatusRelease? = null, + val blockedBy: List = emptyList(), + val next: List = emptyList(), +) { + val subtask: SubTask? + get() = subTask +} + +/* + * 공급자나 Workflow Store를 변경하지 않고 일관된 상태를 조회합니다. + */ +class StatusUseCase( + private val storePort: WorkflowStorePort, + private val taskPort: TaskPort? = null, + private val workspacePort: WorkspacePort? = null, + private val contentPort: ContentPort? = null, + private val gitPort: GitPort? = null, + private val reviewPort: ReviewPort? = null, + private val validationPort: ValidationPort? = null, + private val ciPort: CiPort? = null, + private val aiReviewPort: AiReviewPort? = null, + private val mergeQueuePort: MergeQueuePort? = null, + private val deploymentPort: DeploymentPort? = null, + private val releasePort: ReleasePort? = null, +) { + private companion object { + const val DEPENDENCY_NOT_MERGED = "DEPENDENCY_NOT_MERGED" + const val VALIDATION_REQUIRED = "VALIDATION_REQUIRED" + const val CI_NOT_PASSED = "CI_NOT_PASSED" + const val AI_REVIEW_FAILED = "AI_REVIEW_FAILED" + const val REQUIRED_THREAD_OPEN = "REQUIRED_THREAD_OPEN" + const val MERGE_QUEUE_FAILED = "MERGE_QUEUE_FAILED" + const val DEPLOYMENT_GATE_BLOCKED = "DEPLOYMENT_GATE_BLOCKED" + + const val RESOLVE_REQUIRED_THREADS = "resolve_required_threads" + const val FIX_VALIDATION = "fix_validation" + const val AWAIT_CI = "await_ci" + const val AWAIT_DEPENDENCY = "await_dependency" + const val FIX_MERGE_QUEUE = "fix_merge_queue" + } + + fun status(request: StatusRequest = StatusRequest()): WorkflowResult = + execute(request) + + fun handle(request: StatusRequest = StatusRequest()): WorkflowResult = + execute(request) + + fun execute(request: StatusRequest = StatusRequest()): WorkflowResult { + val selection = + when (val result = resolveSelection(request.selected())) { + is WorkflowResult.Success -> result.data + is WorkflowResult.Failure -> return result + } + + val snapshot = + when (val result = storePort.snapshot(StoreSnapshotRequest(StoreScope.ALL))) { + is PortResult.Success -> result.value.snapshot + is PortResult.Failure -> return failure(result.error, FailureCode.STORE_FAILURE) + } + + return when (selection) { + StatusSelector.CurrentWorkspace -> currentWorkspace(snapshot) + is StatusSelector.SubTask -> subTask(snapshot, selection.id, current = false) + is StatusSelector.Task -> task(snapshot, selection.id) + is StatusSelector.Candidate -> candidate(snapshot, selection.id) + is StatusSelector.Release -> release(snapshot, selection.id) + } + } + + private fun resolveSelection(selection: StatusSelection): WorkflowResult = + when (selection) { + is StatusSelection.Typed -> WorkflowResult.Success(selection.selector) + is StatusSelection.Values -> { + val values = + listOfNotNull( + selection.subTaskId?.let { StatusSelector.SubTask(it) }, + selection.taskId?.let { StatusSelector.Task(it) }, + selection.candidateId?.let { StatusSelector.Candidate(it) }, + selection.releaseId?.let { StatusSelector.Release(it) }, + ) + when { + values.size > 1 -> + invalid( + "status target selection accepts at most one of subtask, task, candidate and release", + ) + values.singleOrNull() != null -> WorkflowResult.Success(values.single()) + else -> WorkflowResult.Success(StatusSelector.CurrentWorkspace) + } + } + } + + private fun currentWorkspace(snapshot: WorkflowStoreSnapshot): WorkflowResult { + val workspaceResult = + workspacePort?.get(WorkspaceLookupRequest()) + ?: return failureCode( + FailureCode.WORKSPACE_NOT_FOUND, + "current workspace lookup is not configured", + ) + val workspace = + when (workspaceResult) { + is PortResult.Success -> workspaceResult.value + is PortResult.Failure -> + return failure(workspaceResult.error, FailureCode.WORKSPACE_NOT_FOUND) + } + val subTaskId = workspace.workspace.subTaskId + val storedSubTask = snapshot.subTasks.firstOrNull { it.id == subTaskId } + if (storedSubTask?.cleanupState?.let { it >= SubTaskCleanupState.WORKTREE_REMOVED } == true) { + return subTask(snapshot, subTaskId, current = false) + } + return subTask(snapshot, subTaskId, current = true, workspaceResult = workspace) + } + + private fun task(snapshot: WorkflowStoreSnapshot, id: TaskId): WorkflowResult { + val stored = snapshot.tasks.firstOrNull { it.id == id || it.externalId.value == id } + val task = + if (taskPort == null) { + stored + } else { + when (val result = taskPort.get(TaskLookupRequest(taskId = stored?.id ?: id))) { + is PortResult.Success -> result.value.task + is PortResult.Failure -> return failure(result.error, FailureCode.TASK_NOT_FOUND, id) + } + } + if (task == null) return failureCode(FailureCode.TASK_NOT_FOUND, "task was not found", id) + + val subTaskIds = + if (task.subTaskIds.isNotEmpty()) task.subTaskIds + else { + snapshot.subTasks.filter { it.taskId == task.id }.map { it.id } + } + val statuses = mutableListOf() + for (subTaskId in subTaskIds) { + when (val result = subTask(snapshot, subTaskId, current = false)) { + is WorkflowResult.Success -> statuses += result.data + is WorkflowResult.Failure -> return result + } + } + return WorkflowResult.Success(StatusResponse(task = task, subTasks = statuses)) + } + + private fun subTask( + snapshot: WorkflowStoreSnapshot, + id: SubTaskId, + current: Boolean, + workspaceResult: WorkspaceLookupResponse? = null, + ): WorkflowResult { + val stored = snapshot.subTasks.firstOrNull { it.id == id } + val subTask = + if (taskPort == null) { + stored + } else { + when (val result = taskPort.getSubTask(SubTaskLookupRequest(id))) { + is PortResult.Success -> result.value.subTask + is PortResult.Failure -> + if (stored == null) return failure(result.error, FailureCode.SUBTASK_NOT_FOUND, id) + else stored + } + } + if (subTask == null) { + return failureCode(FailureCode.SUBTASK_NOT_FOUND, "subtask was not found", id) + } + + val workspace = + if (workspaceResult != null) { + readWorkspace(workspaceResult) + } else { + val storedWorkspace = + subTask.workspace ?: snapshot.workspaces.firstOrNull { it.subTaskId == id } + val snapshotWorkspace = storedWorkspace?.takeIf { + (stored?.cleanupState ?: subTask.cleanupState) < SubTaskCleanupState.WORKTREE_REMOVED + } + val workspaceLookup = + if (storedWorkspace != null && snapshotWorkspace == null) null + else + workspacePort?.get( + WorkspaceLookupRequest( + workspaceId = snapshotWorkspace?.id, + subTaskId = id, + ) + ) + when (workspaceLookup) { + is PortResult.Success -> readWorkspace(workspaceLookup.value) + is PortResult.Failure -> + if (current) + return failure(workspaceLookup.error, FailureCode.WORKSPACE_NOT_FOUND, id) + else snapshotWorkspace?.let { readWorkspace(WorkspaceLookupResponse(it)) } + null -> snapshotWorkspace?.let { readWorkspace(WorkspaceLookupResponse(it)) } + } + } + val reviewSnapshot = snapshot.pullRequests.firstOrNull { it.subTaskId == id } + val review = + if (reviewSnapshot == null || reviewPort == null) { + reviewSnapshot + } else { + when (val result = reviewPort.get(GetReviewRequest(reviewSnapshot.id))) { + is PortResult.Success -> result.value.pullRequest + is PortResult.Failure -> + return failure(result.error, FailureCode.REVIEW_NOT_FOUND, reviewSnapshot.id) + } + } + val validations = + when (val result = readValidation(workspace)) { + is ReadResult.Value -> result.value + is ReadResult.Failure -> return result.result + } + val ciStatus = + when (val result = readCi(review)) { + is ReadResult.Value -> result.value + is ReadResult.Failure -> return result.result + } + val aiReviewStatus = + when (val result = readAiReview(review)) { + is ReadResult.Value -> result.value + is ReadResult.Failure -> return result.result + } + val queueEntries = + when (val result = readQueue(id, review)) { + is ReadResult.Value -> result.value + is ReadResult.Failure -> return result.result + } + val integration = + snapshot.integrations.firstOrNull { it.subTaskId == id } + ?: Integration(id, mergeQueue = queueEntries.firstOrNull()) + val effectiveReview = + review?.copy( + ci = ciStatus, + aiReview = aiReviewStatus, + ) + val candidate = candidateFor(snapshot, subTask) + val effectiveCandidate = + when (val result = readCandidate(candidate)) { + is ReadResult.Value -> result.value + is ReadResult.Failure -> return result.result + } + val effectiveRelease = + when (val result = readRelease(effectiveCandidate ?: candidate)) { + is ReadResult.Value -> result.value + is ReadResult.Failure -> return result.result + } + + val blockers = + blockers( + snapshot, + subTask, + effectiveReview, + validations, + integration, + effectiveCandidate, + ) + val next = + nextActions( + snapshot, + subTask, + effectiveReview, + blockers, + integration, + effectiveCandidate, + effectiveRelease, + ) + return WorkflowResult.Success( + StatusResponse( + task = snapshot.tasks.firstOrNull { it.id == subTask.taskId }, + subTask = subTask, + workspace = workspace, + review = effectiveReview?.let(::StatusReview), + integration = StatusIntegration(integration), + deployment = effectiveCandidate?.let(::StatusDeployment), + release = effectiveRelease?.let(::StatusRelease), + blockedBy = blockers, + next = next, + ), + ) + } + + private fun candidate( + snapshot: WorkflowStoreSnapshot, + id: CandidateId, + ): WorkflowResult { + val stored = snapshot.candidates.firstOrNull { it.id == id } + val candidate = + when (val result = readCandidate(stored)) { + is ReadResult.Value -> + result.value + ?: return failureCode( + FailureCode.DEPLOYMENT_NOT_FOUND, + "deployment candidate was not found", + id, + ) + is ReadResult.Failure -> return result.result + } + val statusRelease = + when (val result = readRelease(candidate)) { + is ReadResult.Value -> result.value + is ReadResult.Failure -> return result.result + } + val next = candidateNext(candidate) + (statusRelease?.let(::releaseNext).orEmpty()) + return WorkflowResult.Success( + StatusResponse( + deployment = StatusDeployment(candidate), + release = statusRelease?.let(::StatusRelease), + next = next, + ), + ) + } + + private fun release( + snapshot: WorkflowStoreSnapshot, + id: ReleaseId, + ): WorkflowResult { + val stored = snapshot.releases.firstOrNull { it.id == id } + val result = + if (releasePort == null) { + stored?.let { ReadResult.Value(it) } + ?: ReadResult.Failure( + failureCode(FailureCode.RELEASE_NOT_FOUND, "release was not found", id) + ) + } else { + when (val response = releasePort.get(GetReleaseRequest(releaseId = id))) { + is PortResult.Success -> + response.value.releases.firstOrNull()?.let { ReadResult.Value(it) } + ?: ReadResult.Failure( + failureCode(FailureCode.RELEASE_NOT_FOUND, "release was not found", id) + ) + is PortResult.Failure -> + ReadResult.Failure(failure(response.error, FailureCode.RELEASE_NOT_FOUND, id)) + } + } + val value = + when (result) { + is ReadResult.Value -> result.value + is ReadResult.Failure -> return result.result + } + val candidate = snapshot.candidates.firstOrNull { it.id == value.candidateId } + val statusCandidate = + when (val deployment = readCandidate(candidate)) { + is ReadResult.Value -> deployment.value + is ReadResult.Failure -> return deployment.result + } + return WorkflowResult.Success( + StatusResponse( + deployment = statusCandidate?.let(::StatusDeployment), + release = StatusRelease(value), + next = releaseNext(value), + ), + ) + } + + private fun readWorkspace(value: WorkspaceLookupResponse): StatusWorkspace { + val content = + if (contentPort == null) value.content + else + when (val result = contentPort.inspect(InspectContentRequest(value.workspace.id))) { + is PortResult.Success -> result.value.content + is PortResult.Failure -> value.content + } + val git = + if (gitPort == null) null + else + when (val result = gitPort.inspect(GitInspectRequest(value.workspace.id))) { + is PortResult.Success -> result.value.status + is PortResult.Failure -> null + } + return StatusWorkspace(value.workspace, content, git) + } + + private fun readValidation(workspace: StatusWorkspace?): ReadResult> { + if (workspace == null || validationPort == null || workspace.git == null) + return ReadResult.Value(emptyList()) + return when ( + val result = + validationPort.get( + GetValidationRequest( + workspace.workspace.id, + workspace.git.revision, + workspace.git.fingerprint, + ) + ) + ) { + is PortResult.Success -> ReadResult.Value(result.value.validations) + is PortResult.Failure -> + ReadResult.Failure( + failure(result.error, FailureCode.CHECK_NOT_PASSED, workspace.workspace.subTaskId) + ) + } + } + + private fun readCi(review: PullRequest?): ReadResult { + if (review == null) return ReadResult.Value(CiStatus.PENDING) + val providerRevision = + review.changeRevision.providerRevision ?: return ReadResult.Value(CiStatus.PENDING) + if (ciPort == null) return ReadResult.Value(review.ci) + return when (val result = ciPort.get(GetCiRequest(review.id, providerRevision))) { + is PortResult.Success -> ReadResult.Value(result.value.status) + is PortResult.Failure -> + ReadResult.Failure(failure(result.error, FailureCode.EXTERNAL_FAILURE, review.id)) + } + } + + private fun readAiReview(review: PullRequest?): ReadResult { + if (review == null || aiReviewPort == null) + return ReadResult.Value(review?.aiReview ?: AiReviewStatus.PENDING) + return when ( + val result = aiReviewPort.get(GetAiReviewRequest(review.id, review.reviewRevision.id)) + ) { + is PortResult.Success -> ReadResult.Value(result.value.status) + is PortResult.Failure -> + ReadResult.Failure(failure(result.error, FailureCode.EXTERNAL_FAILURE, review.id)) + } + } + + private fun readQueue( + subTaskId: SubTaskId, + review: PullRequest?, + ): ReadResult> { + if (mergeQueuePort == null) return ReadResult.Value(emptyList()) + return when (val result = mergeQueuePort.get(GetMergeQueueRequest(subTaskId, review?.id))) { + is PortResult.Success -> ReadResult.Value(result.value.entries) + is PortResult.Failure -> + ReadResult.Failure(failure(result.error, FailureCode.MERGE_QUEUE_FAILED, subTaskId)) + } + } + + private fun readCandidate(candidate: DeploymentCandidate?): ReadResult { + if (candidate == null) return ReadResult.Value(null) + if (deploymentPort == null) return ReadResult.Value(candidate) + return when (val result = deploymentPort.getCandidate(GetCandidateRequest(candidate.id))) { + is PortResult.Success -> ReadResult.Value(result.value.candidate) + is PortResult.Failure -> + ReadResult.Failure(failure(result.error, FailureCode.DEPLOYMENT_NOT_FOUND, candidate.id)) + } + } + + private fun readRelease(candidate: DeploymentCandidate?): ReadResult { + if (candidate == null) return ReadResult.Value(null) + if (releasePort == null) return ReadResult.Value(null) + return when (val result = releasePort.get(GetReleaseRequest(candidateId = candidate.id))) { + is PortResult.Success -> ReadResult.Value(result.value.releases.firstOrNull()) + is PortResult.Failure -> + ReadResult.Failure(failure(result.error, FailureCode.RELEASE_NOT_FOUND, candidate.id)) + } + } + + private fun candidateFor( + snapshot: WorkflowStoreSnapshot, + subTask: SubTask, + ): DeploymentCandidate? = snapshot.candidates.firstOrNull { subTask.id in it.includedSubTasks } + + private fun blockers( + snapshot: WorkflowStoreSnapshot, + subTask: SubTask, + review: PullRequest?, + validations: List, + integration: Integration, + candidate: DeploymentCandidate?, + ): List { + val blockers = mutableListOf() + val dependency = subTask.requires + if (subTask.state != SubTaskState.MERGED && dependency != null) { + val parent = snapshot.subTasks.firstOrNull { it.id == dependency } + if (parent?.state != SubTaskState.MERGED) { + blockers += + BlockedBy( + DEPENDENCY_NOT_MERGED, + "direct dependency is not integrated into main", + dependency, + ) + } + } + if (review != null && subTask.state != SubTaskState.MERGED) { + validations + .filter { it.required && it.status != ValidationStatus.PASSED } + .forEach { + blockers += BlockedBy(VALIDATION_REQUIRED, "required validation has not passed", it.id) + } + if (review.ci != CiStatus.PASSED) + blockers += BlockedBy(CI_NOT_PASSED, "required PR CI has not passed", subTask.id) + if (review.aiReview == AiReviewStatus.FAILED) + blockers += BlockedBy(AI_REVIEW_FAILED, "AI review failed", subTask.id) + review.reviewRevision.openRequiredThreads.forEach { + blockers += + BlockedBy(REQUIRED_THREAD_OPEN, "an unresolved [R] review thread remains", it.id) + } + } + if (integration.mergeQueue?.state == MergeQueueState.FAILED) { + blockers += BlockedBy(MERGE_QUEUE_FAILED, "merge queue validation failed", subTask.id) + } + if (candidate?.state == DeploymentCandidateState.FAILED) { + blockers += BlockedBy(DEPLOYMENT_GATE_BLOCKED, "deployment candidate failed", candidate.id) + } + return blockers.distinctBy { Triple(it.code, it.target, it.message) } + } + + private fun nextActions( + snapshot: WorkflowStoreSnapshot, + subTask: SubTask, + review: PullRequest?, + blockers: List, + integration: Integration, + candidate: DeploymentCandidate?, + release: Release?, + ): List { + val next = mutableListOf() + when { + subTask.state == SubTaskState.DRAFT -> + next += + NextAction( + ActorKind.HUMAN, + "ready_review", + "./tools/workflow/bin/workflow gate ready ${subTask.id} --review-revision ${review?.reviewRevision?.id ?: ""}", + ) + (subTask.state == SubTaskState.READY || subTask.state == SubTaskState.REVIEW) && + blockers.isEmpty() -> + next += + NextAction( + ActorKind.HUMAN, + "approve_change", + "./tools/workflow/bin/workflow gate approve ${subTask.id} --change-revision ${review?.changeRevision?.id ?: ""}", + ) + subTask.state == SubTaskState.APPROVED && + blockers.isEmpty() && + integration.mergeQueue == null -> next += NextAction(ActorKind.WORKFLOW, "enqueue_merge") + } + if (subTask.state != SubTaskState.DRAFT && blockers.isNotEmpty()) { + next += blockers.mapNotNull(::blockerNextAction) + } + if (candidate != null) next += candidateNext(candidate) + if (release != null) next += releaseNext(release) + return next.distinct() + } + + private fun blockerNextAction(blocker: BlockedBy): NextAction? = + when (blocker.code) { + REQUIRED_THREAD_OPEN -> NextAction(ActorKind.AGENT, RESOLVE_REQUIRED_THREADS) + VALIDATION_REQUIRED -> NextAction(ActorKind.AGENT, FIX_VALIDATION) + CI_NOT_PASSED -> NextAction(ActorKind.WORKFLOW, AWAIT_CI) + DEPENDENCY_NOT_MERGED -> NextAction(ActorKind.WORKFLOW, AWAIT_DEPENDENCY) + MERGE_QUEUE_FAILED -> NextAction(ActorKind.AGENT, FIX_MERGE_QUEUE) + else -> null + } + + private fun candidateNext(candidate: DeploymentCandidate): List = + when (candidate.state) { + DeploymentCandidateState.CANDIDATE, + DeploymentCandidateState.VALIDATING, + -> listOf(NextAction(ActorKind.WORKFLOW, "validate_candidate")) + DeploymentCandidateState.AWAITING_DEPLOY_APPROVAL -> + if (candidate.deployGateRequired) + listOf( + NextAction( + ActorKind.HUMAN, + "approve_deployment", + "./tools/workflow/bin/workflow gate deploy ${candidate.id}", + ) + ) + else listOf(NextAction(ActorKind.WORKFLOW, "start_canary")) + DeploymentCandidateState.CANARY -> listOf(NextAction(ActorKind.WORKFLOW, "verify_canary")) + DeploymentCandidateState.PRODUCTION -> emptyList() + DeploymentCandidateState.FAILED -> listOf(NextAction(ActorKind.WORKFLOW, "retry_candidate")) + DeploymentCandidateState.NOT_SELECTED -> emptyList() + } + + private fun releaseNext(release: Release): List = + io.springkit.workflow.domain + .nextReleaseAction(release) + ?.let { + listOf(NextAction(it.actor, it.action, it.command)) + } + .orEmpty() + + private fun failure( + error: PortError, + fallback: FailureCode, + target: String? = error.target, + ): WorkflowResult.Failure = + WorkflowResult.Failure( + FailureData( + code = FailureCode.entries.firstOrNull { it.name == error.code } ?: fallback, + message = error.message, + blockedBy = + target?.let { listOf(BlockedBy(error.code, error.message, it)) }.orEmpty(), + ), + ) + + private fun failureCode( + code: FailureCode, + message: String, + target: String? = null, + ): WorkflowResult.Failure = + WorkflowResult.Failure( + FailureData( + code, + message, + target?.let { listOf(BlockedBy(code.name, message, it)) }.orEmpty(), + ) + ) + + private fun invalid(message: String): WorkflowResult.Failure = + failureCode(FailureCode.INVALID_TARGET_SELECTION, message) + + private sealed interface ReadResult { + data class Value(val value: T) : ReadResult + + data class Failure(val result: WorkflowResult.Failure) : ReadResult + } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/application/WorkflowEventUseCases.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/WorkflowEventUseCases.kt new file mode 100644 index 00000000..6aa803b6 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/WorkflowEventUseCases.kt @@ -0,0 +1,158 @@ +package io.springkit.workflow.application + +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.WorkflowResult + +/* + * 외부 이벤트 하나를 처리하는 애플리케이션 handler 계약입니다. + */ +fun interface WorkflowEventHandler { + fun handle(event: ExternalEvent): WorkflowResult<*> +} + +/* + * 수신한 이벤트의 애플리케이션 처리 결과입니다. + */ +data class WorkflowEventResponse( + val event: ExternalEvent, + val duplicate: Boolean = false, +) { + val handled: Boolean + get() = !duplicate +} + +/* + * 외부 이벤트를 수신하고, 한 번만 handler에 전달한 뒤 acknowledge합니다. + */ +class WorkflowEventUseCases( + private val eventPort: WorkflowEventPort, + private val handlers: Map = emptyMap(), +) { + /* + * 외부 이벤트를 처리합니다. + */ + fun receive(request: ReceiveEventRequest): WorkflowResult = + execute(request) + + /* + * 외부 이벤트 어댑터의 수신 계약을 애플리케이션 경계로 연결합니다. + */ + fun handle(request: ReceiveEventRequest): WorkflowResult = execute(request) + + /* + * 외부 이벤트를 멱등적으로 처리하고 처리 결과를 acknowledge합니다. + */ + fun execute(request: ReceiveEventRequest): WorkflowResult { + return when (val received = eventPort.receive(request)) { + is PortResult.Failure -> received.toEventFailure(FailureCode.EXTERNAL_FAILURE) + is PortResult.Success -> process(received.value) + } + } + + private fun process(received: ReceiveEventResponse): WorkflowResult { + val event = received.event + if (!received.accepted) { + return acknowledge( + event = event, + accepted = true, + message = null, + success = WorkflowResult.Success(WorkflowEventResponse(event, duplicate = true)), + ) + } + + val handler = handlers[event.kind] + if (event.kind == ExternalEventKind.AI_REVIEW_CHANGED || handler == null) { + val failure = + FailureData( + code = FailureCode.INVALID_ARGUMENT, + message = "지원하지 않는 외부 이벤트 종류입니다: ${event.kind.name}", + blockedBy = + listOf( + BlockedBy( + code = "UNSUPPORTED_EVENT_KIND", + message = "등록된 이벤트 handler가 없습니다.", + target = event.kind.name, + ) + ), + ) + return acknowledge( + event = event, + accepted = false, + message = failure.message, + success = WorkflowResult.Failure(failure), + ) + } + + val handled = + try { + handler.handle(event) + } catch (failure: RuntimeException) { + WorkflowResult.Failure( + FailureData( + code = FailureCode.EXTERNAL_FAILURE, + message = failure.message ?: "이벤트 handler가 실패했습니다.", + blockedBy = + listOf( + BlockedBy( + code = FailureCode.EXTERNAL_FAILURE.name, + message = failure.message ?: "이벤트 handler가 실패했습니다.", + target = event.id, + ) + ), + ) + ) + } + + return when (handled) { + is WorkflowResult.Success -> + acknowledge( + event = event, + accepted = true, + message = null, + success = WorkflowResult.Success(WorkflowEventResponse(event)), + ) + is WorkflowResult.Failure -> + acknowledge( + event = event, + accepted = false, + message = handled.data.message, + success = handled, + ) + } + } + + private fun acknowledge( + event: ExternalEvent, + accepted: Boolean, + message: String?, + success: WorkflowResult, + ): WorkflowResult { + return when ( + val acknowledged = + eventPort.acknowledge(AcknowledgeEventRequest(event.id, accepted, message)) + ) { + is PortResult.Success -> success + is PortResult.Failure -> acknowledged.toEventFailure(FailureCode.EXTERNAL_FAILURE) + } + } +} + +private fun PortResult.Failure.toEventFailure(fallback: FailureCode): WorkflowResult.Failure { + val code = FailureCode.entries.firstOrNull { it.name == error.code } ?: fallback + return WorkflowResult.Failure( + FailureData( + code = code, + message = error.message, + blockedBy = + listOf( + BlockedBy( + code = error.code, + message = error.message, + target = error.target, + ) + ), + ) + ) +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/application/WorkflowTransaction.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/WorkflowTransaction.kt new file mode 100644 index 00000000..8d638a6a --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/application/WorkflowTransaction.kt @@ -0,0 +1,93 @@ +package io.springkit.workflow.application + +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.WorkflowResult + +data class TransactionMutation( + val data: T, + val snapshot: WorkflowStoreSnapshot, + val changes: List = emptyList(), +) + +/* + * 공급자를 알지 못한 채 상태 저장과 어댑터 변경의 보상을 조정합니다. + * + * 트랜잭션은 저장소가 소유합니다. 애플리케이션이 결과 상태를 저장하지 못하면 공급자 변경을 역순으로 보상합니다. 커밋 실패는 저장소가 이미 커밋했을 수 있으므로 보상하지 않고 + * 트랜잭션 ID로 모호한 결과를 해결합니다. + */ +class WorkflowTransaction( + private val store: WorkflowStorePort, + private val compensation: CompensationPort, +) { + fun execute( + request: StoreTransactionRequest, + operation: (WorkflowStoreSnapshot) -> PortResult>, + ): WorkflowResult { + val begun = store.begin(request) + if (begun is PortResult.Failure) return begun.toWorkflowFailure(FailureCode.STORE_FAILURE) + + val snapshot = + when (val result = store.snapshot(StoreSnapshotRequest())) { + is PortResult.Failure -> { + store.rollback(request) + return result.toWorkflowFailure(FailureCode.STORE_FAILURE) + } + is PortResult.Success -> result.value.snapshot + } + return when (val mutation = operation(snapshot)) { + is PortResult.Failure -> { + mutation.change?.let { compensate(listOf(it), mutation.error.message) } + store.rollback(request) + mutation.toWorkflowFailure(FailureCode.EXTERNAL_FAILURE) + } + is PortResult.Success -> persist(request, mutation.value) + } + } + + private fun persist( + request: StoreTransactionRequest, + mutation: TransactionMutation, + ): WorkflowResult { + val write = + store.write( + StoreWriteRequest( + transactionId = request.transactionId, + expectedRevision = mutation.snapshot.revision, + snapshot = mutation.snapshot, + ) + ) + if (write is PortResult.Failure) { + compensate(mutation.changes, write.error.message) + store.rollback(request) + return write.toWorkflowFailure(FailureCode.STORE_FAILURE) + } + + val committed = store.commit(request) + if (committed is PortResult.Failure) { + return committed.toWorkflowFailure(FailureCode.STORE_FAILURE) + } + return WorkflowResult.Success(mutation.data) + } + + private fun compensate(changes: List, reason: String) { + changes.asReversed().forEach { change -> + if (change.compensation != null) { + compensation.compensate(CompensateRequest(change, reason)) + } + } + } +} + +fun PortResult.Failure.toWorkflowFailure(fallback: FailureCode): WorkflowResult.Failure { + val code = FailureCode.entries.firstOrNull { it.name == error.code } ?: fallback + return WorkflowResult.Failure( + FailureData( + code = code, + message = error.message, + blockedBy = + error.target?.let { listOf(BlockedBy(error.code, error.message, it)) }.orEmpty(), + ) + ) +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/common/ExclusiveFileLock.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/common/ExclusiveFileLock.kt new file mode 100644 index 00000000..27c37336 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/common/ExclusiveFileLock.kt @@ -0,0 +1,52 @@ +package io.springkit.workflow.common + +import java.nio.channels.FileChannel +import java.nio.channels.FileLock +import java.nio.channels.OverlappingFileLockException +import java.nio.file.StandardOpenOption.CREATE +import java.nio.file.StandardOpenOption.WRITE +import okio.Path + +/* + * Okio가 제공하지 않는 프로세스 간 잠금을 감싸는 Java NIO 래퍼입니다. + */ +class ExclusiveFileLock +private constructor( + private val channel: FileChannel, + private val lock: FileLock, +) : AutoCloseable { + override fun close() { + runCatching { lock.release() } + runCatching { channel.close() } + } + + companion object { + fun acquire(path: Path): ExclusiveFileLock = + tryAcquire(path) ?: throw IllegalStateException("file is already locked: $path") + + /* + * 다른 프로세스가 잠금을 보유하고 있으면 `null`을 반환합니다. + */ + fun tryAcquire(path: Path): ExclusiveFileLock? { + path.parent?.toNioPath()?.let { java.nio.file.Files.createDirectories(it) } + val channel = FileChannel.open(path.toNioPath(), CREATE, WRITE) + return try { + val lock = + try { + channel.tryLock() + } catch (_: OverlappingFileLockException) { + null + } + if (lock == null) { + channel.close() + null + } else { + ExclusiveFileLock(channel, lock) + } + } catch (failure: Throwable) { + channel.close() + throw failure + } + } + } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/common/LocalCommandRunner.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/common/LocalCommandRunner.kt new file mode 100644 index 00000000..3dfd3ad2 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/common/LocalCommandRunner.kt @@ -0,0 +1,46 @@ +package io.springkit.workflow.common + +import java.nio.file.Path +import java.util.concurrent.Executors + +/* + * 한 번의 프로세스 실행 결과입니다. + */ +data class CommandResult( + val exitCode: Int, + val stdout: String, + val stderr: String, +) + +/* + * 셸이나 명령별 정책을 추가하지 않고 실행 파일을 실행합니다. + */ +fun interface CommandRunner { + fun run(command: List, workingDirectory: Path): CommandResult +} + +/* + * [ProcessBuilder]를 사용해 [CommandRunner]를 구현합니다. + */ +class LocalCommandRunner : CommandRunner { + override fun run(command: List, workingDirectory: Path): CommandResult { + require(command.isNotEmpty()) { "command must not be empty" } + + val process = + ProcessBuilder(command) + .directory(workingDirectory.toFile()) + .redirectErrorStream(false) + .start() + val executor = Executors.newFixedThreadPool(2) + return try { + val stdout = + executor.submit { process.inputStream.bufferedReader().use { it.readText() } } + val stderr = + executor.submit { process.errorStream.bufferedReader().use { it.readText() } } + val exitCode = process.waitFor() + CommandResult(exitCode, stdout.get(), stderr.get()) + } finally { + executor.shutdown() + } + } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/common/ManagedPathResolver.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/common/ManagedPathResolver.kt new file mode 100644 index 00000000..57fc18f2 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/common/ManagedPathResolver.kt @@ -0,0 +1,42 @@ +package io.springkit.workflow.common + +import java.nio.file.Files +import java.nio.file.Path +import java.util.ArrayDeque + +/* + * 관리 root 경계 검사를 위해 파일 시스템 경로를 실제 경로 기준으로 해석합니다. + */ +object ManagedPathResolver { + /* + * 존재하는 가장 가까운 조상 경로를 실제 경로로 해석한 뒤, 존재하지 않는 suffix를 붙입니다. + */ + fun canonicalize(path: Path): Path { + val normalized = path.toAbsolutePath().normalize() + val suffix = ArrayDeque() + var existing = normalized + while (!Files.exists(existing)) { + val name = existing.fileName ?: return normalized + suffix.addFirst(name) + existing = existing.parent ?: return normalized + } + val canonicalExisting = + runCatching { existing.toRealPath() } + .getOrElse { + return normalized + } + return suffix.fold(canonicalExisting) { current, name -> current.resolve(name) }.normalize() + } + + /* + * 경로를 managed root 기준으로 해석하고 root 밖이면 null을 반환합니다. + */ + fun resolveWithin(root: Path, path: Path): Path? { + val canonicalRoot = canonicalize(root) + val candidate = if (path.isAbsolute) path else canonicalRoot.resolve(path) + val canonicalCandidate = canonicalize(candidate) + return canonicalCandidate.takeIf { + it == canonicalRoot || it.startsWith(canonicalRoot) + } + } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Deployment.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Deployment.kt new file mode 100644 index 00000000..71ce0045 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Deployment.kt @@ -0,0 +1,106 @@ +package io.springkit.workflow.domain + +import kotlinx.serialization.Serializable + +@Serializable +enum class DeploymentCandidateState { + NOT_SELECTED, + CANDIDATE, + VALIDATING, + AWAITING_DEPLOY_APPROVAL, + CANARY, + PRODUCTION, + FAILED, +} + +@Serializable +enum class ReleaseState { + NOT_APPLICABLE, + SAFE_DEFAULT, + INTERNAL_VALIDATION, + AWAITING_RELEASE_APPROVAL, + ROLLOUT, + RELEASED, + CLEANUP_REQUIRED, +} + +@Serializable +data class DeploymentCandidate( + val id: CandidateId, + val mainRevision: MainRevision, + val includedSubTasks: List, + val risks: Map = emptyMap(), + val validations: List = emptyList(), + val state: DeploymentCandidateState = DeploymentCandidateState.CANDIDATE, +) { + init { + require(id.isNotBlank()) { "deployment candidate id must not be blank" } + require(mainRevision.isNotBlank()) { "deployment candidate main revision must not be blank" } + require(includedSubTasks.distinct().size == includedSubTasks.size) { + "deployment candidate subtasks must be unique" + } + require(risks.keys.all { it in includedSubTasks }) { + "candidate risk must reference included subtasks" + } + require( + state != DeploymentCandidateState.PRODUCTION || requiredValidationsPassed(validations) + ) { + "production candidate must pass required validations" + } + } + + val risk: Risk + get() = candidateRisk(risks.values) + + val deployGateRequired: Boolean + get() = risk == Risk.HIGH && state == DeploymentCandidateState.AWAITING_DEPLOY_APPROVAL +} + +@Serializable +data class Release( + val id: ReleaseId, + val candidateId: CandidateId, + val state: ReleaseState = ReleaseState.SAFE_DEFAULT, + val productionReady: Boolean = false, + val internalValidationPassed: Boolean = false, + val cleanupSubTaskId: SubTaskId? = null, +) { + init { + require(id.isNotBlank()) { "release id must not be blank" } + require(candidateId.isNotBlank()) { "release candidate id must not be blank" } + } +} + +fun candidateRisk(risks: Iterable): Risk = + if (risks.any { it == Risk.HIGH }) Risk.HIGH else Risk.NORMAL + +fun candidateRiskForSubTasks(subTasks: Iterable): Risk = + candidateRisk(subTasks.map { it.risk }) + +fun nextReleaseAction(release: Release): NextAction? = + when (release.state) { + ReleaseState.NOT_APPLICABLE, + ReleaseState.RELEASED, + -> null + ReleaseState.SAFE_DEFAULT, + ReleaseState.INTERNAL_VALIDATION, + -> + if (release.productionReady && release.internalValidationPassed) { + NextAction( + ActorKind.HUMAN, + "approve_release", + "./tools/workflow/bin/workflow gate release ${release.id}", + ) + } else { + NextAction(ActorKind.WORKFLOW, "validate_release", null) + } + ReleaseState.AWAITING_RELEASE_APPROVAL -> + NextAction( + ActorKind.HUMAN, + "approve_release", + "./tools/workflow/bin/workflow gate release ${release.id}", + ) + ReleaseState.ROLLOUT -> NextAction(ActorKind.WORKFLOW, "continue_rollout", null) + ReleaseState.CLEANUP_REQUIRED -> + NextAction(ActorKind.WORKFLOW, "create_cleanup_subtask", null) + } diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Identifiers.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Identifiers.kt new file mode 100644 index 00000000..94fc7a0c --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Identifiers.kt @@ -0,0 +1,48 @@ +package io.springkit.workflow.domain + +import kotlinx.serialization.Serializable + +/* + * 도메인 식별자는 의도적으로 문자열로 표현합니다. 별칭을 사용해 저장소나 제공자 식별자 타입과 도메인을 분리하고 호출부에서 각 필드의 의미를 명확히 합니다. + */ +typealias TaskId = String + +typealias SubTaskId = String + +typealias WorkspaceId = String + +typealias BranchName = String + +typealias PullRequestId = String + +typealias ReviewRevisionId = String + +typealias ChangeRevisionId = String + +typealias DiffIdentity = String + +typealias ThreadId = String + +typealias CheckId = String + +typealias ValidationId = String + +typealias CandidateId = String + +typealias ReleaseId = String + +typealias MainRevision = String + +@Serializable +data class ExternalTaskId(val value: String) { + init { + require(value.isNotBlank()) { "external task id must not be blank" } + } +} + +@Serializable +data class WorkspacePath(val value: String) { + init { + require(value.isNotBlank()) { "workspace path must not be blank" } + } +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Integration.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Integration.kt new file mode 100644 index 00000000..64d58f7b --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Integration.kt @@ -0,0 +1,79 @@ +package io.springkit.workflow.domain + +import kotlinx.serialization.Serializable + +@Serializable +enum class MergeQueueState { + NOT_QUEUED, + QUEUED, + VALIDATING, + PASSED, + FAILED, + MERGED, +} + +@Serializable +enum class IntegrationState { + NOT_MERGED, + QUEUED, + VALIDATING, + MERGED, +} + +@Serializable +data class MergeQueueEntry( + val id: String, + val subTaskId: SubTaskId, + val pullRequestId: PullRequestId, + val changeRevisionId: ChangeRevisionId, + val state: MergeQueueState = MergeQueueState.QUEUED, + val validations: List = emptyList(), + /* + * Queue 등록 시점의 provider head revision입니다. + */ + val providerRevision: String? = null, +) { + init { + require(id.isNotBlank()) { "merge queue id must not be blank" } + require(subTaskId.isNotBlank()) { "merge queue subtask id must not be blank" } + require(pullRequestId.isNotBlank()) { "merge queue pull request id must not be blank" } + require(changeRevisionId.isNotBlank()) { "merge queue change revision must not be blank" } + require(providerRevision == null || providerRevision.isNotBlank()) { + "merge queue provider revision must not be blank" + } + } +} + +@Serializable +data class Integration( + val subTaskId: SubTaskId, + val state: IntegrationState = IntegrationState.NOT_MERGED, + val mergeQueue: MergeQueueEntry? = null, + val mainRevision: MainRevision? = null, + val squashCommit: String? = null, +) { + init { + require(subTaskId.isNotBlank()) { "integration subtask id must not be blank" } + require(state != IntegrationState.MERGED || !mainRevision.isNullOrBlank()) { + "merged integration must have a main revision" + } + } +} + +data class MergeQueueCondition( + val ready: Boolean, + val approvalApplicable: Boolean, + val requiredCiPassed: Boolean, + val requiredValidationsPassed: Boolean, + val openRequiredThreads: Boolean, + val dependencyIntegrated: Boolean, +) { + val canQueue: Boolean + get() = + ready && + approvalApplicable && + requiredCiPassed && + requiredValidationsPassed && + !openRequiredThreads && + dependencyIntegrated +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Review.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Review.kt new file mode 100644 index 00000000..04e15a5c --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Review.kt @@ -0,0 +1,223 @@ +package io.springkit.workflow.domain + +import kotlinx.serialization.Serializable + +@Serializable +enum class Risk { + NORMAL, + HIGH, +} + +@Serializable +enum class PullRequestState { + DRAFT, + READY, + REVIEW, + APPROVED, + BLOCKED, + QUEUED, + MERGED, + CLOSED, +} + +@Serializable +enum class ReviewLevel { + R, + C, + A, +} + +@Serializable +enum class ThreadState { + OPEN, + RESOLVED, +} + +@Serializable +enum class ActorKind { + HUMAN, + AGENT, + WORKFLOW, +} + +@Serializable +data class Actor( + val id: String, + val kind: ActorKind, + val name: String? = null, +) { + init { + require(id.isNotBlank()) { "actor id must not be blank" } + } + + val isHuman: Boolean + get() = kind == ActorKind.HUMAN +} + +@Serializable +data class ReviewComment( + val id: String, + val author: Actor, + val body: String, + val createdAtEpochMillis: Long = 0, + val path: String? = null, + val line: Int? = null, +) { + init { + require(id.isNotBlank()) { "comment id must not be blank" } + require(body.isNotBlank()) { "comment body must not be blank" } + require((path == null) == (line == null)) { "path and line must be supplied together" } + require(line == null || line > 0) { "comment line must be positive" } + } +} + +@Serializable +data class ReviewThread( + val id: ThreadId, + val level: ReviewLevel, + val comments: List, + val state: ThreadState = ThreadState.OPEN, + val requiresHumanResolution: Boolean = comments.any { it.author.isHuman }, +) { + init { + require(id.isNotBlank()) { "thread id must not be blank" } + require(comments.isNotEmpty()) { "thread must have at least one comment" } + require(comments.map { it.id }.distinct().size == comments.size) { + "thread comments must be unique" + } + } + + val isOpen: Boolean + get() = state == ThreadState.OPEN + + val isBlocking: Boolean + get() = isOpen && level == ReviewLevel.R + + fun reply(comment: ReviewComment): ReviewThread = + copy( + comments = comments + comment, + requiresHumanResolution = requiresHumanResolution || comment.author.isHuman, + ) + + fun resolve(actor: Actor): ReviewThread { + require(!requiresHumanResolution || actor.isHuman) { + "human review thread requires a human resolver" + } + return copy(state = ThreadState.RESOLVED) + } +} + +@Serializable +data class Diff( + val identity: DiffIdentity, + val files: List = emptyList(), + val additions: Int = 0, + val deletions: Int = 0, +) { + init { + require(identity.isNotBlank()) { "diff identity must not be blank" } + require(additions >= 0) { "diff additions must not be negative" } + require(deletions >= 0) { "diff deletions must not be negative" } + } +} + +@Serializable +data class ReviewRevision( + val id: ReviewRevisionId, + val number: Long, + val body: String, + val threads: List = emptyList(), + val createdAtEpochMillis: Long = 0, +) { + init { + require(id.isNotBlank()) { "review revision id must not be blank" } + require(number > 0) { "review revision number must be positive" } + require(body.isNotBlank()) { "review body must not be blank" } + require(threads.map { it.id }.distinct().size == threads.size) { + "review thread ids must be unique" + } + } + + val openThreads: List + get() = threads.filter { it.isOpen } + + val openRequiredThreads: List + get() = openThreads.filter { it.level == ReviewLevel.R } +} + +@Serializable +data class ChangeRevision( + val id: ChangeRevisionId, + val number: Long, + val diff: Diff, + val createdAtEpochMillis: Long = 0, + /* + * 외부 Review provider가 부여한 소스 커밋 revision입니다. + */ + val providerRevision: String? = null, +) { + init { + require(id.isNotBlank()) { "change revision id must not be blank" } + require(number > 0) { "change revision number must be positive" } + require(providerRevision == null || providerRevision.isNotBlank()) { + "provider revision must not be blank" + } + } +} + +@Serializable +data class PullRequest( + val id: PullRequestId, + val subTaskId: SubTaskId, + val title: String, + val body: String, + val base: BranchName, + val state: PullRequestState = PullRequestState.DRAFT, + val risk: Risk = Risk.NORMAL, + val reviewRevision: ReviewRevision, + val changeRevision: ChangeRevision, + val approval: Approval? = null, + val ci: CiStatus = CiStatus.PENDING, + val aiReview: AiReviewStatus = AiReviewStatus.PENDING, +) { + init { + require(id.isNotBlank()) { "pull request id must not be blank" } + require(subTaskId.isNotBlank()) { "pull request subtask id must not be blank" } + require(title.isNotBlank()) { "pull request title must not be blank" } + require(body.isNotBlank()) { "pull request body must not be blank" } + require(base.isNotBlank()) { "pull request base must not be blank" } + } +} + +@Serializable +data class Approval( + val id: String, + val actor: Actor, + val changeRevisionId: ChangeRevisionId, + val diffIdentity: DiffIdentity, + val approvedAtEpochMillis: Long = 0, + val active: Boolean = true, +) { + init { + require(id.isNotBlank()) { "approval id must not be blank" } + require(actor.isHuman) { "only a human can approve a change" } + require(changeRevisionId.isNotBlank()) { "approval change revision must not be blank" } + require(diffIdentity.isNotBlank()) { "approval diff identity must not be blank" } + } +} + +@Serializable +enum class CiStatus { + PENDING, + RUNNING, + PASSED, + FAILED, +} + +@Serializable +enum class AiReviewStatus { + PENDING, + RUNNING, + PASSED, + FAILED, +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/ReviewBodyTemplate.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/ReviewBodyTemplate.kt new file mode 100644 index 00000000..c17501f4 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/ReviewBodyTemplate.kt @@ -0,0 +1,77 @@ +package io.springkit.workflow.domain + +/* + * PR 본문에 필요한 Markdown 제목과 순서를 검증합니다. + */ +object ReviewBodyTemplate { + private val requiredHeadings = + listOf( + "해결하려는 문제", + "왜 지금 해결해야 하는가", + "어떻게 해결했는가", + "한계와 트레이드오프", + "기존 기능에 미치는 영향", + "Edge Case와 실패 시나리오", + "검토한 대안과 선택 이유", + "리뷰 포인트", + ) + + /* + * 첫 번째 누락 또는 순서 오류를 설명하며, 계약을 만족하면 null을 반환합니다. + */ + fun validate(body: String): String? { + var fence: CodeFence? = null + val headings = + body + .lineSequence() + .mapNotNull { line -> + val currentFence = fence + if (currentFence != null) { + if (line.closes(currentFence)) fence = null + null + } else { + fence = line.opensFence() + if (fence == null) heading(line) else null + } + } + .toList() + var nextIndex = 0 + + requiredHeadings.forEachIndexed { requiredIndex, requiredHeading -> + val matchIndex = + (nextIndex until headings.size).firstOrNull { headings[it] == requiredHeading } ?: -1 + if (matchIndex >= 0) { + nextIndex = matchIndex + 1 + } else { + val previousHeading = requiredHeadings.getOrNull(requiredIndex - 1) + val appearsEarlier = headings.indexOf(requiredHeading) >= 0 + return if (appearsEarlier && previousHeading != null) { + "PR 본문 제목 순서가 잘못되었습니다. '$previousHeading' 다음에 '$requiredHeading' 제목이 있어야 합니다." + } else { + "PR 본문에 '$requiredHeading' 제목이 없습니다." + } + } + } + + return null + } + + private fun heading(line: String): String? { + val match = Regex("^ {0,3}#{1,6}[ \\t]+(.+?)[ \\t]*$").matchEntire(line) ?: return null + return match.groupValues[1].replace(Regex("[ \\t]+#+[ \\t]*$"), "").trim() + } + + private fun String.opensFence(): CodeFence? { + val match = Regex("^ {0,3}(`{3,}|~{3,}).*$").matchEntire(this) ?: return null + val marker = match.groupValues[1] + return CodeFence(marker.first(), marker.length) + } + + private fun String.closes(fence: CodeFence): Boolean { + val match = Regex("^ {0,3}([`~]+)[ \\t]*$").matchEntire(this) ?: return false + val marker = match.groupValues[1] + return marker.all { it == fence.character } && marker.length >= fence.length + } + + private data class CodeFence(val character: Char, val length: Int) +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Validation.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Validation.kt new file mode 100644 index 00000000..329bee1e --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/Validation.kt @@ -0,0 +1,97 @@ +package io.springkit.workflow.domain + +import kotlinx.serialization.Serializable + +@Serializable +enum class ValidationStatus { + PENDING, + RUNNING, + PASSED, + FAILED, + SKIPPED, +} + +@Serializable +data class Validation( + val id: ValidationId, + val name: String, + val status: ValidationStatus, + val required: Boolean = true, + val revision: String? = null, + val message: String? = null, +) { + init { + require(id.isNotBlank()) { "validation id must not be blank" } + require(name.isNotBlank()) { "validation name must not be blank" } + require(status != ValidationStatus.FAILED || !message.isNullOrBlank()) { + "failed validation must have a message" + } + } + + val passed: Boolean + get() = status == ValidationStatus.PASSED +} + +@Serializable +data class CheckResult( + val id: CheckId, + val name: String, + val status: ValidationStatus, + val fingerprint: String, + val revision: String, + val message: String? = null, +) { + init { + require(id.isNotBlank()) { "check id must not be blank" } + require(name.isNotBlank()) { "check name must not be blank" } + require(fingerprint.isNotBlank()) { "check fingerprint must not be blank" } + require(revision.isNotBlank()) { "check revision must not be blank" } + require(status != ValidationStatus.FAILED || !message.isNullOrBlank()) { + "failed check must have a message" + } + } +} + +@Serializable +data class CheckSummary( + val fingerprint: String, + val revision: String, + val checks: List, +) { + init { + require(fingerprint.isNotBlank()) { "check summary fingerprint must not be blank" } + require(revision.isNotBlank()) { "check summary revision must not be blank" } + require(checks.map { it.id }.distinct().size == checks.size) { "check ids must be unique" } + } + + val passed: Boolean + get() = checks.isNotEmpty() && checks.all { it.status == ValidationStatus.PASSED } + + fun appliesTo(currentFingerprint: String, currentRevision: String): Boolean = + fingerprint == currentFingerprint && revision == currentRevision +} + +data class CiRun( + val id: String, + val status: CiStatus, + val required: Boolean = true, + val revision: String? = null, + val message: String? = null, +) { + init { + require(id.isNotBlank()) { "ci run id must not be blank" } + require(status != CiStatus.FAILED || !message.isNullOrBlank()) { + "failed CI must have a message" + } + } +} + +fun requiredValidationsPassed(validations: Iterable): Boolean = + validations.filter { it.required }.all { it.status == ValidationStatus.PASSED } + +fun checksPassedFor(checks: Iterable, fingerprint: String, revision: String): Boolean = + checks.none { + it.fingerprint == fingerprint && + it.revision == revision && + it.status != ValidationStatus.PASSED + } && checks.any { it.fingerprint == fingerprint && it.revision == revision } diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/WorkItems.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/WorkItems.kt new file mode 100644 index 00000000..5471b09a --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/WorkItems.kt @@ -0,0 +1,141 @@ +package io.springkit.workflow.domain + +import kotlinx.serialization.Serializable + +@Serializable +enum class SubTaskState { + DEVELOPMENT, + DRAFT, + READY, + REVIEW, + APPROVED, + BLOCKED, + QUEUED, + MERGED, +} + +@Serializable +enum class TaskState { + OPEN, + IN_PROGRESS, + COMPLETED, + CANCELLED, +} + +@Serializable +data class Task( + val id: TaskId, + val externalId: ExternalTaskId, + val title: String, + val state: TaskState = TaskState.OPEN, + val subTaskIds: List = emptyList(), +) { + init { + require(id.isNotBlank()) { "task id must not be blank" } + require(title.isNotBlank()) { "task title must not be blank" } + require(subTaskIds.distinct().size == subTaskIds.size) { "task subtask ids must be unique" } + } +} + +/* + * Merge 이후 SubTask 정리 진행 상태입니다. + */ +@Serializable +enum class SubTaskCleanupState { + ACTIVE, + REMOTE_BRANCH_REMOVED, + WORKTREE_REMOVED, + LOCAL_BRANCH_REMOVED, + COMPLETED, +} + +@Serializable +data class Workspace( + val id: WorkspaceId, + val subTaskId: SubTaskId, + val path: WorkspacePath, + val branch: BranchName, + val managed: Boolean = true, + val dirty: Boolean = false, +) { + init { + require(id.isNotBlank()) { "workspace id must not be blank" } + require(subTaskId.isNotBlank()) { "workspace subtask id must not be blank" } + require(branch.isNotBlank()) { "workspace branch must not be blank" } + } +} + +data class Dependency( + val subTaskId: SubTaskId, + val requires: SubTaskId, +) { + init { + require(subTaskId.isNotBlank()) { "dependency subtask id must not be blank" } + require(requires.isNotBlank()) { "dependency target must not be blank" } + require(subTaskId != requires) { "a subtask cannot require itself" } + } +} + +data class TaskGraph( + val subTasks: List = emptyList(), + val dependencies: List = emptyList(), +) { + init { + val ids = subTasks.map { it.id } + require(ids.distinct().size == ids.size) { "task graph subtask ids must be unique" } + require(dependencies.distinct().size == dependencies.size) { + "task graph dependencies must be unique" + } + require(dependencies.groupingBy { it.subTaskId }.eachCount().values.all { it == 1 }) { + "a subtask can have at most one direct dependency" + } + require(dependencies.all { it.subTaskId in ids && it.requires in ids }) { + "task graph dependencies must reference known subtasks" + } + require(!hasDependencyCycle(dependencies)) { "task graph must not contain a dependency cycle" } + } + + fun withDependency(dependency: Dependency): TaskGraph = + copy( + dependencies = + dependencies.filterNot { it.subTaskId == dependency.subTaskId } + dependency + ) + + fun withoutDependency(subTaskId: SubTaskId): TaskGraph = + copy(dependencies = dependencies.filterNot { it.subTaskId == subTaskId }) +} + +@Serializable +data class SubTask( + val id: SubTaskId, + val taskId: TaskId, + val title: String, + val state: SubTaskState = SubTaskState.DEVELOPMENT, + val branch: BranchName = id, + val workspace: Workspace? = null, + val requires: SubTaskId? = null, + val pullRequestId: PullRequestId? = null, + val risk: Risk = Risk.NORMAL, + /* + * Merge 이후 정리 과정에서 마지막으로 저장한 상태입니다. + */ + val cleanupState: SubTaskCleanupState = SubTaskCleanupState.ACTIVE, + /* + * Merge 이후 정리 재시도에서 사용할 검증된 Git commit SHA입니다. + */ + val cleanupRevision: String? = null, +) { + init { + require(id.isNotBlank()) { "subtask id must not be blank" } + require(taskId.isNotBlank()) { "subtask task id must not be blank" } + require(title.isNotBlank()) { "subtask title must not be blank" } + require(branch == id) { "subtask branch must equal subtask id" } + require(cleanupRevision == null || cleanupRevision.isNotBlank()) { + "cleanup revision must not be blank" + } + } +} + +fun SubTask.withState(state: SubTaskState): SubTask = copy(state = state) + +fun SubTask.withDependency(requires: SubTaskId?): SubTask = copy(requires = requires) diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/WorkflowResult.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/WorkflowResult.kt new file mode 100644 index 00000000..4bc89643 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/WorkflowResult.kt @@ -0,0 +1,234 @@ +package io.springkit.workflow.domain + +import kotlinx.serialization.Serializable + +enum class FailureCode { + UNKNOWN_COMMAND, + INVALID_ARGUMENT, + INVALID_TARGET_SELECTION, + INVARIANT_VIOLATION, + STATE_CONFLICT, + STORE_FAILURE, + EXTERNAL_FAILURE, + STALE_REVISION, + STALE_DIFF_IDENTITY, + HUMAN_REQUIRED, + HUMAN_APPROVAL_REQUIRED, + HUMAN_REVIEW_REQUIRED, + DEPENDENCY_CYCLE, + DEPENDENCY_NOT_FOUND, + DEPENDENCY_NOT_MERGED, + WORKTREE_REQUIRED, + WORKTREE_DIRTY, + TASK_NOT_FOUND, + SUBTASK_NOT_FOUND, + WORKSPACE_NOT_FOUND, + IDEMPOTENCY_CONFLICT, + REVIEW_NOT_FOUND, + REVIEW_ALREADY_OPEN, + REVIEW_REVISION_MISSING, + THREAD_NOT_FOUND, + REQUIRED_THREAD_OPEN, + INVALID_GATE_STATE, + VALIDATION_REQUIRED, + CHECK_NOT_PASSED, + CI_NOT_PASSED, + AI_REVIEW_FAILED, + SYNC_CONFLICT, + MERGE_QUEUE_FAILED, + DEPLOYMENT_NOT_FOUND, + DEPLOYMENT_GATE_BLOCKED, + RELEASE_NOT_FOUND, + RELEASE_GATE_BLOCKED, +} + +data class BlockedBy( + val code: String, + val message: String, + val target: String? = null, +) + +data class NextAction( + val actor: ActorKind, + val action: String, + val command: String? = null, +) + +/* + * 실패한 명령이 관리하던 Worktree의 최소 식별 정보입니다. + */ +data class FailureWorkspace( + val path: WorkspacePath, +) + +/* + * 실패 원인으로 보고하는 충돌 파일 정보입니다. + */ +data class FailureConflict( + val path: String, + val kind: String = "content", +) { + init { + require(path.isNotBlank()) { "failure conflict path must not be blank" } + require(kind.isNotBlank()) { "failure conflict kind must not be blank" } + } +} + +data class FailureData( + val code: FailureCode, + val message: String, + val blockedBy: List = emptyList(), + val next: List = emptyList(), + val workspace: FailureWorkspace? = null, + val conflicts: List = emptyList(), +) + +sealed interface WorkflowResult { + data class Success( + val data: T, + val next: List = emptyList(), + ) : WorkflowResult + + data class Failure( + val data: FailureData, + ) : WorkflowResult +} + +@Serializable +sealed interface DomainEvent { + val targetId: String + val occurredAtEpochMillis: Long +} + +@Serializable +data class SubTaskStarted( + override val targetId: SubTaskId, + val taskId: TaskId, + override val occurredAtEpochMillis: Long = 0, +) : DomainEvent + +@Serializable +data class ReviewOpened( + override val targetId: PullRequestId, + val subTaskId: SubTaskId, + val reviewRevisionId: ReviewRevisionId, + val changeRevisionId: ChangeRevisionId, + override val occurredAtEpochMillis: Long = 0, +) : DomainEvent + +@Serializable +data class ReviewChanged( + override val targetId: PullRequestId, + val reviewRevisionId: ReviewRevisionId, + val changeRevisionId: ChangeRevisionId?, + val diffChanged: Boolean, + override val occurredAtEpochMillis: Long = 0, +) : DomainEvent + +@Serializable +data class GateRecorded( + override val targetId: String, + val gate: GateType, + val actor: Actor, + override val occurredAtEpochMillis: Long = 0, +) : DomainEvent + +@Serializable +data class MergeRecorded( + override val targetId: SubTaskId, + val mainRevision: MainRevision, + override val occurredAtEpochMillis: Long = 0, +) : DomainEvent + +@Serializable +data class DeploymentRecorded( + override val targetId: CandidateId, + val state: DeploymentCandidateState, + override val occurredAtEpochMillis: Long = 0, +) : DomainEvent + +@Serializable +data class ReleaseRecorded( + override val targetId: ReleaseId, + val state: ReleaseState, + override val occurredAtEpochMillis: Long = 0, +) : DomainEvent + +@Serializable +data class AuditEntry( + val id: String, + val actor: Actor, + val action: String, + val targetId: String, + val reviewRevisionId: ReviewRevisionId? = null, + val changeRevisionId: ChangeRevisionId? = null, + val mainRevision: MainRevision? = null, + val occurredAtEpochMillis: Long = 0, +) { + init { + require(id.isNotBlank()) { "audit id must not be blank" } + require(action.isNotBlank()) { "audit action must not be blank" } + require(targetId.isNotBlank()) { "audit target id must not be blank" } + } +} + +@Serializable +data class WorkflowCounters( + val startedSubTasks: Int = 0, + val openedReviews: Int = 0, + val changedReviews: Int = 0, + val recordedGates: Int = 0, + val mergedSubTasks: Int = 0, + val deploymentCandidates: Int = 0, + val releases: Int = 0, +) { + init { + require( + listOf( + startedSubTasks, + openedReviews, + changedReviews, + recordedGates, + mergedSubTasks, + deploymentCandidates, + releases, + ) + .all { it >= 0 } + ) { + "workflow counters must not be negative" + } + } + + fun record(event: DomainEvent): WorkflowCounters = + when (event) { + is SubTaskStarted -> copy(startedSubTasks = startedSubTasks + 1) + is ReviewOpened -> copy(openedReviews = openedReviews + 1) + is ReviewChanged -> copy(changedReviews = changedReviews + 1) + is GateRecorded -> copy(recordedGates = recordedGates + 1) + is MergeRecorded -> copy(mergedSubTasks = mergedSubTasks + 1) + is DeploymentRecorded -> copy(deploymentCandidates = deploymentCandidates + 1) + is ReleaseRecorded -> copy(releases = releases + 1) + } +} + +@Serializable +data class EventLog( + val events: List = emptyList(), + val audits: List = emptyList(), + val counters: WorkflowCounters = WorkflowCounters(), +) { + fun append(event: DomainEvent, audit: AuditEntry? = null): EventLog = + copy( + events = events + event, + audits = if (audit == null) audits else audits + audit, + counters = counters.record(event), + ) +} + +@Serializable +enum class GateType { + READY, + APPROVE, + DEPLOY, + RELEASE, +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/WorkflowRules.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/WorkflowRules.kt new file mode 100644 index 00000000..27636e7a --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/WorkflowRules.kt @@ -0,0 +1,163 @@ +package io.springkit.workflow.domain + +/* + * Application 유스케이스에서 사용하는 순수 정책 함수입니다. + */ +object WorkflowRules { + fun hasOpenRequiredThread(threads: Iterable): Boolean = threads.any { + it.isOpen && it.level == ReviewLevel.R + } + + fun openRequiredThreads(threads: Iterable): List = threads.filter { + it.isOpen && it.level == ReviewLevel.R + } + + fun approvalApplies(approval: Approval?, changeRevision: ChangeRevision): Boolean = + approval != null && + approval.active && + approval.actor.isHuman && + approval.changeRevisionId == changeRevision.id && + approval.diffIdentity == changeRevision.diff.identity + + fun approvalApplies( + approval: Approval?, + changeRevisionId: ChangeRevisionId, + diffIdentity: DiffIdentity, + ): Boolean = + approval != null && + approval.active && + approval.actor.isHuman && + approval.changeRevisionId == changeRevisionId && + approval.diffIdentity == diffIdentity + + fun dependencyCycle(dependencies: Iterable): List { + val graph = dependencies.groupBy({ it.subTaskId }, { it.requires }) + val visiting = mutableSetOf() + val visited = mutableSetOf() + val path = mutableListOf() + + fun visit(node: SubTaskId): List? { + if (node in visiting) { + val index = path.indexOf(node) + return (if (index >= 0) path.subList(index, path.size) else path).toList() + node + } + if (!visited.add(node)) return null + visiting += node + path += node + for (next in graph[node].orEmpty()) { + val cycle = visit(next) + if (cycle != null) return cycle + } + path.removeAt(path.lastIndex) + visiting -= node + return null + } + + return graph.keys.firstNotNullOfOrNull(::visit).orEmpty() + } + + fun hasDependencyCycle(dependencies: Iterable): Boolean = + dependencyCycle(dependencies).isNotEmpty() + + fun canEnterMergeQueue( + condition: MergeQueueCondition, + ): Boolean = condition.canQueue + + fun canEnterMergeQueue( + pullRequest: PullRequest, + requiredValidations: Iterable, + dependencyIntegrated: Boolean, + ): Boolean { + val condition = + MergeQueueCondition( + ready = pullRequest.state in setOf(PullRequestState.READY, PullRequestState.REVIEW), + approvalApplicable = approvalApplies(pullRequest.approval, pullRequest.changeRevision), + requiredCiPassed = pullRequest.ci == CiStatus.PASSED, + requiredValidationsPassed = requiredValidationsPassed(requiredValidations), + openRequiredThreads = hasOpenRequiredThread(pullRequest.reviewRevision.threads), + dependencyIntegrated = dependencyIntegrated, + ) + return canEnterMergeQueue(condition) + } + + fun humanGateCondition( + gate: GateType, + actor: Actor, + pullRequest: PullRequest? = null, + candidate: DeploymentCandidate? = null, + release: Release? = null, + ): FailureData? { + if (!actor.isHuman) { + return FailureData( + code = FailureCode.HUMAN_REQUIRED, + message = "${gate.name.lowercase()} gate requires a human actor", + blockedBy = listOf(BlockedBy("HUMAN_REQUIRED", "a human decision is required")), + ) + } + return when (gate) { + GateType.READY -> + if (pullRequest == null || pullRequest.state != PullRequestState.DRAFT) { + FailureData(FailureCode.STATE_CONFLICT, "ready gate requires a draft pull request") + } else null + GateType.APPROVE -> + if ( + pullRequest == null || + pullRequest.state !in setOf(PullRequestState.READY, PullRequestState.REVIEW) + ) { + FailureData( + FailureCode.STATE_CONFLICT, + "approve gate requires a reviewable pull request", + ) + } else null + GateType.DEPLOY -> + if ( + candidate == null || + candidate.risk != Risk.HIGH || + candidate.state != DeploymentCandidateState.AWAITING_DEPLOY_APPROVAL + ) { + FailureData( + FailureCode.STATE_CONFLICT, + "deploy gate requires a high-risk candidate awaiting approval", + ) + } else null + GateType.RELEASE -> + if ( + release == null || + !release.productionReady || + !release.internalValidationPassed || + release.state != ReleaseState.AWAITING_RELEASE_APPROVAL + ) { + FailureData( + FailureCode.STATE_CONFLICT, + "release gate requires production and internal validation", + ) + } else null + } + } + + fun nextReleaseAction(release: Release): NextAction? = + io.springkit.workflow.domain.nextReleaseAction(release) +} + +fun hasOpenRequiredThread(threads: Iterable): Boolean = + WorkflowRules.hasOpenRequiredThread(threads) + +fun approvalApplies(approval: Approval?, changeRevision: ChangeRevision): Boolean = + WorkflowRules.approvalApplies(approval, changeRevision) + +fun dependencyCycle(dependencies: Iterable): List = + WorkflowRules.dependencyCycle(dependencies) + +fun hasDependencyCycle(dependencies: Iterable): Boolean = + WorkflowRules.hasDependencyCycle(dependencies) + +fun canEnterMergeQueue(condition: MergeQueueCondition): Boolean = + WorkflowRules.canEnterMergeQueue(condition) + +fun humanGateCondition( + gate: GateType, + actor: Actor, + pullRequest: PullRequest? = null, + candidate: DeploymentCandidate? = null, + release: Release? = null, +): FailureData? = WorkflowRules.humanGateCondition(gate, actor, pullRequest, candidate, release) diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/WorkflowState.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/WorkflowState.kt new file mode 100644 index 00000000..7291b509 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/domain/WorkflowState.kt @@ -0,0 +1,203 @@ +package io.springkit.workflow.domain + +import kotlinx.serialization.Serializable + +@Serializable +data class StartRequestKey( + val taskId: TaskId, + val requestId: String, +) { + init { + require(taskId.isNotBlank()) { "start request task id must not be blank" } + require(requestId.isNotBlank()) { "start request id must not be blank" } + } +} + +@Serializable +data class StartRequestRecord( + val key: StartRequestKey, + val subTaskId: SubTaskId, + val title: String, + val requires: SubTaskId? = null, +) { + init { + require(subTaskId.isNotBlank()) { "start request subtask id must not be blank" } + require(title.isNotBlank()) { "start request title must not be blank" } + } +} + +@Serializable +data class SyncConflict( + val subTaskId: SubTaskId, + val before: SubTask, + val conflictPaths: List, + val startedAtEpochMillis: Long, +) { + init { + require(subTaskId == before.id) { "sync snapshot must belong to the target subtask" } + require(conflictPaths.isNotEmpty()) { "sync conflict must identify at least one path" } + require(conflictPaths.all { it.isNotBlank() }) { "sync conflict paths must not be blank" } + require(conflictPaths.distinct().size == conflictPaths.size) { + "sync conflict paths must be unique" + } + } +} + +@Serializable +data class IdSequence( + val subTask: Long = 100, + val review: Long = 0, + val change: Long = 0, + val thread: Long = 0, + val comment: Long = 0, + val approval: Long = 0, + val mergeQueue: Long = 0, + val candidate: Long = 0, + val release: Long = 0, + val audit: Long = 0, +) { + init { + require( + listOf( + subTask, + review, + change, + thread, + comment, + approval, + mergeQueue, + candidate, + release, + audit, + ) + .all { it >= 0 }, + ) { + "id sequence values must not be negative" + } + } +} + +@Serializable +data class WorkflowState( + val schemaVersion: Int = 1, + val storeRevision: Long = 0, + val sequence: IdSequence = IdSequence(), + val tasks: Map = emptyMap(), + val subTasks: Map = emptyMap(), + val pullRequests: Map = emptyMap(), + val checks: Map = emptyMap(), + val integrations: Map = emptyMap(), + val mergeQueue: Map = emptyMap(), + val deploymentCandidates: Map = emptyMap(), + val releases: Map = emptyMap(), + val startRequests: Map = emptyMap(), + val syncConflicts: Map = emptyMap(), + val eventLog: EventLog = EventLog(), +) { + init { + require(schemaVersion == 1) { "unsupported workflow state schema version: $schemaVersion" } + require(storeRevision >= 0) { "store revision must not be negative" } + require(tasks.all { (id, task) -> id == task.id }) { "task map keys must match task ids" } + require(subTasks.all { (id, subTask) -> id == subTask.id }) { + "subtask map keys must match subtask ids" + } + require(subTasks.values.all { it.requires == null || it.requires in subTasks }) { + "subtask dependencies must reference known subtasks" + } + require(pullRequests.all { (id, pullRequest) -> id == pullRequest.id }) { + "pull request map keys must match pull request ids" + } + require(pullRequests.values.all { it.subTaskId in subTasks }) { + "pull requests must reference known subtasks" + } + require(checks.keys.all { it in subTasks }) { "checks must reference known subtasks" } + require( + integrations.all { (id, integration) -> id == integration.subTaskId && id in subTasks } + ) { + "integrations must reference known subtasks" + } + require(mergeQueue.all { (id, entry) -> id == entry.id && entry.subTaskId in subTasks }) { + "merge queue entries must reference known subtasks" + } + require(deploymentCandidates.all { (id, candidate) -> id == candidate.id }) { + "deployment candidate map keys must match candidate ids" + } + require( + deploymentCandidates.values.all { candidate -> + candidate.includedSubTasks.all { it in subTasks } + }, + ) { + "deployment candidates must reference known subtasks" + } + require(releases.all { (id, release) -> id == release.id }) { + "release map keys must match release ids" + } + require(releases.values.all { it.candidateId in deploymentCandidates }) { + "releases must reference known deployment candidates" + } + require( + startRequests.all { (key, record) -> key == record.key && record.subTaskId in subTasks } + ) { + "start requests must reference known subtasks" + } + require(syncConflicts.all { (id, conflict) -> id == conflict.subTaskId && id in subTasks }) { + "sync conflicts must reference known subtasks" + } + require( + !hasDependencyCycle( + subTasks.values.mapNotNull { subTask -> + subTask.requires?.let { Dependency(subTask.id, it) } + }, + ), + ) { + "workflow state must not contain a dependency cycle" + } + } + + fun nextSubTaskId(project: String): Pair { + require(project.matches(Regex("[A-Za-z][A-Za-z0-9._-]*"))) { + "project prefix must be safe for a subtask id" + } + val next = sequence.subTask + 1 + return copy(sequence = sequence.copy(subTask = next)) to "$project-$next" + } + + fun nextReviewRevision(): Pair { + val next = sequence.review + 1 + return copy(sequence = sequence.copy(review = next)) to "rv-$next" + } + + fun nextChangeRevision(): Pair { + val next = sequence.change + 1 + return copy(sequence = sequence.copy(change = next)) to "cr-$next" + } + + fun withSubTask(subTask: SubTask): WorkflowState = + copy(subTasks = subTasks + (subTask.id to subTask)) + + fun withPullRequest(pullRequest: PullRequest): WorkflowState = + copy( + pullRequests = pullRequests + (pullRequest.id to pullRequest), + subTasks = + subTasks[pullRequest.subTaskId]?.let { current -> + subTasks + + (current.id to + current.copy( + pullRequestId = pullRequest.id, + state = pullRequest.state.toSubTaskState(), + )) + } ?: subTasks, + ) +} + +fun PullRequestState.toSubTaskState(): SubTaskState = + when (this) { + PullRequestState.DRAFT -> SubTaskState.DRAFT + PullRequestState.READY -> SubTaskState.READY + PullRequestState.REVIEW -> SubTaskState.REVIEW + PullRequestState.APPROVED -> SubTaskState.APPROVED + PullRequestState.BLOCKED -> SubTaskState.BLOCKED + PullRequestState.QUEUED -> SubTaskState.QUEUED + PullRequestState.MERGED -> SubTaskState.MERGED + PullRequestState.CLOSED -> SubTaskState.BLOCKED + } diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/RuntimeFactory.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/RuntimeFactory.kt new file mode 100644 index 00000000..4128b650 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/RuntimeFactory.kt @@ -0,0 +1,770 @@ +package io.springkit.workflow.runtime + +import io.springkit.workflow.adapter.cli.WorkflowCommandGateway +import io.springkit.workflow.adapter.git.LocalGitAdapter +import io.springkit.workflow.adapter.github.GithubCiAdapter +import io.springkit.workflow.adapter.github.GithubIssueTaskAdapter +import io.springkit.workflow.adapter.github.GithubMergeQueueAdapter +import io.springkit.workflow.adapter.local.EnvironmentIdentityAdapter +import io.springkit.workflow.adapter.local.LocalWorkspaceAdapter +import io.springkit.workflow.adapter.local.SnapshotTaskAdapter +import io.springkit.workflow.adapter.local.SystemClockAdapter +import io.springkit.workflow.adapter.provider.ProviderDeploymentAdapter +import io.springkit.workflow.adapter.provider.ProviderReleaseAdapter +import io.springkit.workflow.adapter.store.OkioWorkflowStoreAdapter +import io.springkit.workflow.adapter.store.StoreIdAdapter +import io.springkit.workflow.adapter.validation.LocalValidationAdapter +import io.springkit.workflow.application.CompensateRequest +import io.springkit.workflow.application.CompensateResponse +import io.springkit.workflow.application.CompensationPort +import io.springkit.workflow.application.ContentPort +import io.springkit.workflow.application.CreateCandidateRequest +import io.springkit.workflow.application.CreateCandidateResponse +import io.springkit.workflow.application.CreateReleaseRequest +import io.springkit.workflow.application.CreateReleaseResponse +import io.springkit.workflow.application.DeploymentLifecycleRequest +import io.springkit.workflow.application.DeploymentLifecycleResponse +import io.springkit.workflow.application.DeploymentLifecycleUseCase +import io.springkit.workflow.application.DeploymentPort +import io.springkit.workflow.application.GetCandidateRequest +import io.springkit.workflow.application.GetCandidateResponse +import io.springkit.workflow.application.GetReleaseRequest +import io.springkit.workflow.application.GetReleaseResponse +import io.springkit.workflow.application.IdKind +import io.springkit.workflow.application.IssueIdRequest +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.PostMergeCleanupResponse +import io.springkit.workflow.application.PostMergeCleanupUseCase +import io.springkit.workflow.application.ReleaseLifecycleUseCases +import io.springkit.workflow.application.ReleasePort +import io.springkit.workflow.application.ReviewLifecycleUseCases +import io.springkit.workflow.application.ReviewUseCases +import io.springkit.workflow.application.StackSyncUseCases +import io.springkit.workflow.application.StartCanaryRequest +import io.springkit.workflow.application.StartCanaryResponse +import io.springkit.workflow.application.StartCheckUseCases +import io.springkit.workflow.application.StartReleaseRequest +import io.springkit.workflow.application.StartReleaseResponse +import io.springkit.workflow.application.StatusUseCase +import io.springkit.workflow.application.StoreSnapshotRequest +import io.springkit.workflow.application.StoreTransactionRequest +import io.springkit.workflow.application.StoreTransactionState +import io.springkit.workflow.application.StoreWriteRequest +import io.springkit.workflow.application.TaskPort +import io.springkit.workflow.application.ValidateCandidateRequest +import io.springkit.workflow.application.ValidateCandidateResponse +import io.springkit.workflow.application.ValidateReleaseRequest +import io.springkit.workflow.application.ValidateReleaseResponse +import io.springkit.workflow.application.WorkflowStorePort +import io.springkit.workflow.application.WorkflowStoreSnapshot +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.DeploymentCandidate +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.IntegrationState +import io.springkit.workflow.domain.MergeRecorded +import io.springkit.workflow.domain.NextAction +import io.springkit.workflow.domain.WorkflowResult +import java.nio.file.InvalidPathException +import java.nio.file.Path +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import okio.Path.Companion.toPath + +/* + * 런타임 구성을 외부 환경과 분리해 테스트할 수 있는 구성 경계입니다. + */ +fun interface RuntimeFactory { + fun create(): WorkflowCommandGateway +} + +/* + * 기본 실행 환경에서 실제 로컬·GitHub 어댑터를 조합하는 Workflow 런타임입니다. + */ +object DefaultRuntimeFactory : RuntimeFactory { + override fun create(): WorkflowCommandGateway = createDefaultRuntime() +} + +/* + * 실행 환경 설정이 Workflow 초기화를 막을 때 사용하는 명시적인 오류입니다. + */ +internal class WorkflowConfigurationException(message: String, cause: Throwable? = null) : + IllegalArgumentException(message, cause) + +/* + * 로컬 실행 환경에서 Workflow Application과 Outbound Adapter를 조합합니다. + */ +fun createDefaultRuntime( + currentDirectory: Path = Path.of(System.getProperty("user.dir")), + environment: Map = System.getenv(), + commandRunner: io.springkit.workflow.common.CommandRunner = + io.springkit.workflow.common.LocalCommandRunner(), +): WorkflowCommandGateway = + createDefaultApplicationRuntime( + currentDirectory = currentDirectory, + environment = environment, + commandRunner = commandRunner, + ) + .commandGateway + +/* + * 기본 실행 환경의 Application과 Adapter를 모두 조합한 런타임을 만듭니다. + */ +fun createDefaultApplicationRuntime( + currentDirectory: Path = Path.of(System.getProperty("user.dir")), + environment: Map = System.getenv(), + commandRunner: io.springkit.workflow.common.CommandRunner = + io.springkit.workflow.common.LocalCommandRunner(), + eventPort: io.springkit.workflow.application.WorkflowEventPort = UnconfiguredWorkflowEventPort, +): WorkflowApplicationRuntime { + validateActorKind(environment) + val workingDirectory = currentDirectory.toAbsolutePath().normalize() + val repositoryRoot = resolveRepositoryRoot(workingDirectory, environment, commandRunner) + val configuredStatePath = + environment["WORKFLOW_STATE_FILE"]?.let { + configurationPath("WORKFLOW_STATE_FILE", it) + } + val statePath = + (configuredStatePath?.let { if (it.isAbsolute) it else workingDirectory.resolve(it) } + ?: repositoryRoot.resolve(".workflow").resolve("state.json")) + .toAbsolutePath() + .normalize() + val store = OkioWorkflowStoreAdapter(statePath.toString().toPath()) + val clock = SystemClockAdapter() + val identity = + EnvironmentIdentityAdapter( + commandRunner = commandRunner, + workingDirectory = workingDirectory, + environment = environment, + humanActorIds = environment.humanActorIds(), + ) + val idPort = StoreIdAdapter(store) + val workspacePort = LocalWorkspaceAdapter(store, { workingDirectory }, repositoryRoot) + val workspacePath = workspacePathResolver(store, repositoryRoot) + val git = LocalGitAdapter(repositoryRoot, workspacePath, commandRunner, repositoryRoot) + val task = + createTaskPort( + repositoryRoot = repositoryRoot, + environment = environment, + commandRunner = commandRunner, + snapshotProvider = { snapshot(store) }, + ) + val providerCommands = providerCommands(environment) + val deploymentPort: DeploymentPort = + providerCommands?.deployment?.let { + ProviderDeploymentAdapter( + commandPrefix = it, + workingDirectory = repositoryRoot, + commandRunner = commandRunner, + ) + } ?: UnavailableDeploymentPort + val releasePort: ReleasePort = + providerCommands?.release?.let { + ProviderReleaseAdapter( + commandPrefix = it, + workingDirectory = repositoryRoot, + commandRunner = commandRunner, + ) + } ?: UnavailableReleasePort + val validation = LocalValidationAdapter(workspacePath, commandRunner = commandRunner) + val reviewAdapter = + io.springkit.workflow.adapter.github.GithubReviewAdapter( + repositoryRoot = repositoryRoot, + commandRunner = commandRunner, + currentPullRequest = { pullRequestId -> + snapshot(store).pullRequests.firstOrNull { it.id == pullRequestId } + }, + ) + val ci = GithubCiAdapter(repositoryRoot, commandRunner) + val mergeQueue = + GithubMergeQueueAdapter( + repositoryRoot = repositoryRoot, + commandRunner = commandRunner, + entryLookup = { entryId -> + snapshot(store).mergeQueue.firstOrNull { + it.id == entryId || it.pullRequestId == entryId + } + }, + entryPersister = { entry -> persistMergeQueueEntry(store, entry) }, + ) + val content = GitContentAdapter(git) + val context = StoreWorkflowRuntimeContext(store, workspacePort, git, identity) + val startCheck = + StartCheckUseCases( + taskPort = task, + gitPort = git, + workspacePort = workspacePort, + storePort = store, + validationPort = validation, + idPort = idPort, + clockPort = clock, + projectPrefix = environment["WORKFLOW_PROJECT_PREFIX"] ?: "sk", + ) + val review = + ReviewUseCases( + reviewPort = reviewAdapter, + idPort = idPort, + clockPort = clock, + compensationPort = RuntimeCompensationPort, + storePort = store, + ) + val reviewLifecycle = + ReviewLifecycleUseCases( + workspacePort = workspacePort, + gitPort = git, + gitPublishPort = git, + reviewPort = reviewAdapter, + ciPort = ci, + storePort = store, + clockPort = clock, + ) + val stackSync = StackSyncUseCases(git, reviewAdapter, store, idPort, clockPort = clock) + val mergeQueueLifecycle = + io.springkit.workflow.application.MergeQueueLifecycleUseCase( + storePort = store, + mergeQueuePort = mergeQueue, + taskPort = task, + idPort = idPort, + clockPort = clock, + ) + val deploymentLifecycle = + DeploymentLifecycleUseCase( + storePort = store, + deploymentPort = deploymentPort, + idPort = idPort, + clockPort = clock, + compensationPort = RuntimeCompensationPort, + ) + val releaseLifecycle = + ReleaseLifecycleUseCases( + storePort = store, + releasePort = releasePort, + idPort = idPort, + clockPort = clock, + compensationPort = RuntimeCompensationPort, + ) + val status = + StatusUseCase( + storePort = store, + taskPort = task, + workspacePort = workspacePort, + contentPort = content, + gitPort = git, + reviewPort = reviewAdapter, + validationPort = validation, + ciPort = ci, + mergeQueuePort = mergeQueue, + deploymentPort = deploymentPort.takeIf { providerCommands != null }, + releasePort = releasePort.takeIf { providerCommands != null }, + ) + val reviewGate = + io.springkit.workflow.application.ReviewGateUseCases( + identityPort = identity, + reviewPort = reviewAdapter, + storePort = store, + mergeQueuePort = mergeQueue, + idPort = idPort, + clockPort = clock, + ) + val deliveryGate = + io.springkit.workflow.application.DeliveryGateUseCases( + storePort = store, + deploymentPort = deploymentPort, + releasePort = releasePort, + identityPort = identity, + idPort = idPort, + clockPort = clock, + compensationPort = RuntimeCompensationPort, + ) + val commandGateway = + WorkflowCommandGateway( + startCheck = startCheck, + review = review, + reviewLifecycle = reviewLifecycle, + stackSync = stackSync, + status = status, + reviewGate = reviewGate, + deliveryGate = deliveryGate, + context = context, + commentId = { requestId -> + when (val result = idPort.issue(IssueIdRequest(IdKind.COMMENT, requestId))) { + is PortResult.Success -> { + val id = result.value.ids.singleOrNull()?.value + if (id == null) { + PortResult.Failure(PortError("ID_ISSUE_INVALID", "Comment ID 발급 결과가 하나가 아닙니다.")) + } else { + PortResult.Success(id) + } + } + is PortResult.Failure -> result + } + }, + reviewAccountMismatchCheck = { subTaskId, allowAccountMismatch -> + when (val result = store.snapshot(StoreSnapshotRequest())) { + is PortResult.Failure -> PortResult.Failure(result.error, result.change) + is PortResult.Success -> { + val pullRequest = + result.value.snapshot.pullRequests.firstOrNull { + it.subTaskId == subTaskId + } + if (pullRequest == null) { + PortResult.Success(null) + } else { + reviewAdapter.authorizeAccountMismatch(pullRequest.id, allowAccountMismatch) + } + } + } + }, + ) + val postMergeCleanup = + PostMergeCleanupUseCase( + git, + reviewAdapter, + store, + idPort, + clock, + ) + val eventUseCases = + createWorkflowEventUseCases( + eventPort = eventPort, + providerConfigured = providerCommands != null, + mergeQueueLifecycle = mergeQueueLifecycle, + postMergeCleanup = postMergeCleanup, + deploymentLifecycle = deploymentLifecycle, + releaseLifecycle = releaseLifecycle, + store = store, + ) + return WorkflowApplicationRuntime( + commandGateway = commandGateway, + eventUseCases = eventUseCases, + postMergeCleanup = postMergeCleanup, + deploymentLifecycle = deploymentLifecycle, + releaseLifecycle = releaseLifecycle, + ) +} + +/* + * Production 후보 뒤에 기록된 merge 중 가장 최신 revision으로 다음 후보를 만듭니다. + */ +internal fun createNextCandidateAfterProduction( + store: WorkflowStorePort, + deploymentLifecycle: DeploymentLifecycleUseCase, + production: DeploymentCandidate, + requestId: String, +): WorkflowResult { + val stored = + when (val result = store.snapshot(StoreSnapshotRequest())) { + is PortResult.Failure -> + return WorkflowResult.Failure( + FailureData( + code = FailureCode.STORE_FAILURE, + message = result.error.message, + blockedBy = + listOf( + BlockedBy( + code = result.error.code, + message = result.error.message, + target = "store", + ) + ), + ) + ) + is PortResult.Success -> result.value.snapshot + } + val latestRevision = + latestMergedRevisionAfter(stored, production.mainRevision) + ?: return WorkflowResult.Success(null) + val created = + deploymentLifecycle.create( + DeploymentLifecycleRequest( + mainRevision = latestRevision, + requestId = "next-candidate-${production.id}-$requestId", + ) + ) + return when (created) { + is WorkflowResult.Failure -> created + is WorkflowResult.Success -> WorkflowResult.Success(created.data, created.next) + } +} + +/* + * Store가 보존한 merge 순서에서 현재 Production 이후의 최신 main revision을 찾습니다. + */ +private fun latestMergedRevisionAfter( + snapshot: WorkflowStoreSnapshot, + productionRevision: String, +): String? { + val includedSubTasks = + snapshot.candidates + .firstOrNull { it.mainRevision == productionRevision } + ?.includedSubTasks + ?.toSet() + .orEmpty() + val mergeEvents = snapshot.eventLog.events.filterIsInstance() + val productionEventIndex = mergeEvents.indexOfLast { it.mainRevision == productionRevision } + val eventRevisions = + if (productionEventIndex >= 0) { + mergeEvents + .drop(productionEventIndex + 1) + .filter { it.targetId !in includedSubTasks } + .map { it.mainRevision } + } else { + emptyList() + } + val integrationRevisions = + snapshot.integrations + .asSequence() + .filter { it.state == IntegrationState.MERGED } + .filter { it.subTaskId !in includedSubTasks } + .mapNotNull { it.mainRevision } + .toList() + return (eventRevisions + integrationRevisions) + .filter { it.isNotBlank() && it != productionRevision } + .lastOrNull() +} + +private data class ProviderCommandPrefixes( + val deployment: List, + val release: List, +) + +/* + * 두 provider 명령 설정을 모두 구성했는지 확인하고 JSON 배열을 해석합니다. + */ +private fun providerCommands(environment: Map): ProviderCommandPrefixes? { + val values = + mapOf( + "WORKFLOW_DEPLOYMENT_COMMAND" to environment["WORKFLOW_DEPLOYMENT_COMMAND"], + "WORKFLOW_RELEASE_COMMAND" to environment["WORKFLOW_RELEASE_COMMAND"], + ) + .mapValues { (_, value) -> value?.trim()?.takeIf(String::isNotBlank) } + if (values.values.all { it == null }) return null + val missing = values.filterValues { it == null }.keys + if (missing.isNotEmpty()) { + throw WorkflowConfigurationException( + "provider CLI 설정은 두 환경 변수를 모두 지정해야 합니다. 누락: ${missing.joinToString(", ")}" + ) + } + return ProviderCommandPrefixes( + deployment = + decodeProviderCommand( + "WORKFLOW_DEPLOYMENT_COMMAND", + values.getValue("WORKFLOW_DEPLOYMENT_COMMAND")!!, + ), + release = + decodeProviderCommand( + "WORKFLOW_RELEASE_COMMAND", + values.getValue("WORKFLOW_RELEASE_COMMAND")!!, + ), + ) +} + +/* + * provider CLI 환경 변수를 문자열 배열로 해석하고 실행 가능한 명령인지 확인합니다. + */ +private fun decodeProviderCommand(name: String, value: String): List { + val command = + try { + providerCommandJson.decodeFromString>(value) + } catch (failure: SerializationException) { + throw WorkflowConfigurationException( + "$name 값은 provider CLI 토큰의 JSON 문자열 배열이어야 합니다.", + failure, + ) + } + if (command.isEmpty() || command.any(String::isBlank)) { + throw WorkflowConfigurationException("$name 값은 비어 있지 않은 provider CLI 토큰 배열이어야 합니다.") + } + return command +} + +private val providerCommandJson = Json { explicitNulls = false } + +/* + * 환경 설정에 따라 외부 Task adapter를 명시적으로 선택합니다. + */ +internal fun createTaskPort( + repositoryRoot: Path, + environment: Map, + commandRunner: CommandRunner, + snapshotProvider: () -> WorkflowStoreSnapshot, +): TaskPort = + when (environment["WORKFLOW_TASK_PROVIDER"]?.trim()?.lowercase().orEmpty()) { + "", + "snapshot" -> SnapshotTaskAdapter(snapshotProvider) + "github-issue" -> GithubIssueTaskAdapter(repositoryRoot, commandRunner) + else -> + throw WorkflowConfigurationException( + "지원하지 않는 WORKFLOW_TASK_PROVIDER입니다. " + "지원 값: snapshot, github-issue", + ) + } + +/* + * 환경 변수 또는 설치된 git CLI를 사용해 저장소 루트를 찾습니다. + */ +private fun resolveRepositoryRoot( + currentDirectory: Path, + environment: Map, + commandRunner: io.springkit.workflow.common.CommandRunner, +): Path { + environment["WORKFLOW_REPO_ROOT"]?.let { + val configured = configurationPath("WORKFLOW_REPO_ROOT", it) + return (if (configured.isAbsolute) configured else currentDirectory.resolve(configured)) + .toAbsolutePath() + .normalize() + } + return runCatching { + commandRunner + .run(listOf("git", "rev-parse", "--show-toplevel"), currentDirectory) + .takeIf { it.exitCode == 0 && it.stdout.trim().isNotBlank() } + ?.stdout + ?.trim() + ?.let(Path::of) + } + .getOrNull() + ?.toAbsolutePath() + ?.normalize() ?: currentDirectory +} + +/* + * 문서에 정의된 실행 주체 종류만 런타임 구성으로 허용합니다. + */ +private fun validateActorKind(environment: Map) { + val configured = + environment["WORKFLOW_ACTOR_KIND"]?.trim()?.takeIf(String::isNotBlank)?.uppercase() + if (configured != null && configured !in setOf("AGENT", "WORKFLOW")) { + throw WorkflowConfigurationException("WORKFLOW_ACTOR_KIND는 AGENT 또는 WORKFLOW여야 합니다.") + } +} + +/* + * 환경 변수의 경로를 해석하고 잘못된 경로를 구성 오류로 변환합니다. + */ +private fun configurationPath(name: String, value: String): Path = + try { + Path.of(value) + } catch (failure: InvalidPathException) { + throw WorkflowConfigurationException("$name 값이 올바른 경로가 아닙니다.", failure) + } + +/* + * 저장된 Workspace 경로를 Local Git 어댑터가 사용할 절대 경로로 변환합니다. + */ +private fun workspacePathResolver( + store: WorkflowStorePort, + repositoryRoot: Path, +): (String) -> Path = { workspaceId -> + when (val result = store.snapshot(StoreSnapshotRequest())) { + is PortResult.Success -> { + val workspace = result.value.snapshot.workspaces.first { it.id == workspaceId } + val path = Path.of(workspace.path.value) + if (path.isAbsolute) path.normalize() else repositoryRoot.resolve(path).normalize() + } + is PortResult.Failure -> error(result.error.message) + } +} + +/* + * 현재 상태 저장소에서 최신 전체 snapshot을 읽습니다. + */ +internal fun snapshot(store: WorkflowStorePort): WorkflowStoreSnapshot = + when (val result = store.snapshot(StoreSnapshotRequest())) { + is PortResult.Success -> result.value.snapshot + is PortResult.Failure -> error(result.error.message) + } + +/* + * 환경에서 사람이 수행할 수 있는 주체 목록을 읽습니다. + */ +private fun Map.humanActorIds(): Set = + (this["WORKFLOW_HUMAN_ACTORS"] ?: this["WORKFLOW_HUMAN_ACTOR_IDS"]) + ?.split(',') + ?.map(String::trim) + ?.filter(String::isNotBlank) + ?.toSet() ?: emptySet() + +/* + * 배포 실패에 독립적인 cleanup 차단 원인과 재시도 행동을 함께 보존합니다. + */ +internal fun WorkflowResult.Failure.withCleanupRetry( + cleanup: WorkflowResult, + mergedSubTaskId: String, +): WorkflowResult.Failure { + val cleanupBlocks = + when (cleanup) { + is WorkflowResult.Success -> { + if ( + cleanup.data.state == + io.springkit.workflow.application.PostMergeCleanupState.COMPLETED + ) { + return this + } + cleanup.data.blocks.map { + BlockedBy( + code = it.code, + message = it.message, + target = it.target, + ) + } + } + is WorkflowResult.Failure -> + cleanup.data.blockedBy.ifEmpty { + listOf( + BlockedBy( + code = cleanup.data.code.name, + message = cleanup.data.message, + target = mergedSubTaskId, + ) + ) + } + } + val cleanupNext = + when (cleanup) { + is WorkflowResult.Success -> cleanup.next + is WorkflowResult.Failure -> cleanup.data.next + } + + io.springkit.workflow.domain.NextAction( + io.springkit.workflow.domain.ActorKind.WORKFLOW, + "retry_cleanup", + ) + return WorkflowResult.Failure( + data.copy( + blockedBy = (data.blockedBy + cleanupBlocks).distinct(), + next = + (data.next + cleanupNext).distinctBy { + Triple(it.actor, it.action, it.command) + }, + ) + ) +} + +/* + * 외부 어댑터가 조회한 Merge Queue 항목을 잠금 가능한 Store에 반영합니다. + */ +private fun persistMergeQueueEntry( + store: WorkflowStorePort, + entry: io.springkit.workflow.domain.MergeQueueEntry, +) { + val current = store.snapshot(StoreSnapshotRequest()) + val snapshot = + when (current) { + is PortResult.Failure -> return + is PortResult.Success -> current.value.snapshot + } + val transaction = + StoreTransactionRequest( + transactionId = + "merge-queue-persist-${entry.id}-${entry.changeRevisionId}-${entry.state.name.lowercase()}", + expectedRevision = snapshot.revision, + idempotencyKey = "merge-queue:${entry.id}:${entry.state}", + ) + val opened = store.begin(transaction) + if (opened !is PortResult.Success || opened.value.state != StoreTransactionState.OPEN) return + val updated = + snapshot.copy(mergeQueue = snapshot.mergeQueue.filterNot { it.id == entry.id } + entry) + if ( + store.write(StoreWriteRequest(transaction.transactionId, snapshot.revision, updated)) + is PortResult.Failure + ) { + store.rollback(transaction) + return + } + store.commit(transaction) +} + +/* + * Git 상태를 Content Port 결과로 변환합니다. + */ +private class GitContentAdapter(private val gitPort: io.springkit.workflow.application.GitPort) : + ContentPort { + override fun inspect( + request: io.springkit.workflow.application.InspectContentRequest + ): PortResult = + when ( + val result = + gitPort.inspect( + io.springkit.workflow.application.GitInspectRequest(request.workspaceId) + ) + ) { + is PortResult.Failure -> result + is PortResult.Success -> + PortResult.Success( + io.springkit.workflow.application.InspectContentResponse( + io.springkit.workflow.application.ContentSnapshot( + revision = result.value.status.revision, + fingerprint = result.value.status.fingerprint, + dirty = result.value.status.dirty, + files = result.value.status.conflicts, + ) + ) + ) + } +} + +/* + * 외부 배포 공급자가 아직 연결되지 않았음을 명시적으로 반환합니다. + */ +private object UnavailableDeploymentPort : DeploymentPort { + override fun createCandidate( + request: CreateCandidateRequest + ): PortResult = unavailable(request.mainRevision) + + override fun getCandidate(request: GetCandidateRequest): PortResult = + unavailable(request.candidateId) + + override fun validate(request: ValidateCandidateRequest): PortResult = + unavailable(request.candidateId) + + override fun startCanary(request: StartCanaryRequest): PortResult = + unavailable(request.candidateId) + + override fun promoteProduction( + request: io.springkit.workflow.application.PromoteProductionRequest + ): PortResult = + unavailable(request.candidateId) +} + +/* + * 외부 공개 공급자가 아직 연결되지 않았음을 명시적으로 반환합니다. + */ +private object UnavailableReleasePort : ReleasePort { + override fun get(request: GetReleaseRequest): PortResult = + unavailable(request.releaseId ?: request.candidateId ?: "release") + + override fun create(request: CreateReleaseRequest): PortResult = + unavailable(request.releaseId) + + override fun validateInternal( + request: ValidateReleaseRequest + ): PortResult = unavailable(request.releaseId) + + override fun start(request: StartReleaseRequest): PortResult = + unavailable(request.releaseId) + + override fun continueRollout( + request: io.springkit.workflow.application.ContinueReleaseRequest + ): PortResult = + unavailable(request.releaseId) +} + +/* + * 공급자 미구성 오류를 공통 Port 결과로 만듭니다. + */ +private fun unavailable(target: Any): PortResult = + PortResult.Failure( + PortError( + code = "PROVIDER_NOT_CONFIGURED", + message = "이 명령의 외부 공급자가 아직 구성되지 않았습니다.", + target = target.toString(), + ) + ) + +/* + * 로컬 실행에서 외부 보상 동작을 원자적인 성공 응답으로 기록합니다. + */ +private object RuntimeCompensationPort : CompensationPort { + override fun compensate(request: CompensateRequest): PortResult = + PortResult.Success(CompensateResponse(request.change)) +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/WorkflowApplicationRuntime.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/WorkflowApplicationRuntime.kt new file mode 100644 index 00000000..e83ef535 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/WorkflowApplicationRuntime.kt @@ -0,0 +1,59 @@ +package io.springkit.workflow.runtime + +import io.springkit.workflow.adapter.cli.WorkflowCommandGateway +import io.springkit.workflow.application.AcknowledgeEventRequest +import io.springkit.workflow.application.AcknowledgeEventResponse +import io.springkit.workflow.application.DeploymentLifecycleUseCase +import io.springkit.workflow.application.MergeQueueLifecycleResponse +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.PostMergeCleanupResponse +import io.springkit.workflow.application.PostMergeCleanupUseCase +import io.springkit.workflow.application.ReceiveEventRequest +import io.springkit.workflow.application.ReceiveEventResponse +import io.springkit.workflow.application.ReleaseLifecycleUseCases +import io.springkit.workflow.application.WorkflowEventPort +import io.springkit.workflow.application.WorkflowEventUseCases + +/* + * Merge Queue 이벤트의 통합 결과와 후속 cleanup 결과입니다. + */ +data class MergeQueueEventResponse( + val merge: MergeQueueLifecycleResponse, + val cleanup: PostMergeCleanupResponse, + val deployment: io.springkit.workflow.application.DeploymentLifecycleResponse? = null, +) + +/* + * 기본 Workflow 실행에 필요한 인바운드 명령과 외부 이벤트 조합입니다. + */ +data class WorkflowApplicationRuntime( + val commandGateway: WorkflowCommandGateway, + val eventUseCases: WorkflowEventUseCases, + val postMergeCleanup: PostMergeCleanupUseCase, + val deploymentLifecycle: DeploymentLifecycleUseCase, + val releaseLifecycle: ReleaseLifecycleUseCases, +) + +/* + * 외부 이벤트 ingress가 아직 구성되지 않은 기본 실행 환경의 명시적 포트입니다. + */ +object UnconfiguredWorkflowEventPort : WorkflowEventPort { + override fun receive(request: ReceiveEventRequest): PortResult = + PortResult.Failure( + PortError( + code = "EVENT_INGRESS_NOT_CONFIGURED", + message = "외부 Workflow 이벤트 ingress가 구성되지 않았습니다.", + target = request.event.id, + ) + ) + + override fun acknowledge(request: AcknowledgeEventRequest): PortResult = + PortResult.Failure( + PortError( + code = "EVENT_INGRESS_NOT_CONFIGURED", + message = "외부 Workflow 이벤트 ingress가 구성되지 않았습니다.", + target = request.eventId, + ) + ) +} diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/WorkflowCommandGateway.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/WorkflowCommandGateway.kt new file mode 100644 index 00000000..2481afec --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/WorkflowCommandGateway.kt @@ -0,0 +1,991 @@ +package io.springkit.workflow.runtime + +import io.springkit.workflow.adapter.cli.WorkflowCommandGateway as CliWorkflowCommandGateway +import io.springkit.workflow.adapter.cli.WorkflowCommandRequest +import io.springkit.workflow.adapter.json.WorkflowJson +import io.springkit.workflow.adapter.local.OkioWorkflowBodyReader +import io.springkit.workflow.application.AddReviewCommentRequest +import io.springkit.workflow.application.ApproveGateRequest +import io.springkit.workflow.application.CheckRequest +import io.springkit.workflow.application.DeliveryGateUseCases +import io.springkit.workflow.application.DeployGateRequest +import io.springkit.workflow.application.GetReviewRequest +import io.springkit.workflow.application.OpenReviewLifecycleRequest +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.ReadyGateRequest +import io.springkit.workflow.application.ReleaseGateRequest +import io.springkit.workflow.application.ReplyReviewThreadRequest +import io.springkit.workflow.application.ResolveReviewThreadRequest +import io.springkit.workflow.application.ReviewGateUseCases +import io.springkit.workflow.application.ReviewLifecycleUseCases +import io.springkit.workflow.application.ReviewUseCases +import io.springkit.workflow.application.StackRequest +import io.springkit.workflow.application.StackSyncUseCases +import io.springkit.workflow.application.StartCheckUseCases +import io.springkit.workflow.application.StartRequest +import io.springkit.workflow.application.StatusRequest +import io.springkit.workflow.application.StatusUseCase +import io.springkit.workflow.application.SyncRequest +import io.springkit.workflow.application.UpdateReviewLifecycleRequest +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.ExternalTaskId +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.NextAction +import io.springkit.workflow.domain.ReviewComment +import io.springkit.workflow.domain.ReviewLevel +import io.springkit.workflow.domain.Risk +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.WorkspacePath +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.HexFormat +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/* + * CLI 요청을 Application 유스케이스로 변환하고 공통 JSON 결과로 되돌리는 런타임 디스패처입니다. + */ +class WorkflowCommandGateway( + private val startCheck: StartCheckUseCases, + private val review: ReviewUseCases, + private val reviewLifecycle: ReviewLifecycleUseCases, + private val stackSync: StackSyncUseCases, + private val status: StatusUseCase, + private val reviewGate: ReviewGateUseCases, + private val deliveryGate: DeliveryGateUseCases, + private val context: WorkflowRuntimeContextResolver, + private val bodyReader: WorkflowBodyReader = OkioWorkflowBodyReader(), + private val commentId: (String) -> PortResult = { revision -> + PortResult.Success("comment-$revision") + }, + private val reviewAccountMismatchCheck: (String, Boolean) -> PortResult = { _, _ -> + PortResult.Success(null) + }, + private val reviewAccountMismatchWarning: (String) -> Unit = { System.err.println(it) }, +) : CliWorkflowCommandGateway { + override fun execute(request: WorkflowCommandRequest): WorkflowResult = + try { + when (request) { + is WorkflowCommandRequest.Start -> start(request) + WorkflowCommandRequest.Check -> check() + is WorkflowCommandRequest.ReviewOpen -> reviewOpen(request) + is WorkflowCommandRequest.ReviewShow -> reviewShow(request) + is WorkflowCommandRequest.ReviewUpdate -> reviewUpdate(request) + is WorkflowCommandRequest.ReviewComment -> reviewComment(request) + is WorkflowCommandRequest.ReviewReply -> reviewReply(request) + is WorkflowCommandRequest.ReviewResolve -> reviewResolve(request) + is WorkflowCommandRequest.Stack -> stack(request) + is WorkflowCommandRequest.Sync -> sync(request) + is WorkflowCommandRequest.Status -> status(request) + is WorkflowCommandRequest.GateReady -> gateReady(request) + is WorkflowCommandRequest.GateApprove -> gateApprove(request) + is WorkflowCommandRequest.GateDeploy -> gateDeploy(request) + is WorkflowCommandRequest.GateRelease -> gateRelease(request) + } + } catch (failure: InvalidWorkflowCommandArgument) { + failure(FailureCode.INVALID_ARGUMENT, failure.message ?: "명령 인자를 처리할 수 없습니다.") + } catch (failure: Exception) { + failure( + FailureCode.INVARIANT_VIOLATION, + "명령 실행 중 예상하지 못한 오류가 발생했습니다: " + (failure.message ?: failure::class.simpleName), + ) + } + + private fun start(request: WorkflowCommandRequest.Start) = + startCheck + .start( + StartRequest( + taskId = ExternalTaskId(request.task), + requestId = request.requestId, + title = request.title, + requires = request.requires, + ) + ) + .toJson { data -> + buildJsonObject { + put("task", data.task.externalId.value) + put("subtask", data.subTask.id) + put("branch", data.subTask.branch) + put("state", data.subTask.state.name) + put( + "workspace", + buildJsonObject { put("path", data.workspace.path.value) }, + ) + put( + "next", + kotlinx.serialization.json.buildJsonArray { + add(nextJson(NextAction(ActorKind.AGENT, "implement_change"))) + }, + ) + } + } + + private fun check() = + context + .currentWorkspaceContext() + .flatMapResult { workspace -> + startCheck.check(CheckRequest(workspace.id, workspace.subTaskId, workspace.path)) + } + .toJson { data -> + buildJsonObject { + put( + "workspace", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.Workspace.serializer(), + data.workspace, + ), + ) + put("revision", data.revision) + put("fingerprint", data.fingerprint) + put( + "validations", + WorkflowJson.format.encodeToJsonElement( + kotlinx.serialization.builtins.ListSerializer( + io.springkit.workflow.domain.Validation.serializer() + ), + data.validations, + ), + ) + put( + "summary", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.CheckSummary.serializer(), + data.summary, + ), + ) + put("publishable", data.publishable) + data.publishabilityMessage?.let { put("publishability_message", it) } + } + } + + private fun reviewOpen(request: WorkflowCommandRequest.ReviewOpen) = + context + .currentWorkspaceContext() + .flatMapResult { workspace -> + readBody(workspace.path, request.bodyFile).flatMap { body -> + reviewLifecycle.open( + OpenReviewLifecycleRequest( + workspaceId = workspace.id, + subTaskId = workspace.subTaskId, + body = body, + risk = request.risk.toRisk(), + ) + ) + } + } + .toJson { data -> + buildJsonObject { + put( + "pull_request", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.PullRequest.serializer(), + data.pullRequest, + ), + ) + put( + "check", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.CheckSummary.serializer(), + data.check, + ), + ) + put("published_revision", data.publishedRevision) + } + } + + private fun reviewShow(request: WorkflowCommandRequest.ReviewShow) = + context + .currentReview() + .flatMapResult { current -> + review.show(GetReviewRequest(current.pullRequestId)).flatMap { shown -> + status.status(StatusRequest(subTaskId = shown.pullRequest.subTaskId)).map { + currentStatus -> + ReviewShowView(shown.pullRequest, currentStatus) + } + } + } + .toJson { data -> + val pullRequest = data.pullRequest + val reviewRevision = pullRequest.reviewRevision + val listedThreads = + if (request.threads == "all") reviewRevision.threads + else reviewRevision.threads.filter { it.isOpen } + buildJsonObject { + put("subtask", pullRequest.subTaskId) + put("state", pullRequest.state.name) + put("pr", pullRequestJson(pullRequest)) + put("review_revision", reviewRevision.id) + put("change_revision", pullRequest.changeRevision.id) + put("diff_identity", pullRequest.changeRevision.diff.identity) + put("risk", pullRequest.risk.name) + put("ci", pullRequest.ci.name) + put("ai_review", pullRequest.aiReview.name) + put("ready", pullRequest.state != io.springkit.workflow.domain.PullRequestState.DRAFT) + pullRequest.approval?.let { + put( + "approval", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.Approval.serializer(), + it, + ), + ) + } + put( + "approval_applicable", + pullRequest.approval?.let { + it.active && + it.changeRevisionId == pullRequest.changeRevision.id && + it.diffIdentity == pullRequest.changeRevision.diff.identity + } ?: false, + ) + put( + "threads", + WorkflowJson.format.encodeToJsonElement( + kotlinx.serialization.builtins.ListSerializer( + io.springkit.workflow.domain.ReviewThread.serializer() + ), + listedThreads, + ), + ) + put( + "blocked_by", + kotlinx.serialization.json.buildJsonArray { + data.status.blockedBy.forEach { blocker -> + add( + buildJsonObject { + put("code", blocker.code) + put("message", blocker.message) + blocker.target?.let { put("target", it) } + } + ) + } + }, + ) + put( + "next", + kotlinx.serialization.json.buildJsonArray { + data.status.next.forEach { add(nextJson(it)) } + }, + ) + if (request.diff) { + put( + "diff", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.Diff.serializer(), + pullRequest.changeRevision.diff, + ), + ) + } + } + } + + private fun reviewUpdate(request: WorkflowCommandRequest.ReviewUpdate) = + context + .currentReview() + .flatMapResult { current -> + authorizeReviewMutation(current.subTaskId, request.allowAccountMismatch) { + context.currentWorkspaceContext().flatMapResult { workspace -> + optionalBody(workspace.path, request.bodyFile).flatMap { body -> + reviewLifecycle.update( + UpdateReviewLifecycleRequest( + workspaceId = workspace.id, + subTaskId = workspace.subTaskId, + pullRequestId = current.pullRequestId, + expectedReviewRevisionId = request.revision, + body = body, + ) + ) + } + } + } + } + .toJson { data -> + buildJsonObject { + put( + "pull_request", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.PullRequest.serializer(), + data.pullRequest, + ), + ) + put("code_changed", data.codeChanged) + put( + "check", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.CheckSummary.serializer(), + data.check, + ), + ) + data.publishedRevision?.let { put("published_revision", it) } + } + } + + private fun reviewComment(request: WorkflowCommandRequest.ReviewComment) = + context + .currentReview() + .flatMapResult { current -> + authorizeReviewMutation(current.subTaskId, request.allowAccountMismatch) { + context.currentWorkspaceContext().flatMapResult { workspace -> + body(workspace.path, request.body, request.bodyFile).flatMap { text -> + context.currentActor(current.reviewRevision.id).flatMapResult { actor -> + commentId(request.revision).flatMapResult { id -> + review.comment( + AddReviewCommentRequest( + current.pullRequestId, + request.revision, + actor, + request.level.toReviewLevel(), + ReviewComment( + id, + actor, + text, + path = request.path, + line = request.line, + ), + ), + ) + } + } + } + } + } + } + .toJson { data -> + buildJsonObject { + put( + "review_revision", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.ReviewRevision.serializer(), + data.reviewRevision, + ), + ) + put("thread", data.threadId) + put("change", changeJson(data.change)) + } + } + + private fun reviewReply(request: WorkflowCommandRequest.ReviewReply) = + context + .currentReview() + .flatMapResult { current -> + authorizeReviewMutation(current.subTaskId, request.allowAccountMismatch) { + context.currentWorkspaceContext().flatMapResult { workspace -> + body(workspace.path, request.body, request.bodyFile).flatMap { text -> + context.currentActor(current.reviewRevision.id).flatMapResult { actor -> + val id = + deterministicReplyCommentId( + current.pullRequestId, + request.revision, + request.thread, + actor.id, + text, + ) + review.reply( + ReplyReviewThreadRequest( + current.pullRequestId, + request.revision, + request.thread, + ReviewComment(id, actor, text), + ) + ) + } + } + } + } + } + .toJson { data -> + buildJsonObject { + put( + "review_revision", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.ReviewRevision.serializer(), + data.reviewRevision, + ), + ) + put("change", changeJson(data.change)) + } + } + + /* + * 같은 답글 요청을 다시 실행해도 같은 외부 comment ID를 사용합니다. + */ + private fun deterministicReplyCommentId( + pullRequestId: String, + reviewRevisionId: String, + threadId: String, + actorId: String, + body: String, + ): String { + val digest = MessageDigest.getInstance("SHA-256") + listOf(pullRequestId, reviewRevisionId, threadId, actorId, body).forEach { value -> + val bytes = value.toByteArray(StandardCharsets.UTF_8) + digest.update(ByteBuffer.allocate(Int.SIZE_BYTES).putInt(bytes.size).array()) + digest.update(bytes) + } + return "reply-" + HexFormat.of().formatHex(digest.digest()) + } + + private fun reviewResolve(request: WorkflowCommandRequest.ReviewResolve) = + context + .currentReview() + .flatMapResult { current -> + authorizeReviewMutation(current.subTaskId, request.allowAccountMismatch) { + context.currentActor(current.reviewRevision.id).flatMapResult { actor -> + review.resolve( + ResolveReviewThreadRequest( + current.pullRequestId, + request.revision, + request.thread, + actor, + ) + ) + } + } + } + .toJson { data -> + buildJsonObject { + put( + "review_revision", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.ReviewRevision.serializer(), + data.reviewRevision, + ), + ) + put("change", changeJson(data.change)) + } + } + + private fun stack(request: WorkflowCommandRequest.Stack) = + context.currentWorkspaceContext().flatMapResult { workspace -> + authorizeReviewMutation(workspace.subTaskId, request.allowAccountMismatch) { + stackSync + .stack(StackRequest(workspace.subTaskId, request.requires, request.clear)) + .toJson(::stackJson) + } + } + + private fun sync(request: WorkflowCommandRequest.Sync) = + context.currentWorkspaceContext().flatMapResult { workspace -> + if (request.abort) { + stackSync + .sync(SyncRequest(workspace.subTaskId, request.continueSync, request.abort)) + .toJson(::syncJson) + } else { + authorizeReviewMutation(workspace.subTaskId, request.allowAccountMismatch) { + stackSync + .sync( + SyncRequest( + workspace.subTaskId, + request.continueSync, + request.abort, + ), + ) + .toJson(::syncJson) + } + } + } + + private fun status(request: WorkflowCommandRequest.Status) = + status + .status( + StatusRequest( + subTaskId = request.subtask, + taskId = request.task, + candidateId = request.candidate, + releaseId = request.release, + ) + ) + .toJson(::statusJson) + + private fun gateReady(request: WorkflowCommandRequest.GateReady) = + authorizeReviewMutation(request.target, request.allowAccountMismatch) { + reviewGate.ready(ReadyGateRequest(request.target, request.reviewRevision)).toJson { data -> + buildJsonObject { + put( + "subtask", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.SubTask.serializer(), + data.subTask, + ), + ) + put( + "pull_request", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.PullRequest.serializer(), + data.pullRequest, + ), + ) + put( + "audit", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.AuditEntry.serializer(), + data.audit, + ), + ) + } + } + } + + private fun gateApprove(request: WorkflowCommandRequest.GateApprove) = + authorizeReviewMutation(request.target, request.allowAccountMismatch) { + reviewGate.approve(ApproveGateRequest(request.target, request.changeRevision)).toJson { data + -> + buildJsonObject { + put( + "subtask", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.SubTask.serializer(), + data.subTask, + ), + ) + put( + "pull_request", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.PullRequest.serializer(), + data.pullRequest, + ), + ) + put( + "approval", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.Approval.serializer(), + data.approval, + ), + ) + put( + "merge_queue", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.MergeQueueEntry.serializer(), + data.mergeQueue, + ), + ) + put( + "audit", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.AuditEntry.serializer(), + data.audit, + ), + ) + } + } + } + + private fun authorizeReviewMutation( + subTaskId: String, + allowAccountMismatch: Boolean, + action: () -> WorkflowResult, + ): WorkflowResult = + reviewAccountMismatchCheck(subTaskId, allowAccountMismatch).flatMapResult { warning -> + warning?.let(reviewAccountMismatchWarning) + action() + } + + private fun gateDeploy(request: WorkflowCommandRequest.GateDeploy) = + deliveryGate.deploy(DeployGateRequest(request.target)).toJson { data -> + buildJsonObject { + put( + "candidate", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.DeploymentCandidate.serializer(), + data.candidate, + ), + ) + put("change", changeJson(data.change)) + } + } + + private fun gateRelease(request: WorkflowCommandRequest.GateRelease) = + deliveryGate.release(ReleaseGateRequest(request.target)).toJson { data -> + buildJsonObject { + put( + "release", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.Release.serializer(), + data.release, + ), + ) + put("change", changeJson(data.change)) + } + } + + private fun body( + workspacePath: WorkspacePath, + value: String?, + file: String?, + ): WorkflowResult = + if (value != null) WorkflowResult.Success(value) else readBody(workspacePath, file!!) + + private fun optionalBody( + workspacePath: WorkspacePath, + file: String?, + ): WorkflowResult = + if (file == null) WorkflowResult.Success(null) else readBody(workspacePath, file).map { it } + + private fun readBody(workspacePath: WorkspacePath, path: String): WorkflowResult = + try { + val managedPath = ManagedWorkspaceBodyPath.resolve(workspacePath, path) + WorkflowResult.Success(bodyReader.read(managedPath.toString())) + } catch (failure: Exception) { + this.failure(FailureCode.INVALID_ARGUMENT, "본문 파일을 읽을 수 없습니다: ${failure.message ?: path}") + } + + private fun changeJson(change: io.springkit.workflow.application.ChangeReceipt) = + buildJsonObject { + put("id", change.id) + put("operation", change.operation) + change.beforeRevision?.let { put("before_revision", it) } + change.afterRevision?.let { put("after_revision", it) } + put("status", change.status.name) + } + + private fun stackJson(data: io.springkit.workflow.application.StackResponse) = + stackResponseJson(data) + + private fun syncJson(data: io.springkit.workflow.application.SyncResponse) = + syncResponseJson(data) + + private fun statusJson(data: io.springkit.workflow.application.StatusResponse): JsonObject = + buildJsonObject { + data.task?.let { + put("task", it.externalId.value) + } + if (data.subTasks.isNotEmpty()) { + put( + "subtasks", + kotlinx.serialization.json.buildJsonArray { + data.subTasks.forEach { add(statusJson(it)) } + }, + ) + } + data.subTask?.let { + put("subtask", it.id) + put("branch", it.branch) + put("state", it.state.name) + it.requires?.let { requires -> put("requires", requires) } + } + data.workspace?.let { workspace -> + put( + "workspace", + buildJsonObject { + put("path", workspace.path) + workspace.content?.let { put("content", contentJson(it)) } + workspace.git?.let { put("git", gitJson(it)) } + }, + ) + } + data.review?.let { put("review", statusReviewJson(it)) } + data.integration?.let { put("integration", statusIntegrationJson(it)) } + put( + "deployment", + data.deployment?.let(::statusDeploymentJson) + ?: buildJsonObject { put("state", "NOT_SELECTED") }, + ) + put( + "release", + data.release?.let(::statusReleaseJson) + ?: buildJsonObject { put("state", "NOT_APPLICABLE") }, + ) + put( + "blocked_by", + kotlinx.serialization.json.buildJsonArray { + data.blockedBy.forEach { + add( + buildJsonObject { + put("code", it.code) + put("message", it.message) + it.target?.let { target -> put("target", target) } + } + ) + } + }, + ) + put( + "next", + kotlinx.serialization.json.buildJsonArray { + data.next.forEach { add(nextJson(it)) } + }, + ) + } + + private fun pullRequestJson(pullRequest: io.springkit.workflow.domain.PullRequest) = + buildJsonObject { + put("id", pullRequest.id) + put("title", pullRequest.title) + put("body", pullRequest.body) + put("base", pullRequest.base) + put("state", pullRequest.state.name) + } + + private fun statusReviewJson(review: io.springkit.workflow.application.StatusReview) = + buildJsonObject { + put("state", review.state.name) + put("review_revision", review.reviewRevision) + put("change_revision", review.changeRevision) + put("diff_identity", review.diffIdentity) + put("risk", review.risk.name) + put( + "threads", + WorkflowJson.format.encodeToJsonElement( + kotlinx.serialization.builtins.ListSerializer( + io.springkit.workflow.domain.ReviewThread.serializer() + ), + review.threads, + ), + ) + put("ready", review.ready) + review.approval?.let { + put( + "approval", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.Approval.serializer(), + it, + ), + ) + } + put("ci", review.ci.name) + put("ai_review", review.aiReview.name) + } + + private fun statusIntegrationJson( + integration: io.springkit.workflow.application.StatusIntegration + ) = buildJsonObject { + put("state", integration.state.name) + integration.mergeQueue?.let { + put( + "merge_queue", + buildJsonObject { + put("id", it.id) + put("state", it.state.name) + put("change_revision", it.changeRevisionId) + }, + ) + } + integration.mainRevision?.let { put("main", buildJsonObject { put("revision", it) }) } + } + + private fun statusDeploymentJson(deployment: io.springkit.workflow.application.StatusDeployment) = + buildJsonObject { + put("candidate_id", deployment.candidateId) + put("state", deployment.state.name) + put("main_revision", deployment.mainRevision) + put( + "included_subtasks", + kotlinx.serialization.json.buildJsonArray { + deployment.includedSubTasks.forEach { add(JsonPrimitive(it)) } + }, + ) + put("risk", deployment.risk.name) + put("gate_required", deployment.gateRequired) + } + + private fun statusReleaseJson(release: io.springkit.workflow.application.StatusRelease) = + buildJsonObject { + put("release_id", release.releaseId) + put("state", release.state.name) + put("candidate_id", release.release.candidateId) + put("gate_required", release.gateRequired) + } + + private fun nextJson(action: NextAction) = buildJsonObject { + put("actor", action.actor.name.lowercase()) + put("action", action.action) + action.command?.let { put("command", it) } + } + + private fun contentJson(content: io.springkit.workflow.application.ContentSnapshot) = + buildJsonObject { + put("revision", content.revision) + put("fingerprint", content.fingerprint) + put("dirty", content.dirty) + put( + "files", + kotlinx.serialization.json.buildJsonArray { + content.files.forEach { add(JsonPrimitive(it)) } + }, + ) + } + + private fun gitJson(git: io.springkit.workflow.application.GitStatus) = buildJsonObject { + put("revision", git.revision) + put("fingerprint", git.fingerprint) + put("dirty", git.dirty) + put( + "conflicts", + kotlinx.serialization.json.buildJsonArray { + git.conflicts.forEach { add(JsonPrimitive(it)) } + }, + ) + } + + private fun String.toRisk(): Risk = + when (lowercase()) { + "normal" -> Risk.NORMAL + "high" -> Risk.HIGH + else -> throw InvalidWorkflowCommandArgument("--risk는 normal 또는 high여야 합니다.") + } + + private fun String.toReviewLevel(): ReviewLevel = + when (uppercase()) { + "R" -> ReviewLevel.R + "C" -> ReviewLevel.C + "A" -> ReviewLevel.A + else -> throw InvalidWorkflowCommandArgument("--level은 R, C 또는 A여야 합니다.") + } + + private fun WorkflowResult.toJson(encoder: (T) -> JsonObject): WorkflowResult = + when (this) { + is WorkflowResult.Success -> WorkflowResult.Success(encoder(data), next) + is WorkflowResult.Failure -> this + } + + private fun WorkflowResult.map(transform: (T) -> R): WorkflowResult = + when (this) { + is WorkflowResult.Success -> WorkflowResult.Success(transform(data), next) + is WorkflowResult.Failure -> this + } + + private fun WorkflowResult.flatMap( + transform: (T) -> WorkflowResult + ): WorkflowResult = + when (this) { + is WorkflowResult.Success -> transform(data) + is WorkflowResult.Failure -> this + } + + private fun PortResult.flatMap(transform: (T) -> PortResult): PortResult = + when (this) { + is PortResult.Success -> transform(value) + is PortResult.Failure -> this + } + + private fun PortResult.flatMapResult( + transform: (T) -> WorkflowResult + ): WorkflowResult = + when (this) { + is PortResult.Success -> transform(value) + is PortResult.Failure -> + WorkflowResult.Failure( + FailureData( + FailureCode.entries.firstOrNull { it.name == error.code } + ?: FailureCode.EXTERNAL_FAILURE, + error.message, + blockedBy = listOf(BlockedBy(error.code, error.message, error.target)), + ) + ) + } + + private fun failure(code: FailureCode, message: String): WorkflowResult = + WorkflowResult.Failure( + FailureData(code, message, blockedBy = listOf(BlockedBy(code.name, message))) + ) +} + +private data class ReviewShowView( + val pullRequest: io.springkit.workflow.domain.PullRequest, + val status: io.springkit.workflow.application.StatusResponse, +) + +private class InvalidWorkflowCommandArgument(message: String) : RuntimeException(message) + +/* + * Stack 성공 결과를 명세의 JSON 계약으로 변환합니다. + */ +internal fun stackResponseJson(data: io.springkit.workflow.application.StackResponse): JsonObject = + buildJsonObject { + put( + "subtask", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.SubTask.serializer(), + data.subTask, + ), + ) + put( + "workspace", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.Workspace.serializer(), + data.workspace, + ), + ) + put("base_branch", data.baseBranch) + put("base_revision", data.baseRevision) + data.pullRequest?.let { + put( + "pull_request", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.PullRequest.serializer(), + it, + ), + ) + } + put("diff_identity_same", data.diffIdentitySame) + put("change_revision_retained", data.changeRevisionRetained) + put("approval_retained", data.approvalRetained) + put( + "invalidated_state", + kotlinx.serialization.json.buildJsonArray { + data.invalidatedState.forEach { add(JsonPrimitive(it)) } + }, + ) + } + +/* + * Sync 성공 결과를 명세의 JSON 계약으로 변환합니다. + */ +internal fun syncResponseJson(data: io.springkit.workflow.application.SyncResponse): JsonObject = + buildJsonObject { + put( + "subtask", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.SubTask.serializer(), + data.subTask, + ), + ) + put( + "workspace", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.Workspace.serializer(), + data.workspace, + ), + ) + put("base_branch", data.baseBranch) + put("base_revision", data.baseRevision) + data.pullRequest?.let { + put( + "pull_request", + WorkflowJson.format.encodeToJsonElement( + io.springkit.workflow.domain.PullRequest.serializer(), + it, + ), + ) + } + put("diff_identity_same", data.diffIdentitySame) + put("change_revision_retained", data.changeRevisionRetained) + put("approval_retained", data.approvalRetained) + put( + "invalidated_state", + kotlinx.serialization.json.buildJsonArray { + data.invalidatedState.forEach { add(JsonPrimitive(it)) } + }, + ) + data.recovery?.let { recovery -> + put( + "recovery", + buildJsonObject { + put("subtask", recovery.subTaskId) + put( + "paths", + kotlinx.serialization.json.buildJsonArray { + recovery.paths.forEach { add(JsonPrimitive(it)) } + }, + ) + put("can_continue", recovery.canContinue) + put("can_abort", recovery.canAbort) + }, + ) + } + } diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/WorkflowEventHandlers.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/WorkflowEventHandlers.kt new file mode 100644 index 00000000..e9c3fb88 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/WorkflowEventHandlers.kt @@ -0,0 +1,400 @@ +package io.springkit.workflow.runtime + +import io.springkit.workflow.application.ContinueRolloutLifecycleRequest +import io.springkit.workflow.application.DeploymentLifecycleRequest +import io.springkit.workflow.application.DeploymentLifecycleResponse +import io.springkit.workflow.application.DeploymentLifecycleUseCase +import io.springkit.workflow.application.ExternalEvent +import io.springkit.workflow.application.ExternalEventKind +import io.springkit.workflow.application.MergeQueueLifecycleRequest +import io.springkit.workflow.application.PostMergeCleanupRequest +import io.springkit.workflow.application.PostMergeCleanupResponse +import io.springkit.workflow.application.ReleaseLifecycleResponse +import io.springkit.workflow.application.ReleaseLifecycleUseCases +import io.springkit.workflow.application.WorkflowEventHandler +import io.springkit.workflow.application.WorkflowEventPort +import io.springkit.workflow.application.WorkflowEventUseCases +import io.springkit.workflow.application.WorkflowStorePort +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.DeploymentCandidateState +import io.springkit.workflow.domain.DeploymentRecorded +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.NextAction +import io.springkit.workflow.domain.ReleaseRecorded +import io.springkit.workflow.domain.ReleaseState +import io.springkit.workflow.domain.WorkflowResult + +/* + * 배포 Event의 후보, Release와 후속 후보를 함께 반환하는 런타임 결과입니다. + */ +data class DeploymentEventResponse( + val deployment: DeploymentLifecycleResponse, + val releases: List = emptyList(), + val nextCandidate: DeploymentLifecycleResponse? = null, +) + +/* + * 외부 Event와 해당 lifecycle을 연결하는 handler를 조립합니다. + */ +internal fun createWorkflowEventUseCases( + eventPort: WorkflowEventPort, + providerConfigured: Boolean, + mergeQueueLifecycle: io.springkit.workflow.application.MergeQueueLifecycleUseCase, + postMergeCleanup: io.springkit.workflow.application.PostMergeCleanupUseCase, + deploymentLifecycle: DeploymentLifecycleUseCase, + releaseLifecycle: ReleaseLifecycleUseCases, + store: WorkflowStorePort, +): WorkflowEventUseCases { + val handlers = + mutableMapOf( + ExternalEventKind.MERGE_QUEUE_CHANGED to + WorkflowEventHandler { event -> + val mergeRequest = + MergeQueueLifecycleRequest( + subTaskId = event.targetId, + mergeQueueEntryId = + event.attributes.optionalAttribute("merge_queue_entry_id"), + changeRevisionId = event.attributes.optionalAttribute("change_revision_id"), + requestId = event.id, + ) + when (val merged = mergeQueueLifecycle.execute(mergeRequest)) { + is WorkflowResult.Failure -> merged + is WorkflowResult.Success -> { + val mainRevision = merged.data.integration.mainRevision + if (mainRevision.isNullOrBlank()) { + return@WorkflowEventHandler runtimeEventFailure( + FailureCode.INVARIANT_VIOLATION, + "통합 결과에 정확한 main revision이 없습니다.", + merged.data.subTask.id, + ) + } + val cleanup = + postMergeCleanup.execute( + PostMergeCleanupRequest(mergedSubTaskId = merged.data.subTask.id) + ) + if (!providerConfigured) { + return@WorkflowEventHandler when (cleanup) { + is WorkflowResult.Success -> + WorkflowResult.Success( + MergeQueueEventResponse(merged.data, cleanup.data), + next = cleanup.next, + ) + is WorkflowResult.Failure -> + WorkflowResult.Success( + MergeQueueEventResponse( + merged.data, + cleanup.data.toBlockedCleanup(merged.data.subTask.id), + ), + next = retryCleanupNext(), + ) + } + } + val deployment = + deploymentLifecycle.create( + DeploymentLifecycleRequest( + mainRevision = mainRevision, + requestId = event.id, + ) + ) + if (deployment is WorkflowResult.Failure) { + return@WorkflowEventHandler deployment.withCleanupRetry( + cleanup, + merged.data.subTask.id, + ) + } + val deploymentSuccess = + deployment as WorkflowResult.Success + when (cleanup) { + is WorkflowResult.Success -> + WorkflowResult.Success( + MergeQueueEventResponse( + merged.data, + cleanup.data, + deploymentSuccess.data, + ), + next = cleanup.next + deploymentSuccess.next, + ) + is WorkflowResult.Failure -> + WorkflowResult.Success( + MergeQueueEventResponse( + merged.data, + cleanup.data.toBlockedCleanup(merged.data.subTask.id), + deploymentSuccess.data, + ), + next = retryCleanupNext() + deploymentSuccess.next, + ) + } + } + } + }, + ExternalEventKind.MAIN_MERGED to + WorkflowEventHandler { event -> + if (!providerConfigured) { + return@WorkflowEventHandler postMergeCleanup.execute( + PostMergeCleanupRequest(mergedSubTaskId = event.targetId) + ) + } + val mainRevision = + snapshot(store) + .integrations + .firstOrNull { it.subTaskId == event.targetId } + ?.mainRevision + ?.takeIf(String::isNotBlank) + ?: event.attributes.optionalAttribute("main_revision") + if (mainRevision == null) { + return@WorkflowEventHandler runtimeEventFailure( + FailureCode.INVALID_ARGUMENT, + "MAIN_MERGED 이벤트에 정확한 main revision이 없습니다.", + event.targetId, + ) + } + val cleanup = + postMergeCleanup.execute( + PostMergeCleanupRequest(mergedSubTaskId = event.targetId) + ) + val deployment = + deploymentLifecycle.create( + DeploymentLifecycleRequest( + mainRevision = mainRevision, + requestId = event.id, + ) + ) + if (deployment is WorkflowResult.Failure) { + return@WorkflowEventHandler deployment.withCleanupRetry(cleanup, event.targetId) + } + when (cleanup) { + is WorkflowResult.Success -> deployment + is WorkflowResult.Failure -> { + val deployed = deployment as WorkflowResult.Success + WorkflowResult.Success(deployed.data, next = retryCleanupNext() + deployed.next) + } + } + }, + ) + if (providerConfigured) { + handlers[ExternalEventKind.DEPLOYMENT_CHANGED] = WorkflowEventHandler { event -> + handleDeploymentEvent(event, deploymentLifecycle, releaseLifecycle, store) + } + handlers[ExternalEventKind.CANARY_CHANGED] = WorkflowEventHandler { event -> + handleDeploymentEvent(event, deploymentLifecycle, releaseLifecycle, store) + } + handlers[ExternalEventKind.RELEASE_CHANGED] = WorkflowEventHandler { event -> + val stateName = event.attributes.optionalAttribute("state") + if (stateName == null) { + return@WorkflowEventHandler runtimeEventFailure( + FailureCode.INVALID_ARGUMENT, + "RELEASE_CHANGED 이벤트에 state가 없습니다.", + event.targetId, + ) + } + val state = + try { + ReleaseState.valueOf(stateName.uppercase()) + } catch (_: IllegalArgumentException) { + return@WorkflowEventHandler runtimeEventFailure( + FailureCode.INVALID_ARGUMENT, + "RELEASE_CHANGED 이벤트의 state가 올바르지 않습니다: $stateName", + event.targetId, + ) + } + handleReleaseEvent(event, state, releaseLifecycle) + } + } + return WorkflowEventUseCases(eventPort = eventPort, handlers = handlers) +} + +/* + * 배포 상태를 반영하고 Production 이후 Release와 다음 후보를 순서대로 처리합니다. + */ +private fun handleDeploymentEvent( + event: ExternalEvent, + deploymentLifecycle: DeploymentLifecycleUseCase, + releaseLifecycle: ReleaseLifecycleUseCases, + store: WorkflowStorePort, +): WorkflowResult = + when (val deployment = deploymentLifecycle.handle(event)) { + is WorkflowResult.Failure -> deployment + is WorkflowResult.Success -> + handleProductionDeployment( + event, + deployment, + releaseLifecycle, + store, + deploymentLifecycle, + ) + } + +/* + * Production 후보의 Release와 후속 배포 후보를 처리합니다. + */ +private fun handleProductionDeployment( + event: ExternalEvent, + deployment: WorkflowResult.Success, + releaseLifecycle: ReleaseLifecycleUseCases, + store: WorkflowStorePort, + deploymentLifecycle: DeploymentLifecycleUseCase, +): WorkflowResult { + val deploymentData = deployment.data + if (deploymentData.candidate.state != DeploymentCandidateState.PRODUCTION) { + return WorkflowResult.Success(DeploymentEventResponse(deploymentData), next = deployment.next) + } + val productionEvent = + DeploymentRecorded( + targetId = deploymentData.candidate.id, + state = deploymentData.candidate.state, + occurredAtEpochMillis = event.occurredAtEpochMillis, + ) + return when (val releases = releaseLifecycle.handleDeploymentAll(productionEvent)) { + is WorkflowResult.Failure -> releases.withRuntimeNext(deployment.next) + is WorkflowResult.Success -> { + val currentNext = mergeNextActions(deployment.next, releases.data.flatMap { it.next }) + when ( + val nextCandidate = + createNextCandidateAfterProduction( + store, + deploymentLifecycle, + deploymentData.candidate, + event.id, + ) + ) { + is WorkflowResult.Failure -> nextCandidate.withRuntimeNext(currentNext) + is WorkflowResult.Success -> + WorkflowResult.Success( + DeploymentEventResponse(deploymentData, releases.data, nextCandidate.data), + next = mergeNextActions(currentNext, nextCandidate.next), + ) + } + } + } +} + +/* + * Release 상태를 반영한 뒤 rollout 상태에서만 후속 동작을 실행합니다. + */ +private fun handleReleaseEvent( + event: ExternalEvent, + state: ReleaseState, + releaseLifecycle: ReleaseLifecycleUseCases, +): WorkflowResult = + when ( + val handled = + releaseLifecycle.handleRelease( + ReleaseRecorded(event.targetId, state, event.occurredAtEpochMillis) + ) + ) { + is WorkflowResult.Failure -> handled + is WorkflowResult.Success -> continueReleaseEvent(handled, event, releaseLifecycle) + } + +/* + * ROLLOUT을 진행하고 RELEASED 결과는 같은 Event 흐름에서 cleanup까지 처리합니다. + */ +private fun continueReleaseEvent( + handled: WorkflowResult.Success, + event: ExternalEvent, + releaseLifecycle: ReleaseLifecycleUseCases, +): WorkflowResult { + if ( + handled.data.release.state != ReleaseState.ROLLOUT && + handled.data.release.state != ReleaseState.RELEASED + ) { + return handled + } + val continued = + when ( + val result = + releaseLifecycle.continueRollout( + ContinueRolloutLifecycleRequest(event.targetId, event.id) + ) + ) { + is WorkflowResult.Failure -> return result.withRuntimeNext(handled.next) + is WorkflowResult.Success -> result + } + val afterContinueNext = mergeNextActions(handled.next, continued.next) + if (continued.data.release.state != ReleaseState.RELEASED) { + return WorkflowResult.Success(continued.data, next = afterContinueNext) + } + return when ( + val cleaned = + releaseLifecycle.continueRollout( + ContinueRolloutLifecycleRequest(event.targetId, "${event.id}-cleanup") + ) + ) { + is WorkflowResult.Failure -> cleaned.withRuntimeNext(afterContinueNext) + is WorkflowResult.Success -> + WorkflowResult.Success( + cleaned.data, + next = mergeNextActions(afterContinueNext, cleaned.next), + ) + } +} + +/* + * 기존 next action을 중복 없이 이어 붙입니다. + */ +private fun mergeNextActions(vararg actions: List): List = + actions.asList().flatten().distinctBy { Triple(it.actor, it.action, it.command) } + +/* + * 앞선 런타임 단계의 next action을 후속 실패 결과에도 보존합니다. + */ +private fun WorkflowResult.Failure.withRuntimeNext( + previous: List +): WorkflowResult.Failure = + WorkflowResult.Failure(data.copy(next = mergeNextActions(previous, data.next))) + +/* + * 이벤트 속성에서 공백 값과 누락 값을 동일한 선택적 값으로 변환합니다. + */ +private fun Map.optionalAttribute(name: String): String? = + this[name]?.trim()?.takeIf(String::isNotBlank) + +/* + * 재시도 가능한 cleanup 동작을 구성합니다. + */ +private fun retryCleanupNext(): List = + listOf(NextAction(io.springkit.workflow.domain.ActorKind.WORKFLOW, "retry_cleanup")) + +/* + * 이벤트 handler가 재시도할 수 있는 명시적인 실패 결과를 만듭니다. + */ +private fun runtimeEventFailure( + code: FailureCode, + message: String, + target: String, +): WorkflowResult.Failure = + WorkflowResult.Failure( + FailureData(code, message, blockedBy = listOf(BlockedBy(code.name, message, target))) + ) + +/* + * 이미 완료된 통합을 되돌리지 않고 cleanup 실패를 재시도 가능한 차단 결과로 변환합니다. + */ +private fun io.springkit.workflow.domain.FailureData.toBlockedCleanup( + mergedSubTaskId: String +): PostMergeCleanupResponse = + PostMergeCleanupResponse( + mergedSubTaskId = mergedSubTaskId, + state = io.springkit.workflow.application.PostMergeCleanupState.BLOCKED, + blocks = + if (blockedBy.isEmpty()) { + listOf( + io.springkit.workflow.application.PostMergeCleanupBlock( + "cleanup", + mergedSubTaskId, + code.name, + message, + ) + ) + } else { + blockedBy.map { + io.springkit.workflow.application.PostMergeCleanupBlock( + "cleanup", + it.target ?: mergedSubTaskId, + it.code, + it.message, + ) + } + }, + ) diff --git a/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/WorkflowRuntimeContext.kt b/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/WorkflowRuntimeContext.kt new file mode 100644 index 00000000..7171f0b4 --- /dev/null +++ b/tools/workflow/src/main/kotlin/io/springkit/workflow/runtime/WorkflowRuntimeContext.kt @@ -0,0 +1,176 @@ +package io.springkit.workflow.runtime + +import io.springkit.workflow.application.CurrentActorRequest +import io.springkit.workflow.application.GitInspectRequest +import io.springkit.workflow.application.IdentityPort +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.StoreSnapshotRequest +import io.springkit.workflow.application.WorkflowStorePort +import io.springkit.workflow.application.WorkspaceLookupRequest +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.SubTaskId +import io.springkit.workflow.domain.WorkspaceId +import io.springkit.workflow.domain.WorkspacePath +import java.nio.file.Path + +/* + * CLI 명령이 현재 실행 위치와 Review 상태를 해석할 때 사용하는 경계입니다. + */ +interface WorkflowRuntimeContextResolver { + fun currentWorkspaceContext(): PortResult + + fun currentReview(): PortResult + + fun currentActor(requestId: String): PortResult +} + +/* + * 현재 관리 Workspace의 식별자와 경로입니다. + */ +data class WorkflowWorkspaceContext( + val id: WorkspaceId, + val subTaskId: SubTaskId, + val path: WorkspacePath, +) + +/* + * Review 명령을 Application 요청으로 변환하는 데 필요한 현재 상태입니다. + */ +data class WorkflowReviewContext( + val pullRequestId: String, + val subTaskId: SubTaskId, + val title: String, + val base: String, + val branch: String, + val reviewRevision: ReviewRevision, + val changeRevision: ChangeRevision, +) + +/* + * 파일 본문을 읽는 작은 경계입니다. + */ +fun interface WorkflowBodyReader { + fun read(path: String): String +} + +/* + * 현재 관리 Worktree 안에 있는 본문 파일의 실제 경로를 해석합니다. + */ +internal object ManagedWorkspaceBodyPath { + fun resolve(workspace: WorkspacePath, requestedPath: String): Path { + val workspacePath = Path.of(workspace.value).toAbsolutePath().normalize().toRealPath() + val bodyPath = Path.of(requestedPath) + val normalizedPath = + if (bodyPath.isAbsolute) bodyPath.normalize() + else workspacePath.resolve(bodyPath).normalize() + val canonicalPath = normalizedPath.toRealPath() + require(canonicalPath.startsWith(workspacePath)) { + "본문 파일은 현재 관리 Worktree 내부에 있어야 합니다." + } + return canonicalPath + } +} + +/* + * Store와 로컬 Workspace 및 Git 상태를 CLI 현재 문맥으로 변환합니다. + */ +class StoreWorkflowRuntimeContext( + private val store: WorkflowStorePort, + private val workspacePort: io.springkit.workflow.application.WorkspacePort, + private val gitPort: io.springkit.workflow.application.GitPort, + private val identityPort: IdentityPort, +) : WorkflowRuntimeContextResolver { + override fun currentWorkspaceContext(): PortResult = + currentWorkspace().map { + WorkflowWorkspaceContext(it.workspace.id, it.workspace.subTaskId, it.workspace.path) + } + + override fun currentReview(): PortResult { + val workspace = + when (val result = currentWorkspace()) { + is PortResult.Failure -> return result + is PortResult.Success -> result.value.workspace + } + val snapshot = + when (val result = store.snapshot(StoreSnapshotRequest())) { + is PortResult.Failure -> return result + is PortResult.Success -> result.value.snapshot + } + val subTask = + snapshot.subTasks.firstOrNull { it.id == workspace.subTaskId } + ?: return failure( + "SUBTASK_NOT_FOUND", + "현재 Workspace에 연결된 SubTask를 찾을 수 없습니다.", + workspace.subTaskId, + ) + val pullRequest = snapshot.pullRequests.firstOrNull { it.subTaskId == subTask.id } + if (pullRequest != null) { + return PortResult.Success( + WorkflowReviewContext( + pullRequestId = pullRequest.id, + subTaskId = subTask.id, + title = pullRequest.title, + base = pullRequest.base, + branch = subTask.branch, + reviewRevision = pullRequest.reviewRevision, + changeRevision = pullRequest.changeRevision, + ) + ) + } + val status = + when (val result = gitPort.inspect(GitInspectRequest(workspace.id))) { + is PortResult.Failure -> return result + is PortResult.Success -> result.value.status + } + val reviewNumber = snapshot.sequence.review + 1 + val changeNumber = snapshot.sequence.change + 1 + val parentBranch = + subTask.requires?.let { required -> + snapshot.subTasks.firstOrNull { it.id == required }?.branch + } ?: "main" + return PortResult.Success( + WorkflowReviewContext( + pullRequestId = "new-${subTask.id}", + subTaskId = subTask.id, + title = subTask.title, + base = parentBranch, + branch = subTask.branch, + reviewRevision = ReviewRevision("rv-$reviewNumber", reviewNumber, "초기 Review"), + changeRevision = + ChangeRevision( + id = "cr-$changeNumber", + number = changeNumber, + diff = Diff(identity = status.fingerprint, files = status.conflicts), + ), + ) + ) + } + + override fun currentActor(requestId: String): PortResult = + when (val result = identityPort.currentActor(CurrentActorRequest(requestId))) { + is PortResult.Failure -> result + is PortResult.Success -> PortResult.Success(result.value.actor) + } + + private fun currentWorkspace(): + PortResult = + workspacePort.get(WorkspaceLookupRequest()) + + private fun PortResult.map(transform: (T) -> R): PortResult = + when (this) { + is PortResult.Failure -> this + is PortResult.Success -> PortResult.Success(transform(value)) + } + + private fun failure(code: String, message: String, target: String): PortResult = + PortResult.Failure( + io.springkit.workflow.application.PortError( + code = code, + message = message, + target = target, + ) + ) +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/MainTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/MainTest.kt new file mode 100644 index 00000000..505dd65f --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/MainTest.kt @@ -0,0 +1,103 @@ +package io.springkit.workflow + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldBeEmpty +import io.kotest.matchers.string.shouldContain +import io.springkit.workflow.runtime.WorkflowConfigurationException +import io.springkit.workflow.runtime.createDefaultApplicationRuntime +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import java.nio.file.Path + +class MainTest : + FunSpec({ + context("Native CLI 진입점이 실행 환경을 준비할 때") { + test("구성 오류와 JSON을 함께 요청하면, 단일 failure JSON과 비정상 종료를 반환합니다") { + val stdout = ByteArrayOutputStream() + val stderr = ByteArrayOutputStream() + val exit = + shouldThrow { + runWorkflow( + args = arrayOf("status", "--json"), + stdout = PrintStream(stdout), + stderr = PrintStream(stderr), + runtimeFactory = { throw WorkflowConfigurationException("provider 설정이 없습니다") }, + exit = { throw ExitSignal(it) }, + ) + } + + exit.code shouldBe 1 + stdout.toString().lineSequence().filter(String::isNotBlank).count() shouldBe 1 + stdout.toString() shouldContain "\"type\":\"failure\"" + stdout.toString() shouldContain "\"code\":\"INVALID_ARGUMENT\"" + stderr.toString() shouldContain "provider 설정이 없습니다" + } + + test("구성 오류를 일반 모드로 요청하면, JSON 없이 사람이 읽을 오류와 비정상 종료를 반환합니다") { + val stdout = ByteArrayOutputStream() + val stderr = ByteArrayOutputStream() + val exit = + shouldThrow { + runWorkflow( + args = arrayOf("status"), + stdout = PrintStream(stdout), + stderr = PrintStream(stderr), + runtimeFactory = { throw WorkflowConfigurationException("provider 설정이 없습니다") }, + exit = { throw ExitSignal(it) }, + ) + } + + exit.code shouldBe 1 + stdout.toString().shouldBeEmpty() + stderr.toString() shouldContain "workflow 실행 환경 설정을 확인하지 못했습니다" + stderr.toString() shouldContain "provider 설정이 없습니다" + } + + test("예상하지 못한 초기화 오류이면, 오류를 숨기지 않고 전파합니다") { + val unexpected = IllegalStateException("programming error") + + shouldThrow { + runWorkflow( + args = arrayOf("status"), + stdout = PrintStream(ByteArrayOutputStream()), + stderr = PrintStream(ByteArrayOutputStream()), + runtimeFactory = { throw unexpected }, + exit = { throw ExitSignal(it) }, + ) + } shouldBe unexpected + } + + test("허용되지 않은 WORKFLOW_ACTOR_KIND는 구성 오류로 거부합니다") { + shouldThrow { + createDefaultApplicationRuntime( + currentDirectory = Path.of("."), + environment = mapOf("WORKFLOW_ACTOR_KIND" to "HUMAN"), + ) + } + } + + test("빈 WORKFLOW_ACTOR_KIND는 미설정 기본값으로 처리합니다") { + createDefaultApplicationRuntime( + currentDirectory = Path.of("."), + environment = + mapOf( + "WORKFLOW_ACTOR_KIND" to " ", + "WORKFLOW_REPO_ROOT" to ".", + ), + ) + } + + test("잘못된 저장소 경로는 구성 오류로 거부합니다") { + shouldThrow { + createDefaultApplicationRuntime( + currentDirectory = Path.of("."), + environment = mapOf("WORKFLOW_REPO_ROOT" to "\u0000"), + ) + } + } + } + }) + +private class ExitSignal(val code: Int) : RuntimeException() diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/cli/WorkflowCliTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/cli/WorkflowCliTest.kt new file mode 100644 index 00000000..21d6669b --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/cli/WorkflowCliTest.kt @@ -0,0 +1,358 @@ +package io.springkit.workflow.adapter.cli + +import com.github.ajalt.clikt.testing.test +import io.kotest.core.spec.style.FunSpec +import io.kotest.datatest.withData +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldBeEmpty +import io.kotest.matchers.string.shouldContain +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.WorkflowResult +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +class WorkflowCliTest : + FunSpec({ + context("CLI 명령을 Application 요청으로 변환할 때") { + test("start 필수 옵션을 입력하면, Start 요청을 전달합니다") { + var request: WorkflowCommandRequest? = null + val fixture = fixture { + request = it + success() + } + val result = + fixture.command.test( + "start --task TASK-42 --request-id request-1 --title title --requires sk-101" + ) + + result.statusCode shouldBe 0 + request shouldBe WorkflowCommandRequest.Start("TASK-42", "request-1", "title", "sk-101") + fixture.stderr.toString() shouldContain "완료되었습니다" + } + + test("review comment 인자를 입력하면, 본문과 소스 위치를 전달합니다") { + var request: WorkflowCommandRequest? = null + val fixture = fixture { + request = it + success() + } + val result = + fixture.command.test( + "review comment --revision rv-8 --level R --body 의견 --path src/main.kt --line 42" + ) + + result.statusCode shouldBe 0 + request shouldBe + WorkflowCommandRequest.ReviewComment( + revision = "rv-8", + level = "R", + body = "의견", + path = "src/main.kt", + line = 42, + ) + } + + withData( + nameFn = { "${it.name} 명령을 입력하면, 해당 Application 요청을 전달합니다" }, + CommandCase("check", "check", WorkflowCommandRequest.Check), + CommandCase( + "review open", + "review open --body-file pr.md --risk high", + WorkflowCommandRequest.ReviewOpen("pr.md", "high"), + ), + CommandCase( + "review show", + "review show --diff --threads all", + WorkflowCommandRequest.ReviewShow(diff = true, threads = "all"), + ), + CommandCase( + "review update", + "review update --revision rv-8 --body-file pr.md", + WorkflowCommandRequest.ReviewUpdate("rv-8", "pr.md"), + ), + CommandCase( + "review update mismatch override", + "review update --revision rv-8 --body-file pr.md --allow-account-mismatch", + WorkflowCommandRequest.ReviewUpdate("rv-8", "pr.md", allowAccountMismatch = true), + ), + CommandCase( + "review comment mismatch override", + "review comment --revision rv-8 --level R --body 의견 --allow-account-mismatch", + WorkflowCommandRequest.ReviewComment( + revision = "rv-8", + level = "R", + body = "의견", + allowAccountMismatch = true, + ), + ), + CommandCase( + "review reply", + "review reply --revision rv-8 --thread thread-1 --body 답변", + WorkflowCommandRequest.ReviewReply("rv-8", "thread-1", body = "답변"), + ), + CommandCase( + "review reply mismatch override", + "review reply --revision rv-8 --thread thread-1 --body 답변 --allow-account-mismatch", + WorkflowCommandRequest.ReviewReply( + "rv-8", + "thread-1", + body = "답변", + allowAccountMismatch = true, + ), + ), + CommandCase( + "review resolve", + "review resolve --revision rv-8 --thread thread-1", + WorkflowCommandRequest.ReviewResolve("rv-8", "thread-1"), + ), + CommandCase( + "review resolve mismatch override", + "review resolve --revision rv-8 --thread thread-1 --allow-account-mismatch", + WorkflowCommandRequest.ReviewResolve( + "rv-8", + "thread-1", + allowAccountMismatch = true, + ), + ), + CommandCase( + "stack requires", + "stack --requires sk-101", + WorkflowCommandRequest.Stack(requires = "sk-101"), + ), + CommandCase( + "stack mismatch override", + "stack --requires sk-101 --allow-account-mismatch", + WorkflowCommandRequest.Stack(requires = "sk-101", allowAccountMismatch = true), + ), + CommandCase( + "stack clear", + "stack --clear", + WorkflowCommandRequest.Stack(clear = true), + ), + CommandCase("sync", "sync", WorkflowCommandRequest.Sync()), + CommandCase( + "sync continue", + "sync --continue", + WorkflowCommandRequest.Sync(continueSync = true), + ), + CommandCase( + "sync mismatch override", + "sync --continue --allow-account-mismatch", + WorkflowCommandRequest.Sync(continueSync = true, allowAccountMismatch = true), + ), + CommandCase( + "sync abort", + "sync --abort", + WorkflowCommandRequest.Sync(abort = true), + ), + CommandCase( + "status", + "status --candidate dc-1", + WorkflowCommandRequest.Status(candidate = "dc-1"), + ), + CommandCase( + "gate ready", + "gate ready sk-101 --review-revision rv-8", + WorkflowCommandRequest.GateReady("sk-101", "rv-8"), + ), + CommandCase( + "gate ready mismatch override", + "gate ready sk-101 --review-revision rv-8 --allow-account-mismatch", + WorkflowCommandRequest.GateReady("sk-101", "rv-8", allowAccountMismatch = true), + ), + CommandCase( + "gate approve", + "gate approve sk-101 --change-revision cr-4", + WorkflowCommandRequest.GateApprove("sk-101", "cr-4"), + ), + CommandCase( + "gate approve mismatch override", + "gate approve sk-101 --change-revision cr-4 --allow-account-mismatch", + WorkflowCommandRequest.GateApprove("sk-101", "cr-4", allowAccountMismatch = true), + ), + CommandCase( + "gate deploy", + "gate deploy dc-1", + WorkflowCommandRequest.GateDeploy("dc-1"), + ), + CommandCase( + "gate release", + "gate release rel-1", + WorkflowCommandRequest.GateRelease("rel-1"), + ), + ) { case -> + var request: WorkflowCommandRequest? = null + val fixture = fixture { + request = it + success() + } + + val result = fixture.command.test(case.arguments) + + result.statusCode shouldBe 0 + request shouldBe case.expected + } + } + + context("지원하지 않는 CLI 인자를 검증할 때") { + withData( + nameFn = { "${it.name}, 요청을 전달하지 않고 실패합니다" }, + InvalidCommandCase( + "review open의 risk가 지원 값이 아니면", + "review open --body-file pr.md --risk medium", + ), + InvalidCommandCase( + "review open에서 Feature Flag 옵션을 입력하면", + "review open --body-file pr.md --risk normal --feature-flag flag-1", + ), + InvalidCommandCase( + "review open에서 exposure 옵션을 입력하면", + "review open --body-file pr.md --risk normal --exposure unchanged", + ), + InvalidCommandCase( + "PR 작성자를 비교할 수 없는 review open에는 override를 쓸 수 없으면", + "review open --body-file pr.md --risk normal --allow-account-mismatch", + ), + InvalidCommandCase( + "조회 명령 review show에 mismatch override를 입력하면", + "review show --threads all --allow-account-mismatch", + ), + InvalidCommandCase( + "조회 명령 status에 mismatch override를 입력하면", + "status --candidate dc-1 --allow-account-mismatch", + ), + InvalidCommandCase( + "review comment에 본문 입력이 없으면", + "review comment --revision rv-8 --level R", + ), + InvalidCommandCase( + "review comment에 두 본문 입력이 모두 있으면", + "review comment --revision rv-8 --level R --body 본문 --body-file comment.md", + ), + InvalidCommandCase( + "review comment에 path만 있으면", + "review comment --revision rv-8 --level R --body 본문 --path src/main.kt", + ), + InvalidCommandCase( + "review comment에 line만 있으면", + "review comment --revision rv-8 --level R --body 본문 --line 42", + ), + InvalidCommandCase( + "review comment의 level이 지원 값이 아니면", + "review comment --revision rv-8 --level B --body 본문", + ), + InvalidCommandCase( + "review reply에 본문 입력이 없으면", + "review reply --revision rv-8 --thread thread-1", + ), + InvalidCommandCase( + "review reply에 두 본문 입력이 모두 있으면", + "review reply --revision rv-8 --thread thread-1 --body 답변 --body-file reply.md", + ), + InvalidCommandCase("stack 선택이 없으면", "stack"), + InvalidCommandCase("stack 선택이 둘 다 있으면", "stack --requires sk-101 --clear"), + InvalidCommandCase("sync 복구 선택이 둘 다 있으면", "sync --continue --abort"), + InvalidCommandCase( + "status 대상 선택이 둘 이상이면", + "status --subtask sk-101 --task TASK-42", + ), + ) { case -> + var request: WorkflowCommandRequest? = null + val fixture = fixture { + request = it + success() + } + + val result = fixture.command.test(case.arguments) + + result.statusCode shouldBe 1 + request shouldBe null + } + } + + context("JSON 출력 형식을 사용할 때") { + test("명령이 성공하면, 결정적인 JSON 객체 하나를 표준 출력에 기록합니다") { + val fixture = fixture { success(buildJsonObject { put("ok", true) }) } + val result = fixture.command.test("status --subtask sk-102 --json") + + result.statusCode shouldBe 0 + fixture.stdout.toString() shouldBe "{\"data\":{\"ok\":true},\"type\":\"success\"}\n" + fixture.stderr.toString().shouldBeEmpty() + } + + test("Gateway가 실패하면, 실패 JSON과 0이 아닌 종료 상태를 반환합니다") { + val fixture = fixture { + WorkflowResult.Failure(FailureData(FailureCode.HUMAN_REQUIRED, "사람의 결정이 필요합니다.")) + } + val result = fixture.command.test("gate approve sk-102 --change-revision cr-4 --json") + + result.statusCode shouldBe 1 + fixture.stdout.toString() shouldBe + "{\"data\":{\"blocked_by\":[],\"code\":\"HUMAN_REQUIRED\",\"message\":\"사람의 결정이 필요합니다.\",\"next\":[]},\"type\":\"failure\"}\n" + fixture.stderr.toString() shouldContain "완료하지 못했습니다" + } + + test("--json과 함께 필수 옵션을 누락하면, 실패 JSON 객체 하나를 표준 출력에 기록합니다") { + val fixture = fixture { success() } + + val result = fixture.command.test("start --json") + + result.statusCode shouldBe 1 + fixture.stdout.toString().lineSequence().filter(String::isNotBlank).count() shouldBe 1 + fixture.stdout.toString() shouldContain "\"type\":\"failure\"" + fixture.stdout.toString() shouldContain "\"code\":\"INVALID_ARGUMENT\"" + fixture.stderr.toString().shouldBeEmpty() + } + + test("--json과 함께 지원하지 않는 인자를 입력하면, 실패 JSON 객체 하나를 표준 출력에 기록합니다") { + val fixture = fixture { success() } + + val result = fixture.command.test("review show --threads closed --json") + + result.statusCode shouldBe 1 + fixture.stdout.toString().lineSequence().filter(String::isNotBlank).count() shouldBe 1 + fixture.stdout.toString() shouldContain "\"type\":\"failure\"" + fixture.stdout.toString() shouldContain "\"code\":\"INVALID_ARGUMENT\"" + fixture.stderr.toString().shouldBeEmpty() + } + } + }) + +private fun fixture(handler: (WorkflowCommandRequest) -> WorkflowResult): Fixture { + val stdout = ByteArrayOutputStream() + val stderr = ByteArrayOutputStream() + return Fixture( + command = + WorkflowCli( + gateway = WorkflowCommandGateway(handler), + stdout = PrintStream(stdout), + stderr = PrintStream(stderr), + ), + stdout = stdout, + stderr = stderr, + ) +} + +private fun success(data: JsonObject = buildJsonObject { put("accepted", true) }) = + WorkflowResult.Success(data) + +private data class Fixture( + val command: WorkflowCli, + val stdout: ByteArrayOutputStream, + val stderr: ByteArrayOutputStream, +) + +private data class CommandCase( + val name: String, + val arguments: String, + val expected: WorkflowCommandRequest, +) + +private data class InvalidCommandCase( + val name: String, + val arguments: String, +) diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/git/LocalGitAdapterTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/git/LocalGitAdapterTest.kt new file mode 100644 index 00000000..7c768b4c --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/git/LocalGitAdapterTest.kt @@ -0,0 +1,697 @@ +package io.springkit.workflow.adapter.git + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeTypeOf +import io.springkit.workflow.application.AbortRestackRequest +import io.springkit.workflow.application.CheckRemotePushRequest +import io.springkit.workflow.application.ContinueRestackRequest +import io.springkit.workflow.application.CreateBranchRequest +import io.springkit.workflow.application.CreateWorktreeRequest +import io.springkit.workflow.application.GitInspectRequest +import io.springkit.workflow.application.MainRevisionRequest +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.PublishBranchRequest +import io.springkit.workflow.application.RemoveBranchRequest +import io.springkit.workflow.application.RemoveWorktreeRequest +import io.springkit.workflow.application.RestackRequest +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.common.LocalCommandRunner +import io.springkit.workflow.domain.WorkspacePath +import java.nio.file.Files +import java.nio.file.Path + +class LocalGitAdapterTest : + FunSpec({ + context("로컬 Git 명령을 실행하면") { + test("유효하지 않은 Git 옵션을 실행하면, stdout와 stderr와 종료 코드를 분리합니다") { + val result = LocalCommandRunner().run(listOf("git", "--definitely-invalid"), Path.of(".")) + + result.exitCode shouldBe 129 + result.stdout shouldBe "" + result.stderr shouldContain "unknown option" + } + } + + context("workspace를 조회하면") { + test("workspaceId로 경로를 찾으면, 해당 경로의 revision과 변경 상태를 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "revision-1\n", "")) + runner.enqueue(CommandResult(0, " M src/Main.kt\n?? notes.md\n", "")) + runner.enqueue(CommandResult(0, "diff-content\n", "")) + runner.enqueue(CommandResult(0, "notes.md\u0000", "")) + runner.enqueue(CommandResult(0, "hash-notes\n", "")) + val path = Path.of("/tmp/workspace/sk-101") + val adapter = adapter(runner) { path } + + val result = adapter.inspect(GitInspectRequest("workspace-1")) + + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + response shouldBe + io.springkit.workflow.application.GitInspectResponse( + io.springkit.workflow.application.GitStatus( + revision = "revision-1", + fingerprint = response.status.fingerprint, + dirty = true, + ) + ) + runner.commands shouldContainExactly + listOf( + Invocation(listOf("git", "rev-parse", "HEAD"), path), + Invocation( + listOf("git", "status", "--porcelain=v1", "--untracked-files=all"), + path, + ), + Invocation(listOf("git", "diff", "--binary", "HEAD"), path), + Invocation( + listOf("git", "ls-files", "--others", "--exclude-standard", "-z"), + path, + ), + Invocation(listOf("git", "hash-object", "--", "notes.md"), path), + ) + } + + test("같은 파일의 내용이 달라지면, fingerprint도 달라집니다") { + val firstRunner = RecordingCommandRunner() + enqueueInspect(firstRunner, "diff-a\n", "") + val secondRunner = RecordingCommandRunner() + enqueueInspect(secondRunner, "diff-b\n", "") + val first = adapter(firstRunner) { Path.of("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/workspace/sk-101") } + val second = adapter(secondRunner) { Path.of("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/workspace/sk-101") } + + val firstStatus = first.inspect(GitInspectRequest("workspace-1")) + val secondStatus = second.inspect(GitInspectRequest("workspace-1")) + + firstStatus + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .status + .fingerprint shouldNotBe + secondStatus + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .status + .fingerprint + } + + test("untracked 파일의 내용이 달라지면, fingerprint도 달라집니다") { + val firstRunner = RecordingCommandRunner() + enqueueInspect(firstRunner, "same-diff\n", "hash-a\n") + val secondRunner = RecordingCommandRunner() + enqueueInspect(secondRunner, "same-diff\n", "hash-b\n") + val first = adapter(firstRunner) { Path.of("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/workspace/sk-101") } + val second = adapter(secondRunner) { Path.of("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/workspace/sk-101") } + + val firstStatus = first.inspect(GitInspectRequest("workspace-1")) + val secondStatus = second.inspect(GitInspectRequest("workspace-1")) + + firstStatus + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .status + .fingerprint shouldNotBe + secondStatus + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .status + .fingerprint + } + } + + context("원격 게시 가능성을 확인하면") { + test("dry-run이 성공하면 원격 변경 없이 게시 가능 상태를 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "", "")) + val workspace = Path.of("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/workspace/sk-101") + val adapter = adapter(runner) { workspace } + + val result = adapter.checkRemotePush(CheckRemotePushRequest("ws-101", "sk-101")) + + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .publishable shouldBe true + runner.commands shouldBe + listOf( + Invocation( + listOf( + "git", + "push", + "--dry-run", + "--no-verify", + "origin", + "HEAD:refs/heads/sk-101", + ), + workspace, + ) + ) + } + + test("원격 dry-run 명령이 실패하면 게시 불가와 원인을 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(1, "", "permission denied")) + val adapter = adapter(runner) { Path.of("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/workspace/sk-101") } + + val response = + adapter + .checkRemotePush(CheckRemotePushRequest("ws-101", "sk-101")) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + + response.publishable shouldBe false + response.message.orEmpty() shouldContain "permission denied" + } + + test("잘못된 branch 입력은 Git 명령 없이 거절합니다") { + val runner = RecordingCommandRunner() + val adapter = adapter(runner) { Path.of("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/workspace/sk-101") } + + val result = adapter.checkRemotePush(CheckRemotePushRequest("ws-101", "--delete")) + + result.shouldBeTypeOf().error.code shouldBe "INVALID_ARGUMENT" + runner.commands shouldBe emptyList() + } + } + + context("main revision을 갱신하면") { + test("원격 main fetch가 성공하면, 원격 ref의 revision을 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "", "")) + runner.enqueue(CommandResult(0, "revision-2\n", "")) + val repositoryRoot = Path.of("/repo") + val adapter = adapter(runner, repositoryRoot) { Path.of("/workspace") } + + val result = adapter.refreshMain(MainRevisionRequest("origin", "main")) + + result.shouldBeTypeOf>().value shouldBe + io.springkit.workflow.application.MainRevisionResponse("revision-2") + runner.commands shouldContainExactly + listOf( + Invocation(listOf("git", "fetch", "origin", "main"), repositoryRoot), + Invocation( + listOf("git", "rev-parse", "refs/remotes/origin/main"), + repositoryRoot, + ), + ) + } + + test("원격 main fetch가 실패하면, 로컬 ref를 사용하지 않고 실패를 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(1, "", "network unavailable")) + val repositoryRoot = Path.of("/repo") + val adapter = adapter(runner, repositoryRoot) { Path.of("/workspace") } + + val result = adapter.refreshMain(MainRevisionRequest("origin", "main")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "MAIN_REVISION_REFRESH_FAILED" + runner.commands shouldContainExactly + listOf(Invocation(listOf("git", "fetch", "origin", "main"), repositoryRoot)) + } + + test("remote가 Git 옵션이면, fetch 전에 입력을 거부합니다") { + val runner = RecordingCommandRunner() + val adapter = adapter(runner) { Path.of("/workspace") } + + val result = adapter.refreshMain(MainRevisionRequest("--upload-pack=evil", "main")) + + result.shouldBeTypeOf().error.code shouldBe "INVALID_ARGUMENT" + runner.commands shouldBe emptyList() + } + + test("branch가 Git 옵션이면, fetch 전에 입력을 거부합니다") { + val runner = RecordingCommandRunner() + val adapter = adapter(runner) { Path.of("/workspace") } + + val result = adapter.refreshMain(MainRevisionRequest("origin", "--delete")) + + result.shouldBeTypeOf().error.code shouldBe "INVALID_ARGUMENT" + runner.commands shouldBe emptyList() + } + } + + context("branch를 생성하면") { + test("base revision에서 branch를 생성하면, Git 명령과 변경 영수증을 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "", "")) + val repositoryRoot = Path.of("/repo") + val adapter = adapter(runner, repositoryRoot) { Path.of("/workspace") } + + val result = + adapter.createBranch(CreateBranchRequest("feature/sk-101", "main", "revision-3")) + + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + response.branch shouldBe "feature/sk-101" + response.revision shouldBe "revision-3" + response.change.operation shouldBe "create-branch" + runner.commands shouldContainExactly + listOf( + Invocation( + listOf("git", "branch", "feature/sk-101", "revision-3"), + repositoryRoot, + ) + ) + } + + test("branch와 기준 revision이 Git 옵션이면, 명령 전에 입력을 거부합니다") { + val runner = RecordingCommandRunner() + val adapter = adapter(runner) { Path.of("/workspace") } + + val invalidBranch = + adapter.createBranch(CreateBranchRequest("--force", "main", "revision-3")) + val invalidRevision = + adapter.createBranch(CreateBranchRequest("feature/sk-101", "main", "--help")) + + invalidBranch.shouldBeTypeOf().error.code shouldBe "INVALID_ARGUMENT" + invalidRevision.shouldBeTypeOf().error.code shouldBe + "INVALID_ARGUMENT" + runner.commands shouldBe emptyList() + } + } + + context("Git ref 입력을 검증하면") { + test("worktree branch가 Git 옵션이면, 명령 전에 입력을 거부합니다") { + val runner = RecordingCommandRunner() + val adapter = adapter(runner) { Path.of("/workspace") } + + val result = + adapter.createWorktree( + CreateWorktreeRequest("workspace-2", "--detach", WorkspacePath("workspaces/sk-2")) + ) + + result.shouldBeTypeOf().error.code shouldBe "INVALID_ARGUMENT" + runner.commands shouldBe emptyList() + } + + test("restack 기준 revision이 Git 옵션이면, workspace 조회 전에 입력을 거부합니다") { + val runner = RecordingCommandRunner() + val adapter = adapter(runner) { error("workspace가 조회되면 안 됩니다") } + + val result = + adapter.restack( + RestackRequest("workspace-2", "feature/sk-2", "main", "--onto=evil", "head") + ) + + result.shouldBeTypeOf().error.code shouldBe "INVALID_ARGUMENT" + runner.commands shouldBe emptyList() + } + + test("제거할 branch와 expected revision이 Git 옵션이면, 명령 전에 입력을 거부합니다") { + val runner = RecordingCommandRunner() + val adapter = adapter(runner) { Path.of("/workspace") } + + val invalidBranch = adapter.removeBranch(RemoveBranchRequest("--force")) + val invalidRevision = adapter.removeBranch(RemoveBranchRequest("feature/sk-2", "--help")) + + invalidBranch.shouldBeTypeOf().error.code shouldBe "INVALID_ARGUMENT" + invalidRevision.shouldBeTypeOf().error.code shouldBe + "INVALID_ARGUMENT" + runner.commands shouldBe emptyList() + } + + test("publish remote와 branch가 Git 옵션이면, workspace 조회 전에 입력을 거부합니다") { + val runner = RecordingCommandRunner() + val adapter = adapter(runner) { error("workspace가 조회되면 안 됩니다") } + + val invalidRemote = + adapter.publish( + PublishBranchRequest( + workspaceId = "workspace-2", + branch = "feature/sk-2", + commitTitle = "변경", + remote = "--receive-pack=evil", + ) + ) + val invalidBranch = + adapter.publish( + PublishBranchRequest( + workspaceId = "workspace-2", + branch = "--force", + commitTitle = "변경", + ) + ) + + invalidRemote.shouldBeTypeOf().error.code shouldBe "INVALID_ARGUMENT" + invalidBranch.shouldBeTypeOf().error.code shouldBe "INVALID_ARGUMENT" + runner.commands shouldBe emptyList() + } + } + + context("worktree를 생성하면") { + test("worktree 생성을 요청하면, 경로와 branch를 Git 명령 토큰으로 전달합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "", "")) + val repositoryRoot = Files.createTempDirectory("local-git-adapter") + val requestedPath = WorkspacePath("workspaces/sk-102") + val adapter = adapter(runner, repositoryRoot) { Path.of("/workspace") } + + val result = + adapter.createWorktree( + CreateWorktreeRequest("workspace-2", "feature/sk-102", requestedPath) + ) + + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .path shouldBe requestedPath + runner.commands shouldContainExactly + listOf( + Invocation( + listOf("git", "worktree", "add", "workspaces/sk-102", "feature/sk-102"), + repositoryRoot, + ) + ) + } + + test("worktree 경로가 사라지면, WORKSPACE_NOT_FOUND 오류를 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(128, "", "fatal: not a git repository")) + val adapter = adapter(runner) { Path.of("/tmp/workspace/sk-101") } + + val result = adapter.inspect(GitInspectRequest("workspace-1")) + + result.shouldBeTypeOf().error.code shouldBe "WORKSPACE_NOT_FOUND" + } + } + + test("관리 root가 주어지면, 검증한 절대 경로를 Git 명령에 전달합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "", "")) + val root = Files.createTempDirectory("local-git-root-") + val requestedPath = WorkspacePath("workspaces/sk-102") + val adapter = adapter(runner, root, root) { Path.of("/workspace") } + + adapter.createWorktree( + CreateWorktreeRequest("workspace-2", "feature/sk-102", requestedPath) + ) + + runner.commands shouldBe + listOf( + Invocation( + listOf( + "git", + "worktree", + "add", + root.resolve(requestedPath.value).toAbsolutePath().normalize().toString(), + "feature/sk-102", + ), + root, + ) + ) + } + + context("managed root 경계를 검사하면") { + test("절대 경로가 root 밖이면, worktree 생성을 거부합니다") { + val runner = RecordingCommandRunner() + val root = Files.createTempDirectory("local-git-root-") + val outside = Files.createTempDirectory("local-git-outside-") + val adapter = adapter(runner, root, root) { Path.of("/workspace") } + + val result = + adapter.createWorktree( + CreateWorktreeRequest( + "workspace-2", + "feature/sk-102", + WorkspacePath(outside.toString()), + ) + ) + + result.shouldBeTypeOf().error.code shouldBe "WORKSPACE_PATH_INVALID" + runner.commands shouldBe emptyList() + } + + test("`..`으로 root 밖으로 탈출하면, worktree 생성을 거부합니다") { + val runner = RecordingCommandRunner() + val root = Files.createTempDirectory("local-git-root-") + val adapter = adapter(runner, root, root) { Path.of("/workspace") } + + val result = + adapter.createWorktree( + CreateWorktreeRequest( + "workspace-2", + "feature/sk-102", + WorkspacePath("../outside"), + ) + ) + + result.shouldBeTypeOf().error.code shouldBe "WORKSPACE_PATH_INVALID" + runner.commands shouldBe emptyList() + } + + test("외부를 가리키는 심볼릭 링크이면, worktree 생성을 거부합니다") { + val runner = RecordingCommandRunner() + val root = Files.createTempDirectory("local-git-root-") + val outside = Files.createTempDirectory("local-git-outside-") + Files.createSymbolicLink(root.resolve("link"), outside) + val adapter = adapter(runner, root, root) { Path.of("/workspace") } + + val result = + adapter.createWorktree( + CreateWorktreeRequest("workspace-2", "feature/sk-102", WorkspacePath("link")) + ) + + result.shouldBeTypeOf().error.code shouldBe "WORKSPACE_PATH_INVALID" + runner.commands shouldBe emptyList() + } + + test("외부 symlink 아래의 미존재 자식 경로이면, worktree 생성을 거부합니다") { + val runner = RecordingCommandRunner() + val root = Files.createTempDirectory("local-git-root-") + val outside = Files.createTempDirectory("local-git-outside-") + Files.createSymbolicLink(root.resolve("link"), outside) + val adapter = adapter(runner, root, root) { Path.of("/workspace") } + + val result = + adapter.createWorktree( + CreateWorktreeRequest( + "workspace-2", + "feature/sk-102", + WorkspacePath("link/nonexistent"), + ) + ) + + result.shouldBeTypeOf().error.code shouldBe "WORKSPACE_PATH_INVALID" + runner.commands shouldBe emptyList() + } + + test("root 밖 경로이면, worktree 제거를 Git 명령 전에 거부합니다") { + val runner = RecordingCommandRunner() + val root = Files.createTempDirectory("local-git-root-") + val adapter = adapter(runner, root, root) { Path.of("/workspace") } + + val result = + adapter.removeWorktree( + RemoveWorktreeRequest("workspace-2", WorkspacePath("../outside")) + ) + + result.shouldBeTypeOf().error.code shouldBe "WORKSPACE_PATH_INVALID" + runner.commands shouldBe emptyList() + } + } + + context("현재 revision이 예상 revision과 다르면") { + test("현재 revision이 예상 revision과 다르면, restack 없이 재시도 가능한 오류를 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "actual\n", "")) + val workspace = Path.of("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/workspace/sk-103") + val adapter = adapter(runner) { workspace } + + val result = + adapter.restack( + RestackRequest("workspace-3", "feature/sk-103", "main", "base", "expected") + ) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "STALE_REVISION" + failure.error.retryable shouldBe true + runner.commands shouldBe listOf(Invocation(listOf("git", "rev-parse", "HEAD"), workspace)) + } + } + + context("restack 중 충돌이 발생하면") { + test("restack 중 충돌이 발생하면, unmerged 파일 목록이 있는 오류를 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "expected\n", "")) + runner.enqueue(CommandResult(0, "before\n", "")) + runner.enqueue(CommandResult(1, "", "CONFLICT (content): conflict\n")) + runner.enqueue(CommandResult(0, "src/Main.kt\n", "")) + val workspace = Path.of("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/workspace/sk-104") + val adapter = adapter(runner) { workspace } + + val result = + adapter.restack( + RestackRequest("workspace-4", "feature/sk-104", "main", "base", "expected") + ) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "SYNC_CONFLICT" + failure.error.message shouldContain "src/Main.kt" + runner.commands.last() shouldBe + Invocation(listOf("git", "diff", "--name-only", "--diff-filter=U"), workspace) + } + } + + context("충돌 복구를 요청하면") { + test("동기화 계속을 요청하면, git rebase --continue를 workspace에서 실행합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "", "")) + val workspace = Path.of("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/workspace/sk-107") + val adapter = adapter(runner) { workspace } + + val result = adapter.continueRestack(ContinueRestackRequest("workspace-7")) + + result.shouldBeTypeOf>() + runner.commands shouldBe + listOf( + Invocation( + listOf("git", "-c", "core.editor=true", "rebase", "--continue"), + workspace, + ) + ) + } + + test("동기화 취소를 요청하면, git rebase --abort를 workspace에서 실행합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "", "")) + val workspace = Path.of("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/workspace/sk-108") + val adapter = adapter(runner) { workspace } + + val result = adapter.abortRestack(AbortRestackRequest("workspace-8")) + + result.shouldBeTypeOf>() + runner.commands shouldBe listOf(Invocation(listOf("git", "rebase", "--abort"), workspace)) + } + } + + context("expectedRevision이 일치하는 상태에서 restack하면") { + test("expectedRevision이 일치하면, rebase 이후 revision과 diff 변경 여부를 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "expected\n", "")) + runner.enqueue(CommandResult(0, "same-diff\n", "")) + runner.enqueue(CommandResult(0, "", "")) + runner.enqueue(CommandResult(0, "after\n", "")) + runner.enqueue(CommandResult(0, "changed-diff\n", "")) + val workspace = Path.of("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/workspace/sk-105") + val adapter = adapter(runner) { workspace } + + val result = + adapter.restack( + RestackRequest("workspace-5", "feature/sk-105", "main", "base", "expected") + ) + + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + response.revision shouldBe "after" + response.diffChanged shouldBe true + response.change.beforeRevision shouldBe "expected" + response.change.afterRevision shouldBe "after" + runner.commands[2] shouldBe Invocation(listOf("git", "rebase", "base"), workspace) + } + } + + context("expectedRevision과 함께 branch를 제거하면") { + test("expectedRevision과 함께 branch 제거를 요청하면, revision을 검증한 뒤 제거합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "revision-6\n", "")) + runner.enqueue(CommandResult(0, "", "")) + val repositoryRoot = Path.of("/repo") + val adapter = adapter(runner, repositoryRoot) { Path.of("/workspace") } + + val result = adapter.removeBranch(RemoveBranchRequest("feature/sk-106", "revision-6")) + + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .branch shouldBe "feature/sk-106" + runner.commands shouldContainExactly + listOf( + Invocation( + listOf("git", "rev-parse", "refs/heads/feature/sk-106"), + repositoryRoot, + ), + Invocation(listOf("git", "branch", "-D", "feature/sk-106"), repositoryRoot), + ) + } + + test("expectedRevision 대상 branch가 이미 없으면, 삭제 완료로 처리합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(128, "", "fatal: bad revision")) + runner.enqueue(CommandResult(1, "", "")) + val repositoryRoot = Path.of("/repo") + val adapter = adapter(runner, repositoryRoot) { Path.of("/workspace") } + + val result = adapter.removeBranch(RemoveBranchRequest("feature/sk-106", "revision-6")) + + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .branch shouldBe "feature/sk-106" + runner.commands shouldContainExactly + listOf( + Invocation( + listOf("git", "rev-parse", "refs/heads/feature/sk-106"), + repositoryRoot, + ), + Invocation( + listOf("git", "show-ref", "--verify", "--quiet", "refs/heads/feature/sk-106"), + repositoryRoot, + ), + ) + } + } + }) + +private fun adapter( + runner: RecordingCommandRunner, + repositoryRoot: Path = Path.of("/repo"), + managedWorkspaceRoot: Path? = null, + workspacePath: (String) -> Path, +): LocalGitAdapter = LocalGitAdapter(repositoryRoot, workspacePath, runner, managedWorkspaceRoot) + +private data class Invocation(val command: List, val workingDirectory: Path) + +private class RecordingCommandRunner : CommandRunner { + val commands = mutableListOf() + private val results = ArrayDeque() + + fun enqueue(result: CommandResult) { + results.addLast(result) + } + + override fun run(command: List, workingDirectory: Path): CommandResult { + commands += Invocation(command, workingDirectory) + return results.removeFirst() + } +} + +private fun enqueueInspect(runner: RecordingCommandRunner, diff: String, hash: String) { + runner.enqueue(CommandResult(0, "revision-1\n", "")) + runner.enqueue(CommandResult(0, " M src/Main.kt\n?? notes.md\n", "")) + runner.enqueue(CommandResult(0, diff, "")) + runner.enqueue(CommandResult(0, "notes.md\u0000", "")) + runner.enqueue(CommandResult(0, hash, "")) +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/git/LocalGitCleanupAdapterTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/git/LocalGitCleanupAdapterTest.kt new file mode 100644 index 00000000..5581dea4 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/git/LocalGitCleanupAdapterTest.kt @@ -0,0 +1,299 @@ +package io.springkit.workflow.adapter.git + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeTypeOf +import io.springkit.workflow.application.ListRemoteBranchesRequest +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.RemoveRemoteBranchRequest +import io.springkit.workflow.application.RemoveWorktreeRequest +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.domain.WorkspacePath +import java.nio.file.Path + +class LocalGitCleanupAdapterTest : + FunSpec({ + context("원격 Branch 목록을 조회하면") { + test("Git 원격 ref를 읽으면, remote HEAD를 제외한 Branch와 revision을 반환합니다") { + val runner = RecordingCleanupCommandRunner() + runner.enqueue( + CommandResult( + 0, + "main-revision\trefs/heads/main\nfeature-revision\trefs/heads/feature/sk-101\n", + "", + ) + ) + val repositoryRoot = Path.of("/repo") + val adapter = LocalGitAdapter(repositoryRoot, { Path.of("/workspace") }, runner) + + val result = adapter.listRemoteBranches(ListRemoteBranchesRequest("origin")) + + result.shouldBeTypeOf>().value shouldBe + io.springkit.workflow.application.ListRemoteBranchesResponse( + listOf( + io.springkit.workflow.application.RemoteBranch( + remote = "origin", + branch = "main", + revision = "main-revision", + ), + io.springkit.workflow.application.RemoteBranch( + remote = "origin", + branch = "feature/sk-101", + revision = "feature-revision", + ), + ) + ) + runner.commands shouldContainExactly + listOf( + CleanupInvocation( + listOf( + "git", + "ls-remote", + "--heads", + "origin", + ), + repositoryRoot, + ) + ) + } + + test("Git 원격 ref 조회가 실패하면, REMOTE_BRANCH_LIST_FAILED 오류를 반환합니다") { + val runner = RecordingCleanupCommandRunner() + runner.enqueue(CommandResult(128, "", "remote ref를 읽을 수 없습니다")) + val adapter = LocalGitAdapter(Path.of("/repo"), { Path.of("/workspace") }, runner) + + val result = adapter.listRemoteBranches(ListRemoteBranchesRequest("upstream")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "REMOTE_BRANCH_LIST_FAILED" + failure.error.message shouldContain "remote ref를 읽을 수 없습니다" + } + + test("refs/heads가 아닌 ls-remote 응답이면, 원격 Branch 목록 오류를 반환합니다") { + val runner = RecordingCleanupCommandRunner() + runner.enqueue(CommandResult(0, "revision\trefs/tags/release\n", "")) + val adapter = LocalGitAdapter(Path.of("/repo"), { Path.of("/workspace") }, runner) + + val result = adapter.listRemoteBranches(ListRemoteBranchesRequest("origin")) + + result.shouldBeTypeOf().error.code shouldBe + "REMOTE_BRANCH_LIST_FAILED" + } + } + + context("원격 Branch를 제거하면") { + test("expectedRevision이 현재 revision과 일치하면, 검증 후 Git 원격 삭제를 실행합니다") { + val runner = RecordingCleanupCommandRunner() + runner.enqueue(CommandResult(0, "feature-revision\trefs/heads/feature/sk-101\n", "")) + runner.enqueue(CommandResult(0, "deleted\n", "")) + val repositoryRoot = Path.of("/repo") + val adapter = LocalGitAdapter(repositoryRoot, { Path.of("/workspace") }, runner) + + val result = + adapter.removeRemoteBranch( + RemoveRemoteBranchRequest( + remote = "origin", + branch = "feature/sk-101", + expectedRevision = "feature-revision", + ) + ) + + val response = result.shouldBeTypeOf>().value + response shouldBe + io.springkit.workflow.application.RemoveRemoteBranchResponse( + remote = "origin", + branch = "feature/sk-101", + change = + io.springkit.workflow.application.ChangeReceipt( + id = "git-remove-remote-branch-origin-feature/sk-101", + operation = "remove-remote-branch", + ), + ) + runner.commands shouldContainExactly + listOf( + CleanupInvocation( + listOf( + "git", + "ls-remote", + "--heads", + "origin", + ), + repositoryRoot, + ), + CleanupInvocation( + listOf( + "git", + "push", + "--force-with-lease=refs/heads/feature/sk-101:feature-revision", + "origin", + "--delete", + "feature/sk-101", + ), + repositoryRoot, + ), + ) + } + + test("expectedRevision이 현재 revision과 다르면, 원격 삭제 없이 재시도 가능한 오류를 반환합니다") { + val runner = RecordingCleanupCommandRunner() + runner.enqueue(CommandResult(0, "actual-revision\trefs/heads/feature/sk-101\n", "")) + val repositoryRoot = Path.of("/repo") + val adapter = LocalGitAdapter(repositoryRoot, { Path.of("/workspace") }, runner) + + val result = + adapter.removeRemoteBranch( + RemoveRemoteBranchRequest( + remote = "origin", + branch = "feature/sk-101", + expectedRevision = "expected-revision", + ) + ) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "STALE_REVISION" + failure.error.retryable shouldBe true + runner.commands shouldContainExactly + listOf( + CleanupInvocation( + listOf( + "git", + "ls-remote", + "--heads", + "origin", + ), + repositoryRoot, + ) + ) + } + + test("expectedRevision 대상 Branch가 없으면, 원격 삭제를 실행하지 않습니다") { + val runner = RecordingCleanupCommandRunner() + runner.enqueue(CommandResult(0, "other-revision\trefs/heads/other\n", "")) + val adapter = LocalGitAdapter(Path.of("/repo"), { Path.of("/workspace") }, runner) + + val result = + adapter.removeRemoteBranch( + RemoveRemoteBranchRequest( + remote = "origin", + branch = "feature/sk-101", + expectedRevision = "expected-revision", + ) + ) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "REMOTE_BRANCH_NOT_FOUND" + runner.commands.size shouldBe 1 + } + + test("Git 원격 Branch 삭제가 실패하면, REMOTE_BRANCH_REMOVE_FAILED 오류를 반환합니다") { + val runner = RecordingCleanupCommandRunner() + runner.enqueue(CommandResult(1, "", "remote Branch 삭제가 거부되었습니다")) + val adapter = LocalGitAdapter(Path.of("/repo"), { Path.of("/workspace") }, runner) + + val result = + adapter.removeRemoteBranch( + RemoveRemoteBranchRequest(remote = "origin", branch = "feature/sk-101") + ) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "REMOTE_BRANCH_REMOVE_FAILED" + failure.error.message shouldContain "remote Branch 삭제가 거부되었습니다" + } + + test("remote가 옵션처럼 시작하면, Git 명령을 실행하지 않습니다") { + val runner = RecordingCleanupCommandRunner() + val adapter = LocalGitAdapter(Path.of("/repo"), { Path.of("/workspace") }, runner) + + val result = + adapter.removeRemoteBranch( + RemoveRemoteBranchRequest( + remote = "--upload-pack=evil", + branch = "feature/sk-101", + ) + ) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "INVALID_ARGUMENT" + runner.commands shouldBe emptyList() + } + + test("Branch가 옵션처럼 시작하면, Git 명령을 실행하지 않습니다") { + val runner = RecordingCleanupCommandRunner() + val adapter = LocalGitAdapter(Path.of("/repo"), { Path.of("/workspace") }, runner) + + val result = + adapter.removeRemoteBranch( + RemoveRemoteBranchRequest(remote = "origin", branch = "--delete") + ) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "INVALID_ARGUMENT" + runner.commands shouldBe emptyList() + } + } + + context("Worktree를 제거하면") { + test("Worktree 경로를 입력하면, 해당 경로를 Git CLI에 전달하고 변경 영수증을 반환합니다") { + val runner = RecordingCleanupCommandRunner() + runner.enqueue(CommandResult(0, "", "")) + val repositoryRoot = Path.of("/repo") + val path = WorkspacePath("workspaces/sk-101") + val adapter = LocalGitAdapter(repositoryRoot, { Path.of("/workspace") }, runner) + + val result = adapter.removeWorktree(RemoveWorktreeRequest("workspace-101", path)) + + val response = result.shouldBeTypeOf>().value + response shouldBe + io.springkit.workflow.application.RemoveWorktreeResponse( + workspaceId = "workspace-101", + path = path, + change = + io.springkit.workflow.application.ChangeReceipt( + id = "git-remove-worktree-workspace-101", + operation = "remove-worktree", + ), + ) + runner.commands shouldContainExactly + listOf( + CleanupInvocation( + listOf("git", "worktree", "remove", "workspaces/sk-101"), + repositoryRoot, + ) + ) + } + + test("Git Worktree 삭제가 실패하면, WORKTREE_REMOVE_FAILED 오류를 반환합니다") { + val runner = RecordingCleanupCommandRunner() + runner.enqueue(CommandResult(1, "", "Worktree가 변경되어 있습니다")) + val adapter = LocalGitAdapter(Path.of("/repo"), { Path.of("/workspace") }, runner) + + val result = + adapter.removeWorktree( + RemoveWorktreeRequest("workspace-101", WorkspacePath("workspaces/sk-101")) + ) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "WORKTREE_REMOVE_FAILED" + failure.error.message shouldContain "Worktree가 변경되어 있습니다" + } + } + }) + +private data class CleanupInvocation(val command: List, val workingDirectory: Path) + +private class RecordingCleanupCommandRunner : CommandRunner { + val commands = mutableListOf() + private val results = ArrayDeque() + + fun enqueue(result: CommandResult) { + results.addLast(result) + } + + override fun run(command: List, workingDirectory: Path): CommandResult { + commands += CleanupInvocation(command, workingDirectory) + return results.removeFirst() + } +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/git/LocalGitPublishAdapterTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/git/LocalGitPublishAdapterTest.kt new file mode 100644 index 00000000..7827bdd6 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/git/LocalGitPublishAdapterTest.kt @@ -0,0 +1,136 @@ +package io.springkit.workflow.adapter.git + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.PublishBranchRequest +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import java.nio.file.Path + +class LocalGitPublishAdapterTest : + FunSpec({ + context("Worktree 변경을 게시하면") { + test("staged 변경이 있으면, PR 제목으로 commit하고 upstream Branch를 push합니다") { + val runner = RecordingPublishCommandRunner() + enqueueInspect(runner, "head-1\n", " M src/Main.kt\n", "diff\n") + runner.enqueue(CommandResult(0, "", "")) + runner.enqueue(CommandResult(0, "src/Main.kt\n", "")) + runner.enqueue(CommandResult(0, "[main commit]\n", "")) + runner.enqueue(CommandResult(0, "pushed\n", "")) + runner.enqueue(CommandResult(0, "head-2\n", "")) + enqueueInspect(runner, "head-2\n", "", "") + val repositoryRoot = Path.of("/repo") + val workspace = Path.of("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/workspace/sk-101") + val adapter = LocalGitAdapter(repositoryRoot, { workspace }, runner) + + val result = + adapter.publish( + PublishBranchRequest( + workspaceId = "ws-101", + branch = "sk-101", + commitTitle = "[sk-101] 변경 설명", + ) + ) + + val response = + result + .shouldBeInstanceOf>() + .value + .shouldBeInstanceOf() + response.committed shouldBe true + response.revision shouldBe "head-2" + runner.commands shouldContainExactly + listOf( + PublishInvocation(listOf("git", "rev-parse", "HEAD"), workspace), + PublishInvocation( + listOf("git", "status", "--porcelain=v1", "--untracked-files=all"), + workspace, + ), + PublishInvocation(listOf("git", "diff", "--binary", "HEAD"), workspace), + PublishInvocation( + listOf("git", "ls-files", "--others", "--exclude-standard", "-z"), + workspace, + ), + PublishInvocation(listOf("git", "add", "--all"), workspace), + PublishInvocation(listOf("git", "diff", "--cached", "--name-only"), workspace), + PublishInvocation(listOf("git", "commit", "-m", "[sk-101] 변경 설명"), workspace), + PublishInvocation( + listOf("git", "push", "--set-upstream", "origin", "sk-101"), + workspace, + ), + PublishInvocation(listOf("git", "rev-parse", "HEAD"), workspace), + PublishInvocation(listOf("git", "rev-parse", "HEAD"), workspace), + PublishInvocation( + listOf("git", "status", "--porcelain=v1", "--untracked-files=all"), + workspace, + ), + PublishInvocation(listOf("git", "diff", "--binary", "HEAD"), workspace), + PublishInvocation( + listOf("git", "ls-files", "--others", "--exclude-standard", "-z"), + workspace, + ), + ) + } + + test("staged 변경이 없으면, 빈 commit을 만들지 않고 push만 수행합니다") { + val runner = RecordingPublishCommandRunner() + enqueueInspect(runner, "head-1\n", "", "") + runner.enqueue(CommandResult(0, "", "")) + runner.enqueue(CommandResult(0, "", "")) + runner.enqueue(CommandResult(0, "head-1\n", "")) + runner.enqueue(CommandResult(0, "head-1\n", "")) + enqueueInspect(runner, "head-1\n", "", "") + val workspace = Path.of("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/workspace/sk-102") + val adapter = LocalGitAdapter(Path.of("/repo"), { workspace }, runner) + + val result = + adapter.publish( + PublishBranchRequest( + workspaceId = "ws-102", + branch = "sk-102", + commitTitle = "[sk-102] 변경 설명", + ) + ) + + result + .shouldBeInstanceOf>() + .value + .shouldBeInstanceOf() + .committed shouldBe false + runner.commands.any { + it.command.firstOrNull() == "git" && it.command.contains("commit") + } shouldBe false + } + } + }) + +private data class PublishInvocation(val command: List, val workingDirectory: Path) + +private class RecordingPublishCommandRunner : CommandRunner { + val commands = mutableListOf() + private val results = ArrayDeque() + + fun enqueue(result: CommandResult) { + results.addLast(result) + } + + override fun run(command: List, workingDirectory: Path): CommandResult { + commands += PublishInvocation(command, workingDirectory) + return results.removeFirst() + } +} + +private fun enqueueInspect( + runner: RecordingPublishCommandRunner, + revision: String, + status: String, + diff: String, +) { + runner.enqueue(CommandResult(0, revision, "")) + runner.enqueue(CommandResult(0, status, "")) + runner.enqueue(CommandResult(0, diff, "")) + runner.enqueue(CommandResult(0, "", "")) +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/github/GithubAdapterTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/github/GithubAdapterTest.kt new file mode 100644 index 00000000..0c55d048 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/github/GithubAdapterTest.kt @@ -0,0 +1,1478 @@ +package io.springkit.workflow.adapter.github + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeTypeOf +import io.springkit.workflow.application.AddReviewCommentRequest +import io.springkit.workflow.application.AddReviewCommentResponse +import io.springkit.workflow.application.GetCiRequest +import io.springkit.workflow.application.GetCiResponse +import io.springkit.workflow.application.GetReviewRequest +import io.springkit.workflow.application.GetReviewResponse +import io.springkit.workflow.application.OpenReviewRequest +import io.springkit.workflow.application.OpenReviewResponse +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.ReplyReviewThreadRequest +import io.springkit.workflow.application.ResolveReviewThreadRequest +import io.springkit.workflow.application.StartCiRequest +import io.springkit.workflow.application.StartCiResponse +import io.springkit.workflow.application.UpdateReviewRequest +import io.springkit.workflow.application.UpdateReviewResponse +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.Approval +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.CiStatus +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.ReviewComment +import io.springkit.workflow.domain.ReviewLevel +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.ReviewThread +import io.springkit.workflow.domain.Risk +import java.nio.file.Path + +class GithubAdapterTest : + FunSpec({ + context("PR 변경 전에 gh 계정과 PR 작성자를 비교하면") { + test("불일치 시 두 로그인과 재실행 옵션을 알리고 중단합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "agent-account\n", "")) + runner.enqueue(CommandResult(0, """{"author":{"login":"Jeong-Rae"}}""", "")) + val adapter = GithubReviewAdapter(Path.of("/repo"), runner) + + val failure = adapter.authorizeAccountMismatch("17").shouldBeTypeOf() + + failure.error.code shouldBe "GITHUB_ACCOUNT_MISMATCH" + failure.error.message shouldContain "agent-account" + failure.error.message shouldContain "Jeong-Rae" + failure.error.message shouldContain "--allow-account-mismatch" + runner.commands.map { it.tokens } shouldContainExactly + listOf( + listOf("gh", "api", "user", "--jq", ".login"), + listOf("gh", "pr", "view", "17", "--json", "author"), + ) + } + + test("명시적 override도 불일치를 경고하고 확인 절차만 건너뜁니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "agent-account\n", "")) + runner.enqueue(CommandResult(0, """{"author":{"login":"Jeong-Rae"}}""", "")) + val adapter = GithubReviewAdapter(Path.of("/repo"), runner) + + val warning = + adapter + .authorizeAccountMismatch("17", allowAccountMismatch = true) + .shouldBeTypeOf>() + .value + + warning shouldBe + "현재 gh 사용자 'agent-account'와 PR 작성자 'Jeong-Rae'가 다릅니다. " + + "--allow-account-mismatch로 해당 불일치를 허용해 PR 변경을 계속합니다." + runner.commands.map { it.tokens }.none { it.contains("auth") } shouldBe true + } + + test("같은 계정이면 경고 없이 통과합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "Jeong-Rae\n", "")) + runner.enqueue(CommandResult(0, """{"author":{"login":"jeong-rae"}}""", "")) + val adapter = GithubReviewAdapter(Path.of("/repo"), runner) + + val result = adapter.authorizeAccountMismatch("17") + + result shouldBe PortResult.Success(null) + } + } + + context("GitHub pull request를 열면") { + test("gh pr create와 gh pr view에 요청 토큰을 전달하면, 도메인 revision을 보존합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "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/example/repo/pull/17\n", "")) + runner.enqueue( + CommandResult( + 0, + """ + {"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":true,"baseRefName":"main","headRefName":"sk-27","headRefOid":"abc123"} + """ + .trimIndent(), + "", + ) + ) + val reviewRevision = ReviewRevision("rv-1", 1, "설명") + val changeRevision = ChangeRevision("cr-1", 1, Diff("diff-1")) + val adapter = GithubReviewAdapter(Path.of("/repo"), runner) + + val result = + adapter.open( + OpenReviewRequest( + subTaskId = "sk-27", + title = "기능 추가", + body = "설명", + base = "main", + branch = "sk-27", + risk = Risk.HIGH, + changeRevision = changeRevision, + reviewRevision = reviewRevision, + ) + ) + + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + response.pullRequest.changeRevision shouldBe + changeRevision.copy(providerRevision = "abc123") + response.pullRequest.reviewRevision shouldBe reviewRevision + runner.commands shouldContainExactly + listOf( + Invocation( + listOf( + "gh", + "pr", + "create", + "--draft", + "--title", + "기능 추가", + "--body", + "설명", + "--base", + "main", + "--head", + "sk-27", + ), + Path.of("/repo"), + ), + Invocation( + listOf( + "gh", + "pr", + "view", + "17", + "--json", + "number,title,body,state,isDraft,baseRefName,headRefName,headRefOid,reviewDecision,author,comments,reviews", + ), + Path.of("/repo"), + ), + ) + } + + test("open 결과를 저장한 뒤 같은 head OID를 조회하면, change revision과 상태를 유지합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "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/example/repo/pull/17\n", "")) + runner.enqueue( + CommandResult( + 0, + """{"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":true,"baseRefName":"main","headRefName":"sk-27","headRefOid":"head-1"}""", + "", + ) + ) + var persisted: PullRequest? = null + val adapter = + GithubReviewAdapter( + Path.of("/repo"), + runner, + currentPullRequest = { persisted }, + ) + + val opened = + adapter + .open( + OpenReviewRequest( + subTaskId = "sk-27", + title = "기능 추가", + body = "설명", + base = "main", + branch = "sk-27", + risk = Risk.NORMAL, + changeRevision = ChangeRevision("cr-1", 1, Diff("local-fp")), + reviewRevision = ReviewRevision("rv-1", 1, "설명"), + ) + ) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .pullRequest + opened.changeRevision.providerRevision shouldBe "head-1" + persisted = + opened.copy( + approval = + Approval("ap-1", Actor("human-1", ActorKind.HUMAN), "cr-1", "local-fp"), + ci = CiStatus.PASSED, + ) + runner.enqueue( + CommandResult( + 0, + """{"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":true,"baseRefName":"main","headRefName":"sk-27","headRefOid":"head-1"}""", + "", + ) + ) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + + val current = + adapter + .get(GetReviewRequest("17")) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .pullRequest + + current.changeRevision.id shouldBe "cr-1" + current.changeRevision.number shouldBe 1 + current.changeRevision.providerRevision shouldBe "head-1" + current.ci shouldBe CiStatus.PASSED + current.approval shouldBe persisted.approval + } + } + + context("GitHub pull request를 조회하면") { + test("GitHub JSON을 kotlinx.serialization으로 변환하면, pull request 상태와 revision을 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + """ + {"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":false,"baseRefName":"main","headRefName":"sk-27","headRefOid":"abc123","reviewDecision":"APPROVED"} + """ + .trimIndent(), + "", + ) + ) + runner.enqueue( + CommandResult( + 0, + reviewThreadsResponse( + threadId = "PRRT_external", + commentId = "PRRC_external", + databaseId = 201, + body = "[R] 외부에서 확인이 필요합니다", + path = "src/Main.kt", + line = 8, + authorAssociation = "COLLABORATOR", + ), + "", + ) + ) + val result = GithubReviewAdapter(Path.of("/repo"), runner).get(GetReviewRequest("17")) + + val pullRequest = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .pullRequest + pullRequest.id shouldBe "17" + pullRequest.subTaskId shouldBe "sk-27" + pullRequest.state.name shouldBe "APPROVED" + pullRequest.changeRevision.diff.identity shouldBe "github-untracked-pr-17" + pullRequest.changeRevision.providerRevision shouldBe "abc123" + pullRequest.reviewRevision.threads.single().id shouldBe "PRRT_external" + pullRequest.reviewRevision.threads.single().comments.single().id shouldBe "PRRC_external" + pullRequest.reviewRevision.threads.single().comments.single().author.kind shouldBe + ActorKind.HUMAN + runner.commands[1] + .tokens + .first { it.startsWith("query=") } + .contains("authorAssociation") shouldBe true + } + + test("PR review thread는 Agent 접두사가 사람 작성자 메타데이터보다 우선합니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + """{"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":false,"baseRefName":"main","headRefName":"sk-27","headRefOid":"abc123"}""", + "", + ) + ) + runner.enqueue( + CommandResult( + 0, + reviewThreadsResponse( + threadId = "PRRT_external", + commentId = "PRRC_external", + databaseId = 201, + body = "[Agent] [R] 자동 검토 의견", + path = "src/Main.kt", + line = 8, + authorLogin = "human-1", + authorAssociation = "COLLABORATOR", + ), + "", + ) + ) + + val pullRequest = + GithubReviewAdapter(Path.of("/repo"), runner, humanActorIds = setOf("human-1")) + .get(GetReviewRequest("17")) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .pullRequest + + pullRequest.reviewRevision.threads.single().comments.single().author.kind shouldBe + ActorKind.AGENT + } + + test("저장된 provider SHA와 GitHub head OID가 다르면, 새 change revision을 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + """{"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":false,"baseRefName":"main","headRefName":"sk-27","headRefOid":"diff-2"}""", + "", + ) + ) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + val result = + GithubReviewAdapter( + Path.of("/repo"), + runner, + currentPullRequest = { pullRequest() }, + ) + .get(GetReviewRequest("17")) + + val current = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .pullRequest + current.changeRevision.number shouldBe 2 + current.changeRevision.id shouldBe "github-change-17-2" + current.changeRevision.diff.identity shouldBe "local-fingerprint" + current.changeRevision.providerRevision shouldBe "diff-2" + current.ci.name shouldBe "PENDING" + } + + test("provider SHA가 저장되지 않았으면 diff identity가 같아도 코드 변경을 보수적으로 판단합니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + """{"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":false,"baseRefName":"main","headRefName":"sk-27","headRefOid":"diff-1"}""", + "", + ) + ) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + val legacy = + pullRequest() + .copy( + changeRevision = ChangeRevision("cr-1", 1, Diff("diff-1")), + ) + + val current = + GithubReviewAdapter(Path.of("/repo"), runner, currentPullRequest = { legacy }) + .get(GetReviewRequest("17")) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .pullRequest + + current.changeRevision.number shouldBe 2 + current.changeRevision.providerRevision shouldBe "diff-1" + } + + test("GitHub review thread가 바뀌면, 새 review revision을 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + """{"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":false,"baseRefName":"main","headRefName":"sk-27","headRefOid":"diff-1"}""", + "", + ) + ) + runner.enqueue( + CommandResult( + 0, + reviewThreadsResponse( + threadId = "PRRT_external", + commentId = "PRRC_external", + databaseId = 201, + body = "[R] 외부에서 확인이 필요합니다", + path = "src/Main.kt", + line = 8, + ), + "", + ) + ) + val previous = + pullRequest() + .copy( + reviewRevision = + ReviewRevision( + "rv-1", + 1, + "설명", + threads = + listOf( + ReviewThread( + "PRRT_previous", + ReviewLevel.R, + listOf( + ReviewComment( + "PRRC_previous", + Actor("human-1", ActorKind.HUMAN), + "[R] 이전 의견", + path = "src/Main.kt", + line = 8, + ) + ), + ) + ), + ) + ) + + val result = + GithubReviewAdapter( + Path.of("/repo"), + runner, + currentPullRequest = { previous }, + ) + .get(GetReviewRequest("17")) + + val current = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .pullRequest + current.reviewRevision.id shouldBe "github-review-17-2" + current.reviewRevision.number shouldBe 2 + current.reviewRevision.threads.single().id shouldBe "PRRT_external" + } + + test("동일한 GitHub review thread를 다시 조회하면, review revision을 유지합니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + """{"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":false,"baseRefName":"main","headRefName":"sk-27","headRefOid":"diff-1"}""", + "", + ) + ) + runner.enqueue( + CommandResult( + 0, + reviewThreadsResponse( + threadId = "PRRT_external", + commentId = "PRRC_external", + databaseId = 201, + body = "[R] 외부에서 확인이 필요합니다", + path = "src/Main.kt", + line = 8, + ), + "", + ) + ) + val previous = + pullRequest() + .copy( + reviewRevision = + ReviewRevision( + "rv-1", + 1, + "설명", + threads = + listOf( + ReviewThread( + "PRRT_external", + ReviewLevel.R, + listOf( + ReviewComment( + "PRRC_external", + Actor("reviewer-1", ActorKind.HUMAN, "Reviewer"), + "[R] 외부에서 확인이 필요합니다", + createdAtEpochMillis = 1_790_035_200_000, + path = "src/Main.kt", + line = 8, + ) + ), + ) + ), + ) + ) + + val result = + GithubReviewAdapter( + Path.of("/repo"), + runner, + currentPullRequest = { previous }, + ) + .get(GetReviewRequest("17")) + + val current = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .pullRequest + current.reviewRevision.id shouldBe "rv-1" + current.reviewRevision.number shouldBe 1 + } + } + + context("Review 코멘트를 추가하면") { + test("일반 pull request 코멘트를 추가하면, issue comment를 도메인 thread로 복원합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, """{"id":501,"node_id":"IC_remote"}""", "")) + runner.enqueue( + CommandResult( + 0, + """ + { + "number":17, + "title":"기능 추가", + "body":"설명", + "state":"OPEN", + "isDraft":false, + "baseRefName":"main", + "headRefName":"sk-27", + "headRefOid":"diff-1", + "comments":[{"id":"IC_remote","databaseId":501,"body":"[C] 확인했습니다","author":{"login":"human-1"},"createdAt":"2026-09-22T00:00:00Z"}] + } + """ + .trimIndent(), + "", + ) + ) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + val comment = ReviewComment("comment-1", Actor("human-1", ActorKind.HUMAN), "확인했습니다") + val request = + AddReviewCommentRequest("17", "rv-1", comment.author, ReviewLevel.C, comment) + + val result = + GithubReviewAdapter( + Path.of("/repo"), + runner, + currentPullRequest = { pullRequest() }, + ) + .comment(request) + + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + response.threadId shouldBe "github-issue-comment-IC_remote" + response.reviewRevision.threads.single().comments.single().id shouldBe "IC_remote" + runner.commands.first().tokens shouldBe + listOf( + "gh", + "api", + "--method", + "POST", + "repos/{owner}/{repo}/issues/17/comments", + "-f", + "body=[C] 확인했습니다", + ) + } + + test("코드 줄과 수준을 지정하면, provider revision으로 리뷰 코멘트를 생성합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, """{"id":101,"node_id":"PRRC_remote"}""", "")) + runner.enqueue( + CommandResult( + 0, + """{"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":false,"baseRefName":"main","headRefName":"sk-27","headRefOid":"diff-1"}""", + "", + ) + ) + runner.enqueue( + CommandResult( + 0, + reviewThreadsResponse( + threadId = "PRRT_remote", + commentId = "PRRC_remote", + databaseId = 101, + body = "[Agent] [R] 수정이 필요합니다", + path = "src/Main.kt", + line = 12, + ), + "", + ) + ) + val current = + pullRequest() + .copy( + changeRevision = + pullRequest().changeRevision.copy(providerRevision = "head-1") + ) + val comment = + ReviewComment( + "comment-1", + Actor("agent-1", ActorKind.AGENT), + "[Agent] 수정이 필요합니다", + path = "src/Main.kt", + line = 12, + ) + val adapter = + GithubReviewAdapter( + Path.of("/repo"), + runner, + currentPullRequest = { current }, + nowEpochMillis = { 1_000 }, + ) + + val response = + adapter.comment( + AddReviewCommentRequest("17", "rv-1", comment.author, ReviewLevel.R, comment) + ) + + runner.commands.first().tokens shouldBe + listOf( + "gh", + "api", + "--method", + "POST", + "repos/{owner}/{repo}/pulls/17/comments", + "-f", + "body=[Agent] [R] 수정이 필요합니다", + "-f", + "commit_id=head-1", + "-f", + "path=src/Main.kt", + "-F", + "line=12", + "-f", + "side=RIGHT", + ) + response + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .threadId shouldBe "PRRT_remote" + } + + test("provider SHA가 없으면 코드 줄 코멘트를 GitHub에 게시하지 않습니다") { + val runner = RecordingCommandRunner() + val current = + pullRequest() + .copy( + changeRevision = ChangeRevision("cr-1", 1, Diff("local-fingerprint")), + ) + val comment = + ReviewComment( + "comment-1", + Actor("agent-1", ActorKind.AGENT), + "[Agent] 수정이 필요합니다", + path = "src/Main.kt", + line = 12, + ) + + val result = + GithubReviewAdapter(Path.of("/repo"), runner, currentPullRequest = { current }) + .comment( + AddReviewCommentRequest("17", "rv-1", comment.author, ReviewLevel.R, comment) + ) + + result.shouldBeTypeOf().error.code shouldBe + "GITHUB_REVIEW_REVISION_UNAVAILABLE" + runner.commands shouldBe emptyList() + } + } + + context("GitHub review thread를 답변하거나 해결하면") { + test("provider review thread node ID를 전달하면, 답변 mutation에 같은 ID를 사용합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "{}", "")) + runner.enqueue( + CommandResult( + 0, + """{"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":false,"baseRefName":"main","headRefName":"sk-27","headRefOid":"diff-1"}""", + "", + ) + ) + runner.enqueue( + CommandResult( + 0, + reviewThreadsResponse( + threadId = "PRRT_remote", + commentId = "PRRC_remote", + databaseId = 301, + body = "[C] 답변\n\n", + path = "src/Main.kt", + line = 12, + ), + "", + ) + ) + val current = remoteThreadPullRequest() + val result = + GithubReviewAdapter(Path.of("/repo"), runner, currentPullRequest = { current }) + .reply( + ReplyReviewThreadRequest( + pullRequestId = "17", + reviewRevisionId = "rv-1", + threadId = "PRRT_remote", + comment = + ReviewComment("reply-1", Actor("agent-1", ActorKind.AGENT), "답변"), + ) + ) + + result.shouldBeTypeOf>() + runner.commands[0].tokens shouldContainExactly + listOf( + "gh", + "api", + "graphql", + "-f", + "query=mutation(\u0024subjectId:ID!,\u0024body:String!){addPullRequestReviewThreadReply(input:{pullRequestReviewThreadId:\u0024subjectId,body:\u0024body}){comment{id}}}", + "-f", + "subjectId=PRRT_remote", + "-f", + "body=[C] 답변\n\n", + ) + } + + test("같은 ID와 본문의 원격 답글만 있으면, 기존 revision을 복구할 수 있습니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + issueCommentsPullRequest( + "[R] 확인이 필요합니다", + "[Agent] [R] 답변\n\n\n\n", + ), + "", + ) + ) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + val current = issueCommentPullRequest() + val result = + GithubReviewAdapter(Path.of("/repo"), runner, currentPullRequest = { current }) + .recoverReply( + ReplyReviewThreadRequest( + "17", + "rv-1", + "github-issue-comment-IC_root", + ReviewComment("reply-1", Actor("agent-1", ActorKind.AGENT), "[Agent] 답변"), + ), + ) + + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + response.reviewRevision.number shouldBe 2 + response.reviewRevision.threads.single().comments.map { it.body } shouldBe + listOf("[R] 확인이 필요합니다", "[Agent] [R] 답변") + } + + test("답글 외에 다른 스레드가 바뀌면, 원격 revision 복구를 거부합니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + """{"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":false,"baseRefName":"main","headRefName":"sk-27","headRefOid":"diff-1","comments":[{"id":"IC_root","node_id":"IC_root","body":"[R] 확인이 필요합니다","author":{"login":"agent-1"},"authorAssociation":"BOT","createdAt":"2026-09-22T00:00:00Z"},{"id":"IC_reply","node_id":"IC_reply","body":"[Agent] [R] 답변\n\n\n\n","author":{"login":"agent-1"},"authorAssociation":"BOT","createdAt":"2026-09-22T00:01:00Z"},{"id":"IC_other","node_id":"IC_other","body":"[C] 새 스레드","author":{"login":"agent-1"},"authorAssociation":"BOT","createdAt":"2026-09-22T00:02:00Z"}]}""", + "", + ) + ) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + val current = issueCommentPullRequest() + val result = + GithubReviewAdapter(Path.of("/repo"), runner, currentPullRequest = { current }) + .recoverReply( + ReplyReviewThreadRequest( + "17", + "rv-1", + "github-issue-comment-IC_root", + ReviewComment("reply-1", Actor("agent-1", ActorKind.AGENT), "[Agent] 답변"), + ), + ) + + result.shouldBeTypeOf>().value shouldBe null + } + + test("같은 답글 ID에 다른 본문이 있으면, ID 충돌로 거부합니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + issueCommentsPullRequest( + "[R] 확인이 필요합니다", + "[Agent] [R] 다른 답변\n\n\n\n", + ), + "", + ) + ) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + val current = issueCommentPullRequest() + val result = + GithubReviewAdapter(Path.of("/repo"), runner, currentPullRequest = { current }) + .recoverReply( + ReplyReviewThreadRequest( + "17", + "rv-1", + "github-issue-comment-IC_root", + ReviewComment("reply-1", Actor("agent-1", ActorKind.AGENT), "[Agent] 답변"), + ), + ) + + result.shouldBeTypeOf().error.code shouldBe + "GITHUB_REPLY_IDEMPOTENCY_CONFLICT" + } + + test("Agent 접두사가 있는 HUMAN 계정 코멘트는 agent가 해결할 수 있습니다") { + val runner = RecordingCommandRunner() + val providerComment = + issueCommentsPullRequest( + "[Agent] [C] 자동 검토 의견", + authorLogin = "Human-1", + authorAssociation = "COLLABORATOR", + ) + runner.enqueue(CommandResult(0, providerComment, "")) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + runner.enqueue(CommandResult(0, providerComment, "")) + runner.enqueue(CommandResult(0, "{}", "")) + runner.enqueue( + CommandResult( + 0, + issueCommentsPullRequest( + "[Agent] [C] 자동 검토 의견\n\n", + authorLogin = "Human-1", + authorAssociation = "COLLABORATOR", + ), + "", + ) + ) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + var current: PullRequest? = null + val adapter = + GithubReviewAdapter( + Path.of("/repo"), + runner, + currentPullRequest = { current }, + humanActorIds = setOf("human-1"), + ) + + val pullRequest = + adapter + .get(GetReviewRequest("17")) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .pullRequest + current = pullRequest + val thread = pullRequest.reviewRevision.threads.single() + + thread.comments.single().author.kind shouldBe ActorKind.AGENT + thread.requiresHumanResolution shouldBe false + val result = + adapter.resolve( + ResolveReviewThreadRequest( + pullRequestId = "17", + reviewRevisionId = pullRequest.reviewRevision.id, + threadId = thread.id, + actor = Actor("agent-1", ActorKind.AGENT), + ) + ) + + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + response.reviewRevision.threads.single().state.name shouldBe "RESOLVED" + runner.commands.size shouldBe 6 + } + + test("Agent 접두사가 없는 HUMAN 계정 코멘트는 agent 해결을 거부합니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + issueCommentsPullRequest( + "[C] 사람 검토 의견", + authorLogin = "human-1", + authorAssociation = "COLLABORATOR", + ), + "", + ) + ) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + var current: PullRequest? = null + val adapter = + GithubReviewAdapter( + Path.of("/repo"), + runner, + currentPullRequest = { current }, + humanActorIds = setOf("human-1"), + ) + val pullRequest = + adapter + .get(GetReviewRequest("17")) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .pullRequest + current = pullRequest + val thread = pullRequest.reviewRevision.threads.single() + + thread.comments.single().author.kind shouldBe ActorKind.HUMAN + thread.requiresHumanResolution shouldBe true + val result = + adapter.resolve( + ResolveReviewThreadRequest( + pullRequestId = "17", + reviewRevisionId = pullRequest.reviewRevision.id, + threadId = thread.id, + actor = Actor("agent-1", ActorKind.AGENT), + ) + ) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "HUMAN_REQUIRED" + runner.commands.size shouldBe 2 + } + + test("일반 pull request 코멘트에 답변하면, root thread에 답글을 묶어 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, issueCommentsPullRequest("[R] 확인이 필요합니다"), "")) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + runner.enqueue(CommandResult(0, "{\"id\":502,\"node_id\":\"IC_reply\"}", "")) + runner.enqueue( + CommandResult( + 0, + issueCommentsPullRequest( + "[R] 확인이 필요합니다", + "[R] 답변\n\n\n\n", + ), + "", + ) + ) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + val current = issueCommentPullRequest() + val result = + GithubReviewAdapter(Path.of("/repo"), runner, currentPullRequest = { current }) + .reply( + ReplyReviewThreadRequest( + pullRequestId = "17", + reviewRevisionId = "rv-1", + threadId = "github-issue-comment-IC_root", + comment = + ReviewComment("reply-1", Actor("agent-1", ActorKind.AGENT), "답변"), + ) + ) + + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + response.reviewRevision.threads.single().comments.map { it.body } shouldBe + listOf("[R] 확인이 필요합니다", "[R] 답변") + runner.commands[2].tokens shouldContainExactly + listOf( + "gh", + "api", + "--method", + "POST", + "repos/{owner}/{repo}/issues/17/comments", + "-f", + "body=[R] 답변\n\n\n\n", + ) + } + + test("답글 게시 뒤 재조회가 실패하고 같은 답글을 재시도하면, 기존 답글을 재사용합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, issueCommentsPullRequest("[R] 확인이 필요합니다"), "")) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + runner.enqueue(CommandResult(0, "{\"id\":502,\"node_id\":\"IC_reply\"}", "")) + runner.enqueue(CommandResult(1, "", "temporary fetch failure")) + runner.enqueue( + CommandResult( + 0, + issueCommentsPullRequest( + "[R] 확인이 필요합니다", + "[R] 답변\n\n\n\n", + ), + "", + ) + ) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + val current = issueCommentPullRequest() + val adapter = + GithubReviewAdapter(Path.of("/repo"), runner, currentPullRequest = { current }) + val request = + ReplyReviewThreadRequest( + pullRequestId = "17", + reviewRevisionId = "rv-1", + threadId = "github-issue-comment-IC_root", + comment = ReviewComment("reply-1", Actor("agent-1", ActorKind.AGENT), "답변"), + ) + + adapter.reply(request).shouldBeTypeOf() + val retry = adapter.reply(request) + + val response = + retry + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + response.reviewRevision.threads.single().comments.map { it.body } shouldBe + listOf("[R] 확인이 필요합니다", "[R] 답변") + runner.commands.count { + it.tokens.firstOrNull() == "gh" && + it.tokens.getOrNull(1) == "api" && + it.tokens.getOrNull(3) == "POST" + } shouldBe 1 + } + + test("일반 pull request 코멘트를 해결하면, 숨은 marker로 해결 상태를 복원합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, issueCommentsPullRequest("[C] 확인했습니다"), "")) + runner.enqueue(CommandResult(0, "{}", "")) + runner.enqueue( + CommandResult( + 0, + issueCommentsPullRequest( + "[C] 확인했습니다\n\n" + ), + "", + ) + ) + runner.enqueue(CommandResult(0, emptyReviewThreadsResponse(), "")) + val current = issueCommentPullRequest() + val result = + GithubReviewAdapter(Path.of("/repo"), runner, currentPullRequest = { current }) + .resolve( + ResolveReviewThreadRequest( + pullRequestId = "17", + reviewRevisionId = "rv-1", + threadId = "github-issue-comment-IC_root", + actor = Actor("agent-1", ActorKind.AGENT), + ) + ) + + val thread = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .reviewRevision + .threads + .single() + thread.state.name shouldBe "RESOLVED" + thread.comments.single().body shouldBe "[C] 확인했습니다" + runner.commands[1].tokens shouldContainExactly + listOf( + "gh", + "api", + "graphql", + "-f", + "query=mutation(\u0024issueCommentId:ID!,\u0024body:String!){updateIssueComment(input:{id:\u0024issueCommentId,body:\u0024body}){issueComment{id}}}", + "-f", + "issueCommentId=IC_root", + "-f", + "body=[C] 확인했습니다\n\n", + ) + } + + test("provider review thread node ID를 전달하면, resolve mutation에 같은 ID를 사용합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "{}", "")) + runner.enqueue( + CommandResult( + 0, + """{"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":false,"baseRefName":"main","headRefName":"sk-27","headRefOid":"diff-1"}""", + "", + ) + ) + runner.enqueue( + CommandResult( + 0, + reviewThreadsResponse( + threadId = "PRRT_remote", + commentId = "PRRC_remote", + databaseId = 301, + body = "[C] 확인했습니다", + path = "src/Main.kt", + line = 12, + resolved = true, + ), + "", + ) + ) + val result = + GithubReviewAdapter( + Path.of("/repo"), + runner, + currentPullRequest = { remoteThreadPullRequest() }, + ) + .resolve( + ResolveReviewThreadRequest( + pullRequestId = "17", + reviewRevisionId = "rv-1", + threadId = "PRRT_remote", + actor = Actor("human-1", ActorKind.HUMAN), + ) + ) + + result.shouldBeTypeOf>() + runner.commands[0].tokens shouldContainExactly + listOf( + "gh", + "api", + "graphql", + "-f", + "query=mutation(\u0024threadId:ID!){resolveReviewThread(input:{threadId:\u0024threadId}){thread{id}}}", + "-f", + "threadId=PRRT_remote", + ) + } + } + + context("GitHub pull request를 갱신하면") { + test("Stack 기준 Branch가 바뀌면, gh pr edit에 새 base를 전달합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "", "")) + runner.enqueue( + CommandResult( + 0, + """{"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":false,"baseRefName":"main","headRefName":"sk-27","headRefOid":"diff-1"}""", + "", + ) + ) + val adapter = + GithubReviewAdapter( + Path.of("/repo"), + runner, + currentPullRequest = { pullRequest().copy(base = "sk-26") }, + ) + + val result = + adapter.update( + UpdateReviewRequest( + pullRequestId = "17", + expectedReviewRevisionId = "rv-1", + base = "main", + ) + ) + + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + response.pullRequest.base shouldBe "main" + runner.commands.first().tokens shouldBe listOf("gh", "pr", "edit", "17", "--base", "main") + } + + test("애플리케이션이 revision을 발급하면, GitHub 조회 뒤 해당 revision을 보존합니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + """{"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":false,"baseRefName":"main","headRefName":"sk-27","headRefOid":"diff-2"}""", + "", + ) + ) + val before = pullRequest() + val nextReview = ReviewRevision("rv-2", 2, "설명") + val nextChange = ChangeRevision("cr-2", 2, Diff("diff-2"), providerRevision = "diff-2") + val adapter = + GithubReviewAdapter( + Path.of("/repo"), + runner, + currentPullRequest = { before }, + ) + + val result = + adapter.update( + UpdateReviewRequest( + pullRequestId = "17", + expectedReviewRevisionId = "rv-1", + changeRevision = nextChange, + reviewRevision = nextReview, + ) + ) + + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + response.pullRequest.reviewRevision shouldBe nextReview + response.pullRequest.changeRevision shouldBe nextChange + response.codeChanged shouldBe true + } + } + + context("gh 명령이 실패하면") { + test("종료 코드가 0이 아니면, 종료 코드와 stderr를 PortError로 변환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(1, "", "pull request not found")) + + val result = GithubReviewAdapter(Path.of("/repo"), runner).get(GetReviewRequest("404")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "GITHUB_COMMAND_FAILED" + failure.error.message shouldBe "pull request not found" + runner.commands.size shouldBe 1 + } + + test("gh 프로세스를 시작할 수 없으면, 재시도 가능한 PortError를 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueueFailure(IllegalStateException("gh를 찾을 수 없습니다")) + + val result = GithubReviewAdapter(Path.of("/repo"), runner).get(GetReviewRequest("17")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "GITHUB_COMMAND_FAILED" + failure.error.retryable shouldBe true + } + + test("revision이 오래되면, gh 명령을 실행하지 않고 거부합니다") { + val runner = RecordingCommandRunner() + val current = pullRequest() + val adapter = + GithubReviewAdapter( + Path.of("/repo"), + runner, + currentPullRequest = { current }, + ) + + val result = + adapter.update( + UpdateReviewRequest( + pullRequestId = "17", + expectedReviewRevisionId = "rv-0", + body = "새 설명", + ) + ) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "STALE_REVISION" + runner.commands shouldBe emptyList() + } + } + + context("GitHub CI 상태를 조회하면") { + test("statusCheckRollup이 성공 또는 실패 상태이면, CiRun과 CiStatus로 변환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + """ + {"headRefOid":"abc123","statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"SUCCESS","databaseId":1},{"name":"test","status":"COMPLETED","conclusion":"FAILURE","databaseId":2}]} + """ + .trimIndent(), + "", + ) + ) + + val result = GithubCiAdapter(Path.of("/repo"), runner).get(GetCiRequest("17", "abc123")) + + val response = + result.shouldBeTypeOf>().value.shouldBeTypeOf() + response.status.name shouldBe "FAILED" + response.runs.map { it.id } shouldBe listOf("1", "2") + runner.commands.single().tokens shouldBe + listOf("gh", "pr", "view", "17", "--json", "headRefOid,statusCheckRollup") + } + + test("COMPLETED 상태에 conclusion이 없으면, 성공으로 간주하지 않습니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + """{"headRefOid":"abc123","statusCheckRollup":[{"name":"build","status":"COMPLETED"}]}""", + "", + ) + ) + + val response = + GithubCiAdapter(Path.of("/repo"), runner) + .get(GetCiRequest("17", "abc123")) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + + response.runs.single().status.name shouldBe "PENDING" + response.status.name shouldBe "PENDING" + } + + test("CheckRun conclusion 없이 상태형 status context를 성공과 실패로 변환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + """{"headRefOid":"abc123","statusCheckRollup":[{"context":"legacy-success","state":"SUCCESS"},{"context":"legacy-failure","state":"FAILURE"},{"context":"legacy-error","state":"ERROR"}]}""", + "", + ) + ) + + val response = + GithubCiAdapter(Path.of("/repo"), runner) + .get(GetCiRequest("17", "abc123")) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + + response.runs.map { it.status.name } shouldBe listOf("PASSED", "FAILED", "FAILED") + response.status.name shouldBe "FAILED" + } + + test("CI를 시작하면, statusCheckRollup 조회와 변경 영수증을 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue( + CommandResult(0, "{\"headRefOid\":\"abc123\",\"statusCheckRollup\":[]}", "") + ) + + val result = + GithubCiAdapter(Path.of("/repo"), runner).start(StartCiRequest("17", "abc123")) + + val response = + result.shouldBeTypeOf>().value.shouldBeTypeOf() + response.run.status.name shouldBe "PENDING" + response.change.operation shouldBe "github-ci-start" + } + + test("CI 조회용 gh 프로세스를 시작할 수 없으면, 재시도 가능한 실패를 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueueFailure(IllegalStateException("gh를 찾을 수 없습니다")) + + val result = GithubCiAdapter(Path.of("/repo"), runner).get(GetCiRequest("17")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "GITHUB_CI_FAILED" + failure.error.retryable shouldBe true + } + } + }) + +private fun pullRequest(): PullRequest = + PullRequest( + id = "17", + subTaskId = "sk-27", + title = "기능 추가", + body = "설명", + base = "main", + state = PullRequestState.REVIEW, + reviewRevision = ReviewRevision("rv-1", 1, "설명"), + changeRevision = + ChangeRevision("cr-1", 1, Diff("local-fingerprint"), providerRevision = "diff-1"), + ) + +private fun remoteThreadPullRequest(): PullRequest = + pullRequest() + .copy( + reviewRevision = + ReviewRevision( + "rv-1", + 1, + "설명", + threads = + listOf( + ReviewThread( + id = "PRRT_remote", + level = ReviewLevel.C, + comments = + listOf( + ReviewComment( + id = "PRRC_remote", + author = Actor("human-1", ActorKind.HUMAN), + body = "[C] 확인했습니다", + path = "src/Main.kt", + line = 12, + ) + ), + ) + ), + ) + ) + +private fun issueCommentPullRequest(): PullRequest = + pullRequest() + .copy( + reviewRevision = + ReviewRevision( + "rv-1", + 1, + "설명", + threads = + listOf( + ReviewThread( + id = "github-issue-comment-IC_root", + level = ReviewLevel.R, + comments = + listOf( + ReviewComment( + "IC_root", + Actor("agent-1", ActorKind.AGENT), + "[R] 확인이 필요합니다", + createdAtEpochMillis = 1_790_035_200_000, + ) + ), + ) + ), + ), + ) + +private fun issueCommentsPullRequest( + rootBody: String, + replyBody: String? = null, + authorLogin: String = "agent-1", + authorAssociation: String = "BOT", +): String { + fun encode(value: String): String = + value.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n") + val comments = + listOfNotNull( + """{"id":"IC_root","node_id":"IC_root","body":"${encode(rootBody)}","author":{"login":"$authorLogin"},"authorAssociation":"$authorAssociation","createdAt":"2026-09-22T00:00:00Z"}""", + replyBody?.let { + """{"id":"IC_reply","node_id":"IC_reply","databaseId":502,"body":"${encode(it)}","author":{"login":"agent-1"},"authorAssociation":"BOT","createdAt":"2026-09-22T00:01:00Z"}""" + }, + ) + .joinToString(",") + return """{"number":17,"title":"기능 추가","body":"설명","state":"OPEN","isDraft":false,"baseRefName":"main","headRefName":"sk-27","headRefOid":"diff-1","comments":[$comments]}""" +} + +private fun reviewThreadsResponse( + threadId: String, + commentId: String, + databaseId: Long, + body: String, + path: String, + line: Int, + authorLogin: String = "reviewer-1", + authorAssociation: String = "COLLABORATOR", + resolved: Boolean = false, +): String = + """ + [{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[{"id":"$threadId","isResolved":$resolved,"comments":{"nodes":[{"id":"$commentId","databaseId":$databaseId,"body":"$body","author":{"login":"$authorLogin","name":"Reviewer"},"authorAssociation":"$authorAssociation","createdAt":"2026-09-22T00:00:00Z","path":"$path","line":$line}]} }],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}}] + """ + .trimIndent() + +private fun emptyReviewThreadsResponse(): String = + """ + [{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}}}] + """ + .trimIndent() + +private data class Invocation(val tokens: List, val workingDirectory: Path) + +private class RecordingCommandRunner : CommandRunner { + private val results = ArrayDeque<() -> CommandResult>() + val commands = mutableListOf() + + fun enqueue(result: CommandResult) { + results.addLast { result } + } + + fun enqueueFailure(failure: RuntimeException) { + results.addLast { throw failure } + } + + override fun run(command: List, workingDirectory: Path): CommandResult { + commands += Invocation(command, workingDirectory) + return results.removeFirst().invoke() + } +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/github/GithubIssueTaskAdapterTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/github/GithubIssueTaskAdapterTest.kt new file mode 100644 index 00000000..95e75209 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/github/GithubIssueTaskAdapterTest.kt @@ -0,0 +1,222 @@ +package io.springkit.workflow.adapter.github + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeTypeOf +import io.springkit.workflow.application.CreateSubTaskRequest +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.SubTaskLookupRequest +import io.springkit.workflow.application.TaskLookupRequest +import io.springkit.workflow.application.UpdateExternalSubTaskRequest +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.domain.ExternalTaskId +import io.springkit.workflow.domain.SubTaskState +import java.nio.file.Path + +class GithubIssueTaskAdapterTest : + FunSpec({ + context("GitHub Issue를 외부 Task로 조회하면") { + test("gh issue view 응답을 입력하면, Task 상태로 변환합니다") { + val runner = IssueRecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + """ + {"number":42,"title":"추천 알고리즘 실험","state":"OPEN","body":"","url":"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/acme/repo/issues/42"} + """ + .trimIndent(), + "", + ) + ) + + val result = + GithubIssueTaskAdapter(Path.of("/repo"), runner) + .get(TaskLookupRequest(externalId = ExternalTaskId("42"))) + val task = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .task + + task.id shouldBe "42" + task.externalId shouldBe ExternalTaskId("42") + task.title shouldBe "추천 알고리즘 실험" + task.state.name shouldBe "OPEN" + runner.commands.single().tokens shouldContainExactly + listOf( + "gh", + "issue", + "view", + "42", + "--json", + "number,title,state,stateReason,body,url", + ) + } + } + + context("GitHub Issue SubTask를 조회하면") { + test("metadata와 상태 marker를 입력하면, SubTask로 변환합니다") { + val runner = IssueRecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + """ + [{"number":51,"title":"경계 추가","state":"OPEN","body":"\n\n\n\n"}] + """ + .trimIndent(), + "", + ) + ) + + val result = + GithubIssueTaskAdapter(Path.of("/repo"), runner) + .getSubTask(SubTaskLookupRequest("sk-101")) + val subTask = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .subTask + + subTask.id shouldBe "sk-101" + subTask.taskId shouldBe "42" + subTask.state shouldBe SubTaskState.REVIEW + subTask.requires shouldBe "sk-100" + } + } + + context("GitHub Issue SubTask를 생성하면") { + test("외부 Task를 확인하고 issue를 생성하면, 생성 결과 metadata를 검증합니다") { + val runner = IssueRecordingCommandRunner() + runner.enqueue( + CommandResult(0, """{"number":42,"title":"부모","state":"OPEN","body":""}""", "") + ) + runner.enqueue(CommandResult(0, "[]", "")) + runner.enqueue(CommandResult(0, "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/acme/repo/issues/51\n", "")) + runner.enqueue( + CommandResult( + 0, + """ + {"number":51,"title":"경계 추가","state":"OPEN","body":"\n\n\n"} + """ + .trimIndent(), + "", + ) + ) + + val result = + GithubIssueTaskAdapter(Path.of("/repo"), runner) + .createSubTask( + CreateSubTaskRequest( + externalTaskId = ExternalTaskId("42"), + subTaskId = "sk-101", + title = "경계 추가", + requestId = "start-1", + ) + ) + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + + response.task.subTaskIds shouldBe listOf("sk-101") + response.subTask.taskId shouldBe "42" + runner.commands.map { it.tokens.take(4) } shouldBe + listOf( + listOf("gh", "issue", "view", "42"), + listOf("gh", "issue", "list", "--state"), + listOf("gh", "issue", "create", "--title"), + listOf("gh", "issue", "view", "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/acme/repo/issues/51"), + ) + runner.commands[2].tokens[6] shouldContain "workflow-subtask-id: sk-101" + } + } + + context("외부 SubTask 상태를 갱신하면") { + test("metadata를 보존한 채 외부 SubTask 상태를 수정하면, merge 시 issue를 닫습니다") { + val runner = IssueRecordingCommandRunner() + runner.enqueue( + CommandResult( + 0, + """ + [{"number":51,"title":"경계 추가","state":"OPEN","body":"\n\n\n"}] + """ + .trimIndent(), + "", + ) + ) + runner.enqueue( + CommandResult( + 0, + """{"number":42,"title":"부모","state":"OPEN","body":""}""", + "", + ) + ) + runner.enqueue(CommandResult(0, "", "")) + runner.enqueue(CommandResult(0, "", "")) + + val result = + GithubIssueTaskAdapter(Path.of("/repo"), runner) + .updateSubTask( + UpdateExternalSubTaskRequest( + externalTaskId = ExternalTaskId("42"), + subTaskId = "sk-101", + state = SubTaskState.MERGED, + requestId = "merge-1", + ) + ) + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + + response.state shouldBe SubTaskState.MERGED + runner.commands.map { it.tokens.take(4) } shouldBe + listOf( + listOf("gh", "issue", "list", "--state"), + listOf("gh", "issue", "view", "42"), + listOf("gh", "issue", "edit", "51"), + listOf("gh", "issue", "close", "51"), + ) + runner.commands[2].tokens[5] shouldContain "workflow-subtask-state: MERGED" + } + } + + context("GitHub Issue 응답이 실패하면") { + test("gh 오류가 발생하면, 재시도 가능한 PortError로 변환합니다") { + val runner = IssueRecordingCommandRunner() + runner.enqueue(CommandResult(2, "", "gh auth login required")) + + val result = + GithubIssueTaskAdapter(Path.of("/repo"), runner) + .get(TaskLookupRequest(externalId = ExternalTaskId("42"))) + val failure = result.shouldBeTypeOf() + + failure.error.code shouldBe "GITHUB_TASK_COMMAND_FAILED" + failure.error.retryable shouldBe true + failure.error.message shouldBe "gh auth login required" + } + } + }) + +private data class IssueInvocation(val tokens: List, val workingDirectory: Path) + +private class IssueRecordingCommandRunner : CommandRunner { + private val results = ArrayDeque<() -> CommandResult>() + val commands = mutableListOf() + + fun enqueue(result: CommandResult) { + results.addLast { result } + } + + override fun run(command: List, workingDirectory: Path): CommandResult { + commands += IssueInvocation(command, workingDirectory) + return results.removeFirst().invoke() + } +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/github/GithubMergeQueueAdapterTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/github/GithubMergeQueueAdapterTest.kt new file mode 100644 index 00000000..7ced3700 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/github/GithubMergeQueueAdapterTest.kt @@ -0,0 +1,561 @@ +package io.springkit.workflow.adapter.github + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeTypeOf +import io.springkit.workflow.application.EnqueueMergeRequest +import io.springkit.workflow.application.EnqueueMergeResponse +import io.springkit.workflow.application.GetMergeQueueRequest +import io.springkit.workflow.application.GetMergeQueueResponse +import io.springkit.workflow.application.MergeQueueMergeRequest +import io.springkit.workflow.application.MergeQueueMergeResponse +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import java.nio.file.Path + +class GithubMergeQueueAdapterTest : + FunSpec({ + context("GitHub pull request를 Merge Queue에 등록하면") { + test("활성 queue rule을 확인한 뒤, slash branch를 인코딩해 squash auto merge를 요청합니다") { + val runner = + FakeCommandRunner( + CommandResult( + 0, + """{"number":17,"state":"OPEN","baseRefName":"release/2026","headRefName":"sk-27","headRefOid":"abc123"}""", + "", + ), + CommandResult( + 0, + """[[{"type":"required_status_checks"}],[{"type":"merge_queue"}]]""", + "", + ), + CommandResult(0, "queued", ""), + ) + val adapter = GithubMergeQueueAdapter(Path.of("/repo"), runner) + + val result = + adapter.enqueue( + EnqueueMergeRequest( + subTaskId = "sk-27", + pullRequestId = "17", + changeRevisionId = "cr-7", + expectedRevision = "store-4", + ) + ) + + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + response.entry.subTaskId shouldBe "sk-27" + response.entry.changeRevisionId shouldBe "cr-7" + response.entry.providerRevision shouldBe "abc123" + runner.commands shouldContainExactly + listOf( + MergeQueueInvocation( + listOf( + "gh", + "pr", + "view", + "17", + "--json", + "number,state,isDraft,mergeStateStatus,reviewDecision,baseRefName,headRefName,headRefOid,statusCheckRollup,mergedAt,mergeCommit", + ), + Path.of("/repo"), + ), + MergeQueueInvocation( + listOf( + "gh", + "api", + "--paginate", + "--slurp", + "repos/{owner}/{repo}/rules/branches/release%2F2026", + ), + Path.of("/repo"), + ), + MergeQueueInvocation( + listOf( + "gh", + "pr", + "merge", + "17", + "--squash", + "--auto", + "--match-head-commit", + "abc123", + ), + Path.of("/repo"), + ), + ) + } + + test("활성 merge_queue rule이 없으면, gh pr merge를 실행하지 않습니다") { + val runner = + FakeCommandRunner( + CommandResult( + 0, + """{"number":17,"state":"OPEN","baseRefName":"main","headRefName":"sk-27","headRefOid":"abc123"}""", + "", + ), + CommandResult(0, "[[{\"type\":\"required_status_checks\"}]]", ""), + ) + + val result = + GithubMergeQueueAdapter(Path.of("/repo"), runner) + .enqueue(EnqueueMergeRequest("sk-27", "17", "cr-7", "store-4")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "GITHUB_MERGE_QUEUE_REQUIRED" + runner.commands + .map { it.tokens } + .shouldContainExactly( + listOf( + listOf( + "gh", + "pr", + "view", + "17", + "--json", + "number,state,isDraft,mergeStateStatus,reviewDecision,baseRefName,headRefName,headRefOid,statusCheckRollup,mergedAt,mergeCommit", + ), + listOf( + "gh", + "api", + "--paginate", + "--slurp", + "repos/{owner}/{repo}/rules/branches/main", + ), + ) + ) + } + + test("branch rules API 조회가 실패하면, queue 등록을 진행하지 않습니다") { + val runner = + FakeCommandRunner( + CommandResult( + 0, + """{"number":17,"state":"OPEN","baseRefName":"main","headRefName":"sk-27","headRefOid":"abc123"}""", + "", + ), + CommandResult(1, "", "permission denied"), + ) + + val result = + GithubMergeQueueAdapter(Path.of("/repo"), runner) + .enqueue(EnqueueMergeRequest("sk-27", "17", "cr-7", "store-4")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "GITHUB_MERGE_QUEUE_FAILED" + failure.error.message shouldBe "permission denied" + runner.commands.size shouldBe 2 + } + } + + context("GitHub Merge Queue 상태를 조회하면") { + test("완료 상태에 conclusion이 없으면 검증 성공으로 판단하지 않습니다") { + val runner = + FakeCommandRunner( + CommandResult( + 0, + """{"number":17,"state":"OPEN","headRefName":"sk-27","headRefOid":"abc123","statusCheckRollup":[{"name":"build","status":"COMPLETED"}]}""", + "", + ) + ) + + val entry = + GithubMergeQueueAdapter(Path.of("/repo"), runner) + .get(GetMergeQueueRequest(pullRequestId = "17")) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .entries + .single() + + entry.validations.single().status.name shouldBe "PENDING" + entry.state.name shouldBe "QUEUED" + } + + test("CheckRun conclusion 없이 상태형 status context도 validation 상태로 변환합니다") { + val runner = + FakeCommandRunner( + CommandResult( + 0, + """{"number":17,"state":"OPEN","headRefName":"sk-27","headRefOid":"abc123","statusCheckRollup":[{"context":"legacy-success","state":"SUCCESS"},{"context":"legacy-failure","state":"FAILURE"},{"context":"legacy-error","state":"ERROR"}]}""", + "", + ) + ) + val adapter = GithubMergeQueueAdapter(Path.of("/repo"), runner) + + val entry = + adapter + .get(GetMergeQueueRequest(pullRequestId = "17")) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .entries + .single() + + entry.state.name shouldBe "FAILED" + entry.validations.map { it.status.name } shouldBe listOf("PASSED", "FAILED", "FAILED") + } + + test("gh JSON을 입력하면, 상태와 검증 결과를 MergeQueueEntry로 변환합니다") { + val runner = + FakeCommandRunner( + CommandResult( + 0, + """ + {"number":17,"state":"OPEN","headRefName":"sk-27","headRefOid":"abc123","statusCheckRollup":[{"name":"build","status":"COMPLETED","conclusion":"SUCCESS","databaseId":1},{"name":"test","status":"COMPLETED","conclusion":"FAILURE","databaseId":2}]} + """ + .trimIndent(), + "", + ) + ) + val adapter = + GithubMergeQueueAdapter( + Path.of("/repo"), + runner, + GithubMergeQueueEntryResolver { _, fallback -> + fallback.copy(subTaskId = "sk-27", changeRevisionId = "cr-7") + }, + ) + + val result = adapter.get(GetMergeQueueRequest(pullRequestId = "17")) + + val entries = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .entries + val entry = entries.single() + entry.state.name shouldBe "FAILED" + entry.subTaskId shouldBe "sk-27" + entry.changeRevisionId shouldBe "cr-7" + entry.validations.map { it.status.name } shouldBe listOf("PASSED", "FAILED") + runner.commands.single().tokens shouldBe + listOf( + "gh", + "pr", + "view", + "17", + "--json", + "number,state,isDraft,mergeStateStatus,reviewDecision,baseRefName,headRefName,headRefOid,statusCheckRollup,mergedAt,mergeCommit", + ) + } + } + + context("Merge Queue 항목을 squash merge하면") { + test("gh 명령과 병합 JSON을 사용하면, 통합 revision을 반환합니다") { + val persisted = mutableMapOf() + val runner = + FakeCommandRunner( + CommandResult( + 0, + """{"number":17,"state":"OPEN","baseRefName":"main","headRefName":"sk-27","headRefOid":"abc123"}""", + "", + ), + CommandResult(0, """[[{"type":"merge_queue"}]]""", ""), + CommandResult(0, "queued", ""), + CommandResult( + 0, + """{"number":17,"state":"OPEN","baseRefName":"main","headRefName":"sk-27","headRefOid":"abc123"}""", + "", + ), + CommandResult(0, """[[{"type":"merge_queue"}]]""", ""), + CommandResult( + 0, + """{"state":"MERGED","headRefOid":"abc123","mergeCommit":{"oid":"def456"}}""", + "", + ), + CommandResult( + 0, + """{"state":"MERGED","headRefOid":"abc123","mergeCommit":{"oid":"def456"}}""", + "", + ), + ) + val adapter = + GithubMergeQueueAdapter( + repositoryRoot = Path.of("/repo"), + commandRunner = runner, + entryLookup = { key -> persisted[key] }, + entryPersister = { entry -> persisted[entry.id] = entry }, + ) + adapter.enqueue(EnqueueMergeRequest("sk-27", "17", "cr-7", "store-4")) + + val restartedAdapter = + GithubMergeQueueAdapter( + repositoryRoot = Path.of("/repo"), + commandRunner = runner, + entryLookup = { key -> persisted[key] }, + entryPersister = { entry -> persisted[entry.id] = entry }, + ) + val result = + restartedAdapter.merge(MergeQueueMergeRequest("github-merge-queue-17", "cr-7")) + + val integration = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .integration + integration.subTaskId shouldBe "sk-27" + integration.mainRevision shouldBe "def456" + integration.squashCommit shouldBe "def456" + runner.commands.drop(3).map { it.tokens } shouldContainExactly + listOf( + listOf( + "gh", + "pr", + "view", + "17", + "--json", + "number,state,isDraft,mergeStateStatus,reviewDecision,baseRefName,headRefName,headRefOid,statusCheckRollup,mergedAt,mergeCommit", + ), + listOf( + "gh", + "api", + "--paginate", + "--slurp", + "repos/{owner}/{repo}/rules/branches/main", + ), + listOf( + "gh", + "pr", + "merge", + "17", + "--squash", + "--match-head-commit", + "abc123", + ), + listOf( + "gh", + "pr", + "view", + "17", + "--json", + "number,state,isDraft,mergeStateStatus,reviewDecision,baseRefName,headRefName,headRefOid,statusCheckRollup,mergedAt,mergeCommit", + ), + ) + } + + test("조회된 change revision이 다르면, squash merge를 실행하지 않습니다") { + val persisted = + io.springkit.workflow.domain.MergeQueueEntry( + id = "github-merge-queue-17", + subTaskId = "sk-27", + pullRequestId = "17", + changeRevisionId = "cr-1", + ) + val runner = + FakeCommandRunner( + CommandResult( + 0, + """{"number":17,"state":"OPEN","headRefName":"sk-27","headRefOid":"abc123"}""", + "", + ) + ) + val adapter = + GithubMergeQueueAdapter( + Path.of("/repo"), + runner, + GithubMergeQueueEntryResolver { _, fallback -> + fallback.copy(subTaskId = "sk-27", changeRevisionId = "cr-1") + }, + entryLookup = { persisted }, + ) + + val result = adapter.merge(MergeQueueMergeRequest("github-merge-queue-17", "cr-2")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "STALE_REVISION" + runner.commands.size shouldBe 1 + } + + test("활성 merge_queue rule이 없으면, 기존 Queue 항목도 직접 merge하지 않습니다") { + val persisted = + io.springkit.workflow.domain.MergeQueueEntry( + id = "github-merge-queue-17", + subTaskId = "sk-27", + pullRequestId = "17", + changeRevisionId = "cr-7", + providerRevision = "abc123", + ) + val runner = + FakeCommandRunner( + CommandResult( + 0, + """{"number":17,"state":"OPEN","baseRefName":"main","headRefName":"sk-27","headRefOid":"abc123"}""", + "", + ), + CommandResult(0, "[[]]", ""), + ) + val adapter = + GithubMergeQueueAdapter( + repositoryRoot = Path.of("/repo"), + commandRunner = runner, + entryLookup = { persisted }, + ) + + val result = adapter.merge(MergeQueueMergeRequest("github-merge-queue-17", "cr-7")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "GITHUB_MERGE_QUEUE_REQUIRED" + runner.commands + .map { it.tokens } + .shouldContainExactly( + listOf( + listOf( + "gh", + "pr", + "view", + "17", + "--json", + "number,state,isDraft,mergeStateStatus,reviewDecision,baseRefName,headRefName,headRefOid,statusCheckRollup,mergedAt,mergeCommit", + ), + listOf( + "gh", + "api", + "--paginate", + "--slurp", + "repos/{owner}/{repo}/rules/branches/main", + ), + ) + ) + } + + test("GitHub가 head OID 불일치로 merge를 거부하면, STALE_REVISION을 반환합니다") { + val persisted = + io.springkit.workflow.domain.MergeQueueEntry( + id = "github-merge-queue-17", + subTaskId = "sk-27", + pullRequestId = "17", + changeRevisionId = "cr-7", + ) + val runner = + FakeCommandRunner( + CommandResult( + 0, + """{"number":17,"state":"OPEN","baseRefName":"main","headRefName":"sk-27","headRefOid":"abc123"}""", + "", + ), + CommandResult(0, """[[{"type":"merge_queue"}]]""", ""), + CommandResult(1, "", "head commit does not match"), + ) + val adapter = + GithubMergeQueueAdapter( + repositoryRoot = Path.of("/repo"), + commandRunner = runner, + entryLookup = { persisted }, + ) + + val result = adapter.merge(MergeQueueMergeRequest("github-merge-queue-17", "cr-7")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "STALE_REVISION" + runner.commands.size shouldBe 3 + } + + test("queue 등록 시점 이후 head OID가 바뀌면, STALE_REVISION을 반환합니다") { + val persisted = + io.springkit.workflow.domain.MergeQueueEntry( + id = "github-merge-queue-17", + subTaskId = "sk-27", + pullRequestId = "17", + changeRevisionId = "cr-7", + providerRevision = "old-oid", + ) + val runner = + FakeCommandRunner( + CommandResult( + 0, + """{"number":17,"state":"OPEN","headRefName":"sk-27","headRefOid":"new-oid"}""", + "", + ) + ) + val adapter = + GithubMergeQueueAdapter( + Path.of("/repo"), + commandRunner = runner, + entryLookup = { persisted }, + ) + + val result = adapter.merge(MergeQueueMergeRequest("github-merge-queue-17", "cr-7")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "STALE_REVISION" + runner.commands.size shouldBe 1 + } + } + + context("gh 명령이 실패하면") { + test("gh 명령이 종료 코드와 stderr를 반환하면, PortError로 변환하고 네트워크를 호출하지 않습니다") { + val runner = + FakeCommandRunner( + CommandResult( + 0, + """{"number":17,"state":"OPEN","baseRefName":"main","headRefName":"sk-27","headRefOid":"abc123"}""", + "", + ), + CommandResult(0, """[[{"type":"merge_queue"}]]""", ""), + CommandResult(1, "", "permission denied"), + ) + val result = + GithubMergeQueueAdapter(Path.of("/repo"), runner) + .enqueue(EnqueueMergeRequest("sk-27", "17", "cr-7", "store-4")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "GITHUB_MERGE_QUEUE_FAILED" + failure.error.message shouldBe "permission denied" + runner.commands.size shouldBe 3 + } + + test("CommandRunner가 예외를 던지면, 재시도 가능한 PortError로 변환합니다") { + val runner = CommandRunner { _, _ -> error("gh unavailable") } + val result = + GithubMergeQueueAdapter(Path.of("/repo"), runner) + .enqueue(EnqueueMergeRequest("sk-27", "17", "cr-7", "store-4")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "GITHUB_MERGE_QUEUE_FAILED" + failure.error.message shouldBe "gh 명령을 실행할 수 없습니다: gh unavailable" + failure.error.retryable shouldBe true + } + } + + context("프로세스를 다시 시작한 뒤 Merge Queue를 병합하면") { + test("영속 조회가 없으면, 인메모리 상태를 추측하지 않고 상태 필요 오류를 반환합니다") { + val runner = + FakeCommandRunner( + CommandResult( + 0, + """{"number":17,"state":"OPEN","headRefName":"sk-27","headRefOid":"abc123"}""", + "", + ) + ) + + val result = + GithubMergeQueueAdapter(Path.of("/repo"), runner) + .merge(MergeQueueMergeRequest("github-merge-queue-17", "cr-7")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "MERGE_QUEUE_STATE_REQUIRED" + runner.commands.size shouldBe 1 + } + } + }) + +private data class MergeQueueInvocation(val tokens: List, val workingDirectory: Path) + +private class FakeCommandRunner(vararg results: CommandResult) : CommandRunner { + private val queuedResults = ArrayDeque(results.toList()) + val commands = mutableListOf() + + override fun run(command: List, workingDirectory: Path): CommandResult { + commands += MergeQueueInvocation(command, workingDirectory) + return queuedResults.removeFirst() + } +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/json/JsonContractTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/json/JsonContractTest.kt new file mode 100644 index 00000000..8f05822a --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/json/JsonContractTest.kt @@ -0,0 +1,70 @@ +package io.springkit.workflow.adapter.json + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +@kotlinx.serialization.Serializable private data class KnownJsonValue(val value: String) + +@kotlinx.serialization.Serializable +private data class NamedJsonValue(val reviewRevision: String, val changeRevision: String) + +@OptIn(ExperimentalSerializationApi::class) +class JsonContractTest : + FunSpec({ + context("kotlinx Json의 원시값과 출력 형식을 확인할 때") { + test("JsonObject에 원시값을 입력하면, 값을 보존한 compact JSON을 생성합니다") { + val value = buildJsonObject { + put("name", JsonPrimitive("workflow")) + put("enabled", true) + put("count", 2) + } + + value["name"]?.jsonPrimitive?.content shouldBe "workflow" + value.toString() shouldBe "{\"name\":\"workflow\",\"enabled\":true,\"count\":2}" + } + } + + context("WorkflowJson의 설정을 확인할 때") { + test("알 수 없는 키를 입력하면, JSON 역직렬화를 거부합니다") { + shouldThrow { + WorkflowJson.format.decodeFromString( + "{\"value\":\"ok\",\"extra\":true}" + ) + } + } + + test("기본값을 포함한 값을 인코딩하면, compact JSON을 생성합니다") { + WorkflowJson.format.encodeToString(KnownJsonValue("ok")) shouldBe "{\"value\":\"ok\"}" + } + + test("Kotlin 프로퍼티 이름을 인코딩하면, 명세의 snake_case 필드 이름을 사용합니다") { + WorkflowJson.format.encodeToString(NamedJsonValue("rv-1", "cr-1")) shouldBe + "{\"review_revision\":\"rv-1\",\"change_revision\":\"cr-1\"}" + } + + test("WorkflowJson 설정을 조회하면, 계약에 맞는 옵션을 반환합니다") { + val configuration = WorkflowJson.format.configuration + + configuration.explicitNulls shouldBe false + configuration.encodeDefaults shouldBe true + configuration.ignoreUnknownKeys shouldBe false + configuration.classDiscriminator shouldBe "type" + configuration.prettyPrint shouldBe false + } + } + + context("JSON parser의 입력을 확인할 때") { + test("잘못된 JSON을 입력하면, JSONDecodingException을 발생시킵니다") { + shouldThrow { + Json.parseToJsonElement("{\"value\":}") + } + } + } + }) diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/json/WorkflowResultJsonTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/json/WorkflowResultJsonTest.kt new file mode 100644 index 00000000..519cf678 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/json/WorkflowResultJsonTest.kt @@ -0,0 +1,80 @@ +package io.springkit.workflow.adapter.json + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureConflict +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.FailureWorkspace +import io.springkit.workflow.domain.NextAction +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.WorkspacePath +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +class WorkflowResultJsonTest : + FunSpec({ + context("WorkflowResult 실패 응답을 인코딩할 때") { + test("공통 JSON 결과 계약을 사용하면, 차단 정보와 다음 작업을 data에 배치합니다") { + val result = + WorkflowResult.Failure( + FailureData( + code = FailureCode.HUMAN_REQUIRED, + message = "사람의 결정이 필요합니다.", + blockedBy = listOf(BlockedBy("HUMAN_REQUIRED", "사람만 실행할 수 있습니다.", "sk-1")), + next = + listOf( + NextAction( + ActorKind.HUMAN, + "approve_change", + "workflow gate approve sk-1", + ) + ), + ), + ) + + result.encodeToString() shouldBe + "{\"data\":{\"blocked_by\":[{\"code\":\"HUMAN_REQUIRED\",\"message\":\"사람만 실행할 수 있습니다.\",\"target\":\"sk-1\"}],\"code\":\"HUMAN_REQUIRED\",\"message\":\"사람의 결정이 필요합니다.\",\"next\":[{\"action\":\"approve_change\",\"actor\":\"human\",\"command\":\"workflow gate approve sk-1\"}]},\"type\":\"failure\"}" + } + + test("동기화 충돌이면, Worktree와 충돌 파일의 구조화 정보를 반환합니다") { + val result = + WorkflowResult.Failure( + FailureData( + code = FailureCode.SYNC_CONFLICT, + message = "자동으로 동기화할 수 없는 코드 충돌이 있습니다.", + workspace = FailureWorkspace(WorkspacePath("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/repo-managed/sk-102")), + conflicts = listOf(FailureConflict("src/main.kt")), + ) + ) + + result.encodeToString() shouldBe + "{\"data\":{\"blocked_by\":[],\"code\":\"SYNC_CONFLICT\",\"message\":\"자동으로 동기화할 수 없는 코드 충돌이 있습니다.\",\"next\":[],\"workspace\":{\"path\":\"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/repo-managed/sk-102\"},\"conflicts\":[{\"path\":\"src/main.kt\",\"kind\":\"content\"}]},\"type\":\"failure\"}" + } + } + + context("WorkflowResult 성공 응답을 인코딩할 때") { + test("다음 작업이 있으면, data 안에 다음 작업을 배치합니다") { + val result = + WorkflowResult.Success( + buildJsonObject { put("subtask", JsonPrimitive("sk-1")) }, + listOf(NextAction(ActorKind.AGENT, "implement_change")), + ) + + result.encodeToString() shouldBe + "{\"data\":{\"next\":[{\"action\":\"implement_change\",\"actor\":\"agent\"}],\"subtask\":\"sk-1\"},\"type\":\"success\"}" + } + } + + context("kotlinx Json으로 workflow 계약을 파싱할 때") { + test("정상적인 JSON을 입력하면, 계약의 compact 표현을 유지합니다") { + val value = Json.parseToJsonElement("{\"type\":\"success\",\"data\":{\"ok\":true}}") + + value.toString() shouldBe "{\"type\":\"success\",\"data\":{\"ok\":true}}" + } + } + }) diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/local/LocalRuntimeAdapterTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/local/LocalRuntimeAdapterTest.kt new file mode 100644 index 00000000..26576e82 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/local/LocalRuntimeAdapterTest.kt @@ -0,0 +1,432 @@ +package io.springkit.workflow.adapter.local + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.application.AuthorizeRequest +import io.springkit.workflow.application.Capability +import io.springkit.workflow.application.CreateSubTaskRequest +import io.springkit.workflow.application.CreateSubTaskResponse +import io.springkit.workflow.application.CreateWorkspaceRequest +import io.springkit.workflow.application.CurrentActorRequest +import io.springkit.workflow.application.CurrentActorResponse +import io.springkit.workflow.application.DeleteWorkspaceRequest +import io.springkit.workflow.application.NowResponse +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.SubTaskLookupRequest +import io.springkit.workflow.application.TaskLookupRequest +import io.springkit.workflow.application.TaskLookupResponse +import io.springkit.workflow.application.WorkflowStoreSnapshot +import io.springkit.workflow.application.WorkspaceLookupRequest +import io.springkit.workflow.application.WorkspaceLookupResponse +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.ExternalTaskId +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.Task +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspacePath +import java.nio.file.Files +import java.nio.file.Path + +class LocalRuntimeAdapterTest : + FunSpec({ + context("현재 실행 경로를 Workspace로 해석하면") { + test("관리 Worktree의 경로이면, 해당 Workspace를 반환합니다") { + val workspace = Workspace("ws-1", "sk-101", WorkspacePath("workspaces/sk-101"), "sk-101") + val adapter = + LocalWorkspaceAdapter( + WorkflowStoreSnapshot("1", workspaces = listOf(workspace)), + Path.of("/repo/workspaces/sk-101"), + ) + + val result = adapter.get(WorkspaceLookupRequest()) + + val response = + result.shouldBeInstanceOf>().value + + response.workspace shouldBe workspace + } + + test("관리 Worktree의 하위 경로이면, 해당 Workspace를 반환합니다") { + val workspace = + Workspace("ws-1", "sk-101", WorkspacePath("/repo/workspaces/sk-101"), "sk-101") + val adapter = + LocalWorkspaceAdapter( + WorkflowStoreSnapshot("1", workspaces = listOf(workspace)), + Path.of("/repo/workspaces/sk-101/src/main"), + ) + + val result = adapter.get(WorkspaceLookupRequest()) + + result + .shouldBeInstanceOf>() + .value + .workspace shouldBe workspace + } + + test("관리 Worktree 밖의 경로이면, NOT_MANAGED_WORKTREE 오류를 반환합니다") { + val workspace = Workspace("ws-1", "sk-101", WorkspacePath("workspaces/sk-101"), "sk-101") + val adapter = + LocalWorkspaceAdapter( + WorkflowStoreSnapshot("1", workspaces = listOf(workspace)), + Path.of("/repo"), + ) + + val failure = + adapter.get(WorkspaceLookupRequest()).shouldBeInstanceOf() + + failure.error.code shouldBe "NOT_MANAGED_WORKTREE" + } + + test("관리되지 않은 Workspace를 삭제하려 하면, NOT_MANAGED_WORKTREE 오류를 반환합니다") { + val workspace = + Workspace( + "ws-1", + "sk-101", + WorkspacePath("/repo/workspaces/sk-101"), + "sk-101", + managed = false, + ) + val adapter = + LocalWorkspaceAdapter( + WorkflowStoreSnapshot("1", workspaces = listOf(workspace)), + Path.of("/repo"), + ) + + val failure = + adapter + .delete( + io.springkit.workflow.application.DeleteWorkspaceRequest("ws-1", "sk-101") + ) + .shouldBeInstanceOf() + + failure.error.code shouldBe "NOT_MANAGED_WORKTREE" + } + } + + context("관리 root 경계를 검사하면") { + test("상대 Workspace 경로를 입력하면, 관리 root 기준으로 해석합니다") { + val root = Files.createTempDirectory("workflow-workspace-root-") + val current = root.resolve("workspaces/sk-101/src/main") + val workspace = Workspace("ws-1", "sk-101", WorkspacePath("workspaces/sk-101"), "sk-101") + val adapter = + LocalWorkspaceAdapter( + WorkflowStoreSnapshot("1", workspaces = listOf(workspace)), + current, + root, + ) + + adapter.get(WorkspaceLookupRequest()).shouldBeInstanceOf>() + } + + test("절대 경로가 관리 root 밖이면, Workspace 생성을 거부합니다") { + val root = Files.createTempDirectory("workflow-workspace-root-") + val outside = Files.createTempDirectory("workflow-workspace-outside-") + val adapter = LocalWorkspaceAdapter(WorkflowStoreSnapshot("1"), root, root) + + val result = + adapter.create( + CreateWorkspaceRequest( + workspaceId = "ws-1", + subTaskId = "sk-101", + path = WorkspacePath(outside.toString()), + branch = "feature/sk-101", + baseRevision = "main", + ) + ) + + result.shouldBeInstanceOf().error.code shouldBe + "WORKSPACE_PATH_INVALID" + } + + test("`..`으로 관리 root 밖으로 탈출한 경로이면, Workspace 삭제를 거부합니다") { + val root = Files.createTempDirectory("workflow-workspace-root-") + val workspace = Workspace("ws-1", "sk-101", WorkspacePath("../outside"), "sk-101") + val adapter = + LocalWorkspaceAdapter( + WorkflowStoreSnapshot("1", workspaces = listOf(workspace)), + root, + root, + ) + + val result = adapter.delete(DeleteWorkspaceRequest("ws-1", "sk-101")) + + result.shouldBeInstanceOf().error.code shouldBe + "WORKSPACE_PATH_INVALID" + } + + test("외부를 가리키는 심볼릭 링크 경로이면, Workspace 조회를 거부합니다") { + val root = Files.createTempDirectory("workflow-workspace-root-") + val outside = Files.createTempDirectory("workflow-workspace-outside-") + Files.createSymbolicLink(root.resolve("link"), outside) + val adapter = LocalWorkspaceAdapter(WorkflowStoreSnapshot("1"), root, root) + + val result = adapter.get(WorkspaceLookupRequest(path = WorkspacePath("link"))) + + result.shouldBeInstanceOf().error.code shouldBe + "WORKSPACE_PATH_INVALID" + } + + test("외부를 가리키는 심볼릭 링크 아래의 미존재 자식 경로이면, Workspace 조회를 거부합니다") { + val root = Files.createTempDirectory("workflow-workspace-root-") + val outside = Files.createTempDirectory("workflow-workspace-outside-") + Files.createSymbolicLink(root.resolve("link"), outside) + val adapter = LocalWorkspaceAdapter(WorkflowStoreSnapshot("1"), root, root) + + val result = + adapter.get(WorkspaceLookupRequest(path = WorkspacePath("link/nonexistent/body.md"))) + + result.shouldBeInstanceOf().error.code shouldBe + "WORKSPACE_PATH_INVALID" + } + } + + context("snapshot에서 외부 Task를 조회하면") { + test("외부 Task ID로 조회하면, 일치하는 Task를 반환합니다") { + val task = Task("task-1", ExternalTaskId("TASK-1"), "작업") + val adapter = SnapshotTaskAdapter(WorkflowStoreSnapshot("1", tasks = listOf(task))) + + val result = adapter.get(TaskLookupRequest(externalId = ExternalTaskId("TASK-1"))) + + val response = result.shouldBeInstanceOf>().value + + response.task shouldBe task + } + + test("존재하지 않는 SubTask ID로 조회하면, SUBTASK_NOT_FOUND 오류를 반환합니다") { + val adapter = SnapshotTaskAdapter(WorkflowStoreSnapshot("1")) + + val failure = + adapter + .getSubTask(SubTaskLookupRequest("sk-404")) + .shouldBeInstanceOf() + + failure.error.code shouldBe "SUBTASK_NOT_FOUND" + } + } + + context("외부 Task 제공자 없이 시작하면") { + test("외부 Task ID를 조회하면, 로컬 Task를 임시로 구체화합니다") { + val adapter = SnapshotTaskAdapter(WorkflowStoreSnapshot("1")) + + val response = + adapter + .get(TaskLookupRequest(externalId = ExternalTaskId("TASK-1"))) + .shouldBeInstanceOf>() + .value + + response.task.id shouldBe "TASK-1" + response.task.title shouldBe "TASK-1" + response.task.externalId shouldBe ExternalTaskId("TASK-1") + } + + test("외부 Task의 SubTask를 생성하면, 임시 Task와 발급된 ID를 함께 반환합니다") { + val adapter = SnapshotTaskAdapter(WorkflowStoreSnapshot("1")) + + val response = + adapter + .createSubTask( + CreateSubTaskRequest( + externalTaskId = ExternalTaskId("TASK-1"), + subTaskId = "sk-101", + title = "변경", + requestId = "request-1", + ), + ) + .shouldBeInstanceOf>() + .value + + response.task.id shouldBe "TASK-1" + response.subTask.id shouldBe "sk-101" + } + } + + context("외부 Task의 SubTask 생성을 요청하면") { + test("snapshot에 Task가 있으면, 저장할 SubTask와 변경 영수증을 반환합니다") { + val existing = SubTask("sk-100", "task-1", "기존") + val task = + Task("task-1", ExternalTaskId("TASK-1"), "작업", subTaskIds = listOf(existing.id)) + val adapter = + SnapshotTaskAdapter( + WorkflowStoreSnapshot( + "1", + tasks = listOf(task), + subTasks = listOf(existing), + ) + ) + + val result = + adapter.createSubTask( + CreateSubTaskRequest( + externalTaskId = ExternalTaskId("TASK-1"), + subTaskId = "sk-101", + title = "변경", + requestId = "request-1", + ) + ) + val response = + result.shouldBeInstanceOf>().value + + response.subTask.taskId shouldBe task.id + response.subTask.title shouldBe "변경" + response.change.operation shouldBe "create-subtask" + } + } + + context("시스템 시계를 조회하면") { + test("epoch 값을 주입하면, 해당 epoch 값을 반환합니다") { + val result = SystemClockAdapter { 1_723_456_789L }.now() + + val response = result.shouldBeInstanceOf>().value + + response.epochMillis shouldBe 1_723_456_789L + } + } + + context("현재 principal과 사람 Gate를 확인하면") { + test("gh 인증 사용자를 조회하면, 기본적으로 AGENT actor로 반환합니다") { + val runner = RecordingCommandRunner(CommandResult(0, "octocat\n", "")) + val adapter = + EnvironmentIdentityAdapter( + commandRunner = runner, + workingDirectory = Path.of("/repo"), + environment = mapOf("USER" to "runner"), + ) + + val result = adapter.currentActor(CurrentActorRequest("request-1")) + val actor = + result.shouldBeInstanceOf>().value.actor + + actor shouldBe Actor("octocat", ActorKind.AGENT, "octocat") + runner.command shouldBe listOf("gh", "api", "user", "--jq", ".login") + } + + test("환경 변수만 HUMAN으로 지정하면, gh principal을 AGENT actor로 유지합니다") { + val runner = RecordingCommandRunner(CommandResult(0, "octocat\n", "")) + val adapter = + EnvironmentIdentityAdapter( + commandRunner = runner, + environment = mapOf("WORKFLOW_ACTOR_KIND" to "HUMAN"), + ) + + val result = adapter.currentActor() + + result + .shouldBeInstanceOf>() + .value + .actor + .kind shouldBe ActorKind.AGENT + } + + test("gh principal이 명시적 human allowlist에 포함되면, HUMAN actor로 반환합니다") { + val runner = RecordingCommandRunner(CommandResult(0, "octocat\n", "")) + val adapter = + EnvironmentIdentityAdapter( + commandRunner = runner, + environment = mapOf("WORKFLOW_ACTOR_KIND" to "HUMAN"), + humanActorIds = setOf("octocat"), + ) + + val result = adapter.currentActor() + + result + .shouldBeInstanceOf>() + .value + .actor + .kind shouldBe ActorKind.HUMAN + } + + test("human allowlist의 현재 gh principal은 사람 Gate를 수행할 수 있습니다") { + val runner = RecordingCommandRunner(CommandResult(0, "octocat\n", "")) + val adapter = + EnvironmentIdentityAdapter( + commandRunner = runner, + environment = mapOf("USER" to "runner"), + humanActorIds = setOf("octocat"), + ) + + val result = + adapter.authorize( + AuthorizeRequest( + Actor("octocat", ActorKind.HUMAN), + Capability.APPROVE, + "pr-1", + ) + ) + val response = + result + .shouldBeInstanceOf< + PortResult.Success + >() + .value + + response.allowed shouldBe true + } + + test("자동화 principal이 사람 Gate를 요청하면, 권한을 거부합니다") { + val adapter = + EnvironmentIdentityAdapter( + environment = mapOf("CI" to "true", "GITHUB_ACTOR" to "workflow-bot") + ) + + val result = + adapter.authorize( + AuthorizeRequest( + Actor("workflow-bot", ActorKind.WORKFLOW), + Capability.APPROVE, + "pr-1", + ) + ) + val response = + result + .shouldBeInstanceOf< + PortResult.Success + >() + .value + + response.allowed shouldBe false + response.reason shouldContain "HUMAN" + } + + test("인증된 gh principal과 다른 HUMAN actor를 전달하면, 사람 Gate를 거부합니다") { + val runner = RecordingCommandRunner(CommandResult(0, "octocat\n", "")) + val adapter = + EnvironmentIdentityAdapter( + commandRunner = runner, + environment = mapOf("USER" to "runner"), + humanActorIds = setOf("trusted-human"), + ) + + val result = + adapter.authorize( + AuthorizeRequest( + Actor("trusted-human", ActorKind.HUMAN), + Capability.APPROVE, + "pr-1", + ) + ) + val response = + result + .shouldBeInstanceOf< + PortResult.Success + >() + .value + + response.allowed shouldBe false + response.reason shouldContain "principal" + } + } + }) + +private class RecordingCommandRunner(private val next: CommandResult) : CommandRunner { + var command: List? = null + + override fun run(command: List, workingDirectory: Path): CommandResult { + this.command = command + return next + } +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/local/OkioWorkflowBodyReaderTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/local/OkioWorkflowBodyReaderTest.kt new file mode 100644 index 00000000..7e3df2c6 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/local/OkioWorkflowBodyReaderTest.kt @@ -0,0 +1,19 @@ +package io.springkit.workflow.adapter.local + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import okio.Path.Companion.toPath +import okio.buffer +import okio.fakefilesystem.FakeFileSystem + +class OkioWorkflowBodyReaderTest : + FunSpec({ + test("Okio FileSystem에서 본문을 읽으면, UTF-8 내용을 반환합니다") { + val fileSystem = FakeFileSystem() + val path = "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/workspace/pr.md".toPath() + fileSystem.createDirectories(requireNotNull(path.parent)) + fileSystem.sink(path).buffer().use { it.writeUtf8("본문") } + + OkioWorkflowBodyReader(fileSystem).read(path.toString()) shouldBe "본문" + } + }) diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/provider/ProviderDeploymentAdapterTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/provider/ProviderDeploymentAdapterTest.kt new file mode 100644 index 00000000..2c272ab2 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/provider/ProviderDeploymentAdapterTest.kt @@ -0,0 +1,273 @@ +package io.springkit.workflow.adapter.provider + +import io.kotest.core.spec.style.FunSpec +import io.kotest.datatest.withData +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeTypeOf +import io.springkit.workflow.application.CreateCandidateRequest +import io.springkit.workflow.application.GetCandidateRequest +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.PromoteProductionRequest +import io.springkit.workflow.application.StartCanaryRequest +import io.springkit.workflow.application.ValidateCandidateRequest +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.domain.DeploymentCandidateState +import io.springkit.workflow.domain.Risk +import java.nio.file.Path +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject + +class ProviderDeploymentAdapterTest : + FunSpec({ + context("provider CLI로 배포 후보를 생성하면") { + test("요청 JSON과 후보 검증 결과를 입력하면, 도메인 객체로 변환합니다") { + val runner = + ProviderDeploymentRecordingCommandRunner( + commandResult(candidateResponse(withChange = true)) + ) + val adapter = + ProviderDeploymentAdapter(listOf("deployctl", "workflow"), Path.of("/repo"), runner) + + val result = + adapter.createCandidate( + CreateCandidateRequest( + mainRevision = "abc123", + includedSubTasks = listOf("sk-1"), + risks = mapOf("sk-1" to Risk.HIGH), + ) + ) + + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + response.candidate.id shouldBe "dc-1" + response.candidate.risk shouldBe Risk.HIGH + response.candidate.validations.single().status.name shouldBe "PASSED" + response.change.id shouldBe "change-1" + runner.commands.single().tokens.take(4) shouldContainExactly + listOf("deployctl", "workflow", "create-candidate", "--request-json") + val request = Json.parseToJsonElement(runner.commands.single().tokens.last()).jsonObject + request["main_revision"].toString() shouldBe "\"abc123\"" + request["included_subtasks"].toString() shouldBe "[\"sk-1\"]" + request["risks"].toString() shouldBe "{\"sk-1\":\"HIGH\"}" + } + } + + context("provider CLI로 배포 후보를 조회하면") { + test("candidate 응답을 조회하면, 응답을 반환하고 조회 명령을 구성합니다") { + val runner = + ProviderDeploymentRecordingCommandRunner( + commandResult(candidateResponse(withChange = false)) + ) + val adapter = ProviderDeploymentAdapter(listOf("deployctl"), Path.of("/repo"), runner) + + val result = adapter.getCandidate(GetCandidateRequest("dc-1")) + + val candidate = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .candidate + candidate.mainRevision shouldBe "abc123" + runner.commands.single().tokens.take(4) shouldContainExactly + listOf("deployctl", "get-candidate", "--request-json", "{\"candidate_id\":\"dc-1\"}") + } + } + + context("변경 영수증이 없는 provider 응답을 받으면") { + test("변경 영수증이 없는 provider 응답을 받으면, operation과 대상 기반 영수증을 생성합니다") { + val runner = + ProviderDeploymentRecordingCommandRunner( + commandResult(candidateResponse(withChange = false)) + ) + val adapter = ProviderDeploymentAdapter(listOf("deployctl"), Path.of("/repo"), runner) + + val result = adapter.startCanary(StartCanaryRequest("dc-1")) + + val response = + result + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + response.change.id shouldBe "provider-deployment-start-canary-dc-1" + response.change.operation shouldBe "provider-deployment-start-canary" + } + } + + context("각 배포 변경 명령을 호출하면") { + withData( + nameFn = { (operation, expectedTokens) -> "${operation}을 호출하면, provider 명령을 전달합니다" }, + Triple( + "validate-candidate", + listOf("deployctl", "validate-candidate", "--request-json"), + "validate", + ), + Triple( + "start-canary", + listOf("deployctl", "start-canary", "--request-json"), + "start", + ), + Triple( + "promote-production", + listOf("deployctl", "promote-production", "--request-json"), + "promote", + ), + ) { (operation, expectedTokens, kind) -> + val runner = + ProviderDeploymentRecordingCommandRunner( + commandResult(candidateResponse(withChange = true)) + ) + val adapter = ProviderDeploymentAdapter(listOf("deployctl"), Path.of("/repo"), runner) + + when (kind) { + "validate" -> adapter.validate(ValidateCandidateRequest("dc-1", "abc123")) + "start" -> adapter.startCanary(StartCanaryRequest("dc-1")) + else -> + adapter.promoteProduction( + PromoteProductionRequest("dc-1", DeploymentCandidateState.CANARY) + ) + } + + runner.commands.single().tokens.take(3) shouldBe expectedTokens + runner.commands.single().workingDirectory shouldBe Path.of("/repo") + operation.isNotBlank() shouldBe true + } + } + + context("provider CLI 응답이 올바르지 않으면") { + test("provider CLI 응답이 JSON 오류이면, PortError로 변환합니다") { + val runner = ProviderDeploymentRecordingCommandRunner(CommandResult(0, "{invalid", "")) + val result = + ProviderDeploymentAdapter(listOf("deployctl"), Path.of("/repo"), runner) + .getCandidate(GetCandidateRequest("dc-1")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "DEPLOYMENT_PROVIDER_RESPONSE_INVALID" + } + + test("후보 불변식 오류가 발생하면, PortError로 변환합니다") { + val runner = + ProviderDeploymentRecordingCommandRunner( + commandResult( + """ + {"candidate":{"id":"dc-1","main_revision":"abc123","included_subtasks":["sk-1"],"risks":{"sk-2":"HIGH"},"validations":[],"state":"CANDIDATE"}} + """ + .trimIndent() + ) + ) + val result = + ProviderDeploymentAdapter(listOf("deployctl"), Path.of("/repo"), runner) + .getCandidate(GetCandidateRequest("dc-1")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "DEPLOYMENT_PROVIDER_INVARIANT_VIOLATION" + } + } + + context("provider CLI 실행이 실패하면") { + test("provider CLI가 종료 코드와 표준 오류를 반환하면, PortError로 보존합니다") { + val runner = + ProviderDeploymentRecordingCommandRunner(CommandResult(7, "", "provider unavailable")) + val result = + ProviderDeploymentAdapter(listOf("deployctl"), Path.of("/repo"), runner) + .getCandidate(GetCandidateRequest("dc-1")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "DEPLOYMENT_PROVIDER_COMMAND_FAILED" + failure.error.message shouldBe "provider unavailable" + } + + test("provider CLI 명령에서 예외가 발생하면, 재시도 가능한 PortError로 변환합니다") { + val runner = + ProviderDeploymentFailingCommandRunner(IllegalStateException("not installed")) + val result = + ProviderDeploymentAdapter(listOf("deployctl"), Path.of("/repo"), runner) + .getCandidate(GetCandidateRequest("dc-1")) + + val failure = result.shouldBeTypeOf() + failure.error.retryable shouldBe true + failure.error.code shouldBe "DEPLOYMENT_PROVIDER_COMMAND_FAILED" + } + } + + context("배포 요청 인자가 비어 있으면") { + test("배포 요청 인자가 비어 있으면, provider 명령을 실행하지 않고 PortError를 반환합니다") { + val runner = + ProviderDeploymentRecordingCommandRunner( + commandResult(candidateResponse(withChange = true)) + ) + val result = + ProviderDeploymentAdapter(listOf("deployctl"), Path.of("/repo"), runner) + .validate(ValidateCandidateRequest("", "abc123")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "DEPLOYMENT_PROVIDER_REQUEST_INVALID" + runner.commands shouldBe emptyList() + } + } + + context("provider CLI 명령 접두사가 올바르지 않으면") { + withData( + nameFn = { "접두사 ${it}이면, 명령 오류를 반환합니다" }, + emptyList(), + listOf("deployctl", ""), + listOf(" "), + ) { prefix -> + val runner = + ProviderDeploymentRecordingCommandRunner( + commandResult(candidateResponse(withChange = true)) + ) + val result = + ProviderDeploymentAdapter(prefix, Path.of("/repo"), runner) + .getCandidate(GetCandidateRequest("dc-1")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "DEPLOYMENT_PROVIDER_COMMAND_INVALID" + runner.commands shouldBe emptyList() + } + } + }) + +private data class ProviderDeploymentInvocation( + val tokens: List, + val workingDirectory: Path, +) + +private class ProviderDeploymentRecordingCommandRunner(private val result: CommandResult) : + CommandRunner { + val commands = mutableListOf() + + override fun run(command: List, workingDirectory: Path): CommandResult { + commands += ProviderDeploymentInvocation(command, workingDirectory) + return result + } +} + +private class ProviderDeploymentFailingCommandRunner(private val failure: Exception) : + CommandRunner { + override fun run(command: List, workingDirectory: Path): CommandResult = throw failure +} + +private fun commandResult(stdout: String): CommandResult = CommandResult(0, stdout, "") + +private fun candidateResponse(withChange: Boolean): String = + """ + { + "candidate": { + "id": "dc-1", + "main_revision": "abc123", + "included_subtasks": ["sk-1"], + "risks": {"sk-1": "HIGH"}, + "validations": [ + {"id": "validation-1", "name": "merge-validation", "status": "PASSED", "required": true, "revision": "abc123"} + ], + "state": "CANDIDATE" + }${if (withChange) ",\"change\": {\"id\": \"change-1\", \"operation\": \"provider-create\", \"status\": \"APPLIED\", \"before_revision\": \"old\", \"after_revision\": \"abc123\"}" else ""} + } + """ + .trimIndent() diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/provider/ProviderReleaseAdapterTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/provider/ProviderReleaseAdapterTest.kt new file mode 100644 index 00000000..0646c7ac --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/provider/ProviderReleaseAdapterTest.kt @@ -0,0 +1,292 @@ +package io.springkit.workflow.adapter.provider + +import io.kotest.core.spec.style.FunSpec +import io.kotest.datatest.withData +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeTypeOf +import io.springkit.workflow.application.ChangeStatus +import io.springkit.workflow.application.ContinueReleaseRequest +import io.springkit.workflow.application.CreateReleaseRequest +import io.springkit.workflow.application.GetReleaseRequest +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.StartReleaseRequest +import io.springkit.workflow.application.ValidateReleaseRequest +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import java.nio.file.Path +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +class ProviderReleaseAdapterTest : + FunSpec({ + context("외부 공개 provider CLI에 Release 요청을 전달하면") { + test("operation과 request JSON을 전달하면, Release로 변환하고 하나의 인자로 명령을 구성합니다") { + val runner = + ProviderReleaseRecordingCommandRunner(resultFor("{\"release\":${releaseJson()}}")) + val adapter = + ProviderReleaseAdapter( + listOf("release-provider", "--repo", "sample"), + Path.of("/repo"), + runner, + ) + + val result = + adapter.create( + CreateReleaseRequest( + releaseId = "rel-1", + candidateId = "dc-1", + ) + ) + + val response = result.shouldBeTypeOf>().value + response + .shouldBeTypeOf() + .release + .id shouldBe "rel-1" + runner.commands.single().tokens.take(4) shouldContainExactly + listOf("release-provider", "--repo", "sample", "create-release") + runner.commands.single().tokens[4] shouldBe "--request-json" + val request = Json.parseToJsonElement(runner.commands.single().tokens[5]).jsonObject + request["release_id"]?.jsonPrimitive?.content shouldBe "rel-1" + request["candidate_id"]?.jsonPrimitive?.content shouldBe "dc-1" + request["feature_flag_id"] shouldBe null + } + + test("actor가 있으면, 최소 actor 필드를 snake_case 요청 JSON에 포함합니다") { + val runner = ProviderReleaseRecordingCommandRunner(resultFor(releaseJson())) + val adapter = ProviderReleaseAdapter(listOf("provider"), Path.of("/repo"), runner) + + adapter.start( + StartReleaseRequest( + releaseId = "rel-1", + actor = Actor("human-1", ActorKind.HUMAN, "홍길동"), + ) + ) + + val request = Json.parseToJsonElement(runner.commands.single().tokens.last()).jsonObject + val actor = request["actor"]!!.jsonObject + actor["id"]?.jsonPrimitive?.content shouldBe "human-1" + actor["kind"]?.jsonPrimitive?.content shouldBe "HUMAN" + actor["name"]?.jsonPrimitive?.content shouldBe "홍길동" + } + + test("provider change가 있으면, receipt의 상태와 revision을 보존합니다") { + val runner = + ProviderReleaseRecordingCommandRunner( + resultFor( + """ + {"release":${releaseJson()},"change":{"id":"change-1","operation":"provider-start","status":"PENDING","before_revision":"r1","after_revision":"r2"}} + """ + .trimIndent() + ) + ) + val adapter = ProviderReleaseAdapter(listOf("provider"), Path.of("/repo"), runner) + + val response = + adapter + .start(StartReleaseRequest("rel-1", Actor("workflow", ActorKind.WORKFLOW))) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + + response.change.id shouldBe "change-1" + response.change.status shouldBe ChangeStatus.PENDING + response.change.beforeRevision shouldBe "r1" + response.change.afterRevision shouldBe "r2" + } + + test("provider change가 없으면, 작업별 receipt를 생성합니다") { + val runner = + ProviderReleaseRecordingCommandRunner(resultFor("{\"release\":${releaseJson()}}")) + val adapter = ProviderReleaseAdapter(listOf("provider"), Path.of("/repo"), runner) + + val response = + adapter + .continueRollout(ContinueReleaseRequest("rel-1")) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + + response.change.id shouldBe "provider-release-continue-rollout-rel-1" + response.change.operation shouldBe "provider-release-continue-rollout" + } + } + + context("외부 공개 provider 응답을 해석하면") { + test("get 응답에 releases 배열이 있으면, Release 목록으로 변환합니다") { + val runner = + ProviderReleaseRecordingCommandRunner( + resultFor("{\"releases\":[${releaseJson()},${releaseJson("rel-2")}]}") + ) + val adapter = ProviderReleaseAdapter(listOf("provider"), Path.of("/repo"), runner) + + val response = + adapter + .get(GetReleaseRequest(candidateId = "dc-1")) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + + response.releases.map { it.id } shouldBe listOf("rel-1", "rel-2") + val request = Json.parseToJsonElement(runner.commands.single().tokens.last()).jsonObject + request["candidate_id"]?.jsonPrimitive?.content shouldBe "dc-1" + request.containsKey("release_id") shouldBe false + } + + test("JSON이 유효하지 않으면, 응답 오류를 반환합니다") { + val adapter = + ProviderReleaseAdapter( + listOf("provider"), + Path.of("/repo"), + ProviderReleaseRecordingCommandRunner(resultFor("not-json")), + ) + + val failure = + adapter + .get(GetReleaseRequest(releaseId = "rel-1")) + .shouldBeTypeOf() + + failure.error.code shouldBe "RELEASE_PROVIDER_RESPONSE_INVALID" + } + + test("Release 대상이 요청과 다르면, 불변식 오류를 반환합니다") { + val adapter = + ProviderReleaseAdapter( + listOf("provider"), + Path.of("/repo"), + ProviderReleaseRecordingCommandRunner( + resultFor("{\"release\":${releaseJson("other")}}") + ), + ) + + val failure = + adapter + .validateInternal(ValidateReleaseRequest("rel-1")) + .shouldBeTypeOf() + + failure.error.code shouldBe "RELEASE_PROVIDER_INVARIANT_VIOLATION" + } + + test("change 필수 필드가 누락되면, 응답 오류를 반환합니다") { + val adapter = + ProviderReleaseAdapter( + listOf("provider"), + Path.of("/repo"), + ProviderReleaseRecordingCommandRunner( + resultFor( + "{\"release\":${releaseJson()},\"change\":{\"id\":\"change-1\",\"operation\":\"provider-create\"}}" + ) + ), + ) + + val failure = + adapter + .create(CreateReleaseRequest("rel-1", "dc-1")) + .shouldBeTypeOf() + + failure.error.code shouldBe "RELEASE_PROVIDER_RESPONSE_INVALID" + } + } + + context("외부 공개 provider 명령이 실패하면") { + withData( + nameFn = { (exitCode, retryable) -> + "종료 코드가 ${exitCode}이면, 재시도 가능 여부를 ${retryable}로 반환합니다" + }, + 1 to false, + 2 to true, + 3 to false, + ) { (exitCode, retryable) -> + val adapter = + ProviderReleaseAdapter( + listOf("provider"), + Path.of("/repo"), + ProviderReleaseRecordingCommandRunner( + CommandResult(exitCode, "", "provider unavailable") + ), + ) + + val failure = + adapter + .get(GetReleaseRequest(releaseId = "rel-1")) + .shouldBeTypeOf() + + failure.error.code shouldBe "RELEASE_PROVIDER_COMMAND_FAILED" + failure.error.message shouldBe "provider unavailable" + failure.error.target shouldBe "rel-1" + failure.error.retryable shouldBe retryable + } + + test("CommandRunner가 예외를 던지면, 재시도 가능한 PortError로 변환합니다") { + val adapter = + ProviderReleaseAdapter( + listOf("provider"), + Path.of("/repo"), + CommandRunner { _, _ -> error("provider unavailable") }, + ) + + val failure = + adapter + .get(GetReleaseRequest(releaseId = "rel-1")) + .shouldBeTypeOf() + + failure.error.code shouldBe "RELEASE_PROVIDER_COMMAND_FAILED" + failure.error.retryable shouldBe true + } + } + + context("외부 공개 provider 요청을 검증하면") { + test("명령 접두사에 빈 항목이 있으면, provider 명령을 실행하지 않습니다") { + val runner = ProviderReleaseRecordingCommandRunner(CommandResult(0, "", "")) + val adapter = ProviderReleaseAdapter(listOf("provider", ""), Path.of("/repo"), runner) + + val failure = + adapter + .get(GetReleaseRequest(releaseId = "rel-1")) + .shouldBeTypeOf() + + failure.error.code shouldBe "RELEASE_PROVIDER_COMMAND_INVALID" + runner.commands shouldBe emptyList() + } + + test("Release 식별자가 비어 있으면, provider 명령을 실행하지 않습니다") { + val runner = ProviderReleaseRecordingCommandRunner(CommandResult(0, "", "")) + val adapter = ProviderReleaseAdapter(listOf("provider"), Path.of("/repo"), runner) + + val failure = + adapter + .continueRollout(ContinueReleaseRequest("")) + .shouldBeTypeOf() + + failure.error.code shouldBe "RELEASE_PROVIDER_REQUEST_INVALID" + runner.commands shouldBe emptyList() + } + } + }) + +private class ProviderReleaseRecordingCommandRunner(private val result: CommandResult) : + CommandRunner { + val commands = mutableListOf() + + override fun run(command: List, workingDirectory: Path): CommandResult { + commands += ProviderReleaseInvocation(command, workingDirectory) + return result + } +} + +private data class ProviderReleaseInvocation( + val tokens: List, + val workingDirectory: Path, +) + +private fun resultFor(stdout: String): CommandResult = CommandResult(0, stdout, "") + +private fun releaseJson(id: String = "rel-1"): String = + """ + {"release_id":"$id","candidate_id":"dc-1","state":"SAFE_DEFAULT","production_ready":true,"internal_validation_passed":true} + """ + .trimIndent() diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/store/OkioWorkflowStoreAdapterTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/store/OkioWorkflowStoreAdapterTest.kt new file mode 100644 index 00000000..977916b8 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/store/OkioWorkflowStoreAdapterTest.kt @@ -0,0 +1,407 @@ +package io.springkit.workflow.adapter.store + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeTypeOf +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.StoreEventRequest +import io.springkit.workflow.application.StoreSnapshotRequest +import io.springkit.workflow.application.StoreTransactionRequest +import io.springkit.workflow.application.StoreTransactionState +import io.springkit.workflow.application.StoreWriteRequest +import io.springkit.workflow.application.WorkflowStoreSnapshot +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.Approval +import io.springkit.workflow.domain.AuditEntry +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.CheckResult +import io.springkit.workflow.domain.CheckSummary +import io.springkit.workflow.domain.CiStatus +import io.springkit.workflow.domain.Dependency +import io.springkit.workflow.domain.DeploymentCandidate +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.EventLog +import io.springkit.workflow.domain.GateRecorded +import io.springkit.workflow.domain.GateType +import io.springkit.workflow.domain.IdSequence +import io.springkit.workflow.domain.Integration +import io.springkit.workflow.domain.IntegrationState +import io.springkit.workflow.domain.MergeQueueEntry +import io.springkit.workflow.domain.MergeQueueState +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.Release +import io.springkit.workflow.domain.ReleaseState +import io.springkit.workflow.domain.ReviewComment +import io.springkit.workflow.domain.ReviewLevel +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.ReviewThread +import io.springkit.workflow.domain.Risk +import io.springkit.workflow.domain.StartRequestKey +import io.springkit.workflow.domain.StartRequestRecord +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.SyncConflict +import io.springkit.workflow.domain.Task +import io.springkit.workflow.domain.TaskState +import io.springkit.workflow.domain.ThreadState +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.ValidationStatus +import io.springkit.workflow.domain.WorkflowState +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspacePath +import java.io.IOException +import java.nio.file.Files +import okio.FileSystem +import okio.ForwardingFileSystem +import okio.Path +import okio.Path.Companion.toOkioPath +import okio.Path.Companion.toPath +import okio.buffer +import okio.fakefilesystem.FakeFileSystem + +class OkioWorkflowStoreAdapterTest : + FunSpec({ + context("저장소가 비어 있는 상태에서 스냅샷을 조회하면") { + test("저장소가 비어 있으면, 초기 revision과 빈 컬렉션을 반환합니다") { + val result = + adapter().snapshot(StoreSnapshotRequest()).shouldBeTypeOf>() + val snapshot = + result.value + .shouldBeTypeOf() + .snapshot + + snapshot.revision shouldBe "0" + snapshot.sequence shouldBe IdSequence() + snapshot.tasks shouldBe emptyList() + snapshot.subTasks shouldBe emptyList() + snapshot.eventLog.events shouldBe emptyList() + } + } + + context("트랜잭션을 기록하고 커밋하면") { + test("트랜잭션을 기록하고 커밋하면, 모든 workflow state 필드를 저장한 뒤 revision을 증가시켜 다시 읽습니다") { + val fileSystem = FakeFileSystem() + val path = "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/workflow/state.json".toPath() + val adapter = OkioWorkflowStoreAdapter(fileSystem, path) + val expected = fixtureSnapshot("0") + val transaction = + StoreTransactionRequest( + "tx-1", + expectedRevision = "0", + idempotencyKey = "request-1", + ) + + adapter.begin(transaction).shouldBeTypeOf>() + adapter + .write(StoreWriteRequest(transaction.transactionId, "0", expected)) + .shouldBeTypeOf>() + val committed = adapter.commit(transaction).shouldBeTypeOf>() + val commitResponse = + committed.value.shouldBeTypeOf< + io.springkit.workflow.application.StoreTransactionResponse + >() + commitResponse.state shouldBe StoreTransactionState.COMMITTED + commitResponse.revision shouldBe "1" + + val reopened = OkioWorkflowStoreAdapter(fileSystem, path).snapshot(StoreSnapshotRequest()) + val actual = reopened.shouldBeTypeOf>().value + actual + .shouldBeTypeOf() + .snapshot shouldBe expected.copy(revision = "1") + } + } + + context("트랜잭션을 롤백하면") { + test("트랜잭션을 롤백하면, 대상 상태를 기록하지 않고 현재 revision을 유지합니다") { + val fileSystem = FakeFileSystem() + val adapter = OkioWorkflowStoreAdapter(fileSystem, "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/workflow/state.json".toPath()) + val transaction = StoreTransactionRequest("tx-rollback") + + adapter.begin(transaction).shouldBeTypeOf>() + adapter + .write(StoreWriteRequest(transaction.transactionId, "0", fixtureSnapshot("0"))) + .shouldBeTypeOf>() + val rollback = adapter.rollback(transaction).shouldBeTypeOf>() + rollback.value + .shouldBeTypeOf() + .state shouldBe StoreTransactionState.ROLLED_BACK + + val result = + adapter.snapshot(StoreSnapshotRequest()).shouldBeTypeOf>() + val snapshot = + result.value + .shouldBeTypeOf() + .snapshot + snapshot.revision shouldBe "0" + snapshot.tasks shouldBe emptyList() + } + } + + context("현재 revision과 다른 예상 revision으로 트랜잭션을 시작하면") { + test("현재 revision과 다른 예상 revision으로 트랜잭션을 시작하면, 재시도 가능한 STALE_REVISION 오류를 반환합니다") { + val result = adapter().begin(StoreTransactionRequest("tx-stale", expectedRevision = "7")) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "STALE_REVISION" + failure.error.retryable shouldBe true + } + } + + context("같은 트랜잭션 식별자를 다른 idempotency 키와 함께 재사용하면") { + test("같은 트랜잭션 식별자를 다른 idempotency 키와 함께 재사용하면, IDEMPOTENCY_CONFLICT 오류를 반환합니다") { + val adapter = adapter() + val first = StoreTransactionRequest("tx-identity", idempotencyKey = "same") + + adapter.begin(first).shouldBeTypeOf>() + adapter.begin(first).shouldBeTypeOf>() + val conflict = adapter.begin(first.copy(idempotencyKey = "different")) + conflict.shouldBeTypeOf().error.code shouldBe "IDEMPOTENCY_CONFLICT" + adapter.commit(first).shouldBeTypeOf>() + adapter.begin(first).shouldBeTypeOf>() + adapter + .begin(first.copy(idempotencyKey = "different")) + .shouldBeTypeOf() + .error + .code shouldBe "IDEMPOTENCY_CONFLICT" + } + } + + context("이벤트를 기록한 트랜잭션을 커밋하면") { + test("이벤트를 기록한 트랜잭션을 커밋하면, 이벤트를 스냅샷에 추가합니다") { + val fileSystem = FakeFileSystem() + val adapter = OkioWorkflowStoreAdapter(fileSystem, "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/workflow/state.json".toPath()) + val transaction = StoreTransactionRequest("tx-event") + val event = GateRecorded("sk-101", GateType.READY, Actor("agent", ActorKind.AGENT), 10) + + adapter.begin(transaction).shouldBeTypeOf>() + val appended = adapter.append(StoreEventRequest(transaction.transactionId, event)) + appended + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .revision shouldBe "1" + adapter.commit(transaction).shouldBeTypeOf>() + val snapshot = + adapter + .snapshot(StoreSnapshotRequest()) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + .snapshot + snapshot.eventLog.events shouldBe listOf(event) + } + } + + context("저장된 JSON이 올바르지 않은 상태에서 스냅샷을 조회하면") { + test("저장된 JSON이 올바르지 않은 상태에서 스냅샷을 조회하면, STORE_CORRUPT 오류를 반환합니다") { + val fileSystem = FakeFileSystem() + val path = "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/workflow/state.json".toPath() + fileSystem.createDirectories(checkNotNull(path.parent)) + fileSystem.sink(path).buffer().use { it.writeUtf8("{not-json") } + + val failure = + OkioWorkflowStoreAdapter(fileSystem, path) + .snapshot(StoreSnapshotRequest()) + .shouldBeTypeOf() + failure.error.code shouldBe "STORE_CORRUPT" + } + } + + context("원자적 파일 이동이 실패하면") { + test("원자적 파일 이동이 실패하면, 기존 저장 상태를 보존하고 STORE_IO_FAILURE 오류를 반환합니다") { + val fileSystem = FakeFileSystem() + val path = "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/workflow/state.json".toPath() + val initial = OkioWorkflowStoreAdapter(fileSystem, path) + val first = StoreTransactionRequest("tx-first") + initial.begin(first).shouldBeTypeOf>() + initial + .write(StoreWriteRequest("tx-first", "0", fixtureSnapshot("0"))) + .shouldBeTypeOf>() + initial.commit(first).shouldBeTypeOf>() + val before = fileSystem.source(path).buffer().use { it.readUtf8() } + + val failing = OkioWorkflowStoreAdapter(FailingAtomicMoveFileSystem(fileSystem), path) + val second = StoreTransactionRequest("tx-second", expectedRevision = "1") + failing.begin(second).shouldBeTypeOf>() + failing + .write(StoreWriteRequest("tx-second", "1", fixtureSnapshot("1"))) + .shouldBeTypeOf>() + val failure = failing.commit(second).shouldBeTypeOf() + + failure.error.code shouldBe "STORE_IO_FAILURE" + fileSystem.source(path).buffer().use { it.readUtf8() } shouldBe before + } + } + + context("여러 프로세스가 잠금을 동시에 요청하면") { + test("여러 프로세스가 잠금을 동시에 요청하면, 잠금을 먼저 획득한 트랜잭션만 진행하고 해제 후 다른 트랜잭션을 허용합니다") { + val directory = Files.createTempDirectory("springkit-store-test") + val path = directory.resolve("state.json").toOkioPath() + try { + val first = OkioWorkflowStoreAdapter(FileSystem.SYSTEM, path) + val second = OkioWorkflowStoreAdapter(FileSystem.SYSTEM, path) + val transaction = StoreTransactionRequest("tx-lock", expectedRevision = "0") + + first.begin(transaction).shouldBeTypeOf>() + val blocked = second.begin(StoreTransactionRequest("tx-other", expectedRevision = "0")) + blocked.shouldBeTypeOf().error.code shouldBe "LOCK_UNAVAILABLE" + first.rollback(transaction).shouldBeTypeOf>() + second + .begin(StoreTransactionRequest("tx-other", expectedRevision = "0")) + .shouldBeTypeOf>() + second + .rollback(StoreTransactionRequest("tx-other", expectedRevision = "0")) + .shouldBeTypeOf>() + } finally { + FileSystem.SYSTEM.deleteRecursively(directory.toOkioPath(), mustExist = false) + } + } + } + }) + +private fun adapter(): OkioWorkflowStoreAdapter = + OkioWorkflowStoreAdapter(FakeFileSystem(), "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/workflow/state.json".toPath()) + +private fun fixtureSnapshot(revision: String): WorkflowStoreSnapshot { + val state = fixtureState().copy(storeRevision = revision.toLong()) + val subTasks = state.subTasks.values.sortedBy { it.id } + return WorkflowStoreSnapshot( + revision = revision, + sequence = state.sequence, + tasks = state.tasks.values.toList(), + subTasks = subTasks, + dependencies = + subTasks.mapNotNull { it.requires?.let { required -> Dependency(it.id, required) } }, + workspaces = subTasks.mapNotNull { it.workspace }, + pullRequests = state.pullRequests.values.toList(), + checks = state.checks, + integrations = state.integrations.values.toList(), + mergeQueue = state.mergeQueue.values.toList(), + candidates = state.deploymentCandidates.values.toList(), + releases = state.releases.values.toList(), + startRequests = state.startRequests, + syncConflicts = state.syncConflicts, + eventLog = state.eventLog, + ) +} + +private fun fixtureState(): WorkflowState { + val human = Actor("alice", ActorKind.HUMAN, "Alice") + val validation = Validation("build", "Build", ValidationStatus.PASSED, revision = "cr-1") + val workspaceA = Workspace("ws-a", "sk-101", WorkspacePath("/tmp/sk-101"), "sk-101", dirty = true) + val workspaceB = + Workspace("ws-b", "sk-102", WorkspacePath("/tmp/sk-102"), "sk-102", managed = false) + val task = + Task( + "task-1", + io.springkit.workflow.domain.ExternalTaskId("external-1"), + "Task", + TaskState.IN_PROGRESS, + listOf("sk-101", "sk-102"), + ) + val subTaskA = + SubTask( + "sk-101", + "task-1", + "First", + SubTaskState.APPROVED, + workspace = workspaceA, + risk = Risk.HIGH, + ) + val subTaskB = SubTask("sk-102", "task-1", "Second", requires = "sk-101", workspace = workspaceB) + val comment = ReviewComment("comment-1", human, "Looks good", 10, "src/App.kt", 4) + val review = + ReviewRevision( + "rv-1", + 1, + "Review", + listOf( + ReviewThread("thread-1", ReviewLevel.R, listOf(comment), ThreadState.RESOLVED, true) + ), + 10, + ) + val change = ChangeRevision("cr-1", 1, Diff("diff-1", listOf("src/App.kt"), 3, 1), 11) + val pullRequest = + PullRequest( + "pr-1", + "sk-101", + "First", + "Body", + "develop", + PullRequestState.APPROVED, + Risk.HIGH, + review, + change, + Approval("approval-1", human, "cr-1", "diff-1", 12), + CiStatus.PASSED, + ) + val queue = + MergeQueueEntry( + "mq-1", + "sk-101", + "pr-1", + "cr-1", + MergeQueueState.PASSED, + listOf(validation), + ) + val candidate = + DeploymentCandidate( + "candidate-1", + "main-1", + listOf("sk-101", "sk-102"), + mapOf("sk-101" to Risk.HIGH), + listOf(validation), + ) + val event = GateRecorded("sk-101", GateType.APPROVE, human, 20) + return WorkflowState( + storeRevision = 0, + sequence = IdSequence(102, 1, 1, 1, 1, 1, 1, 1, 1, 1), + tasks = mapOf(task.id to task), + subTasks = mapOf(subTaskA.id to subTaskA, subTaskB.id to subTaskB), + pullRequests = mapOf(pullRequest.id to pullRequest), + checks = + mapOf( + "sk-101" to + CheckSummary( + "fp-1", + "cr-1", + listOf(CheckResult("test", "Tests", ValidationStatus.PASSED, "fp-1", "cr-1")), + ) + ), + integrations = + mapOf( + "sk-101" to + Integration("sk-101", IntegrationState.MERGED, queue, "main-1", "squash-1") + ), + mergeQueue = mapOf(queue.id to queue), + deploymentCandidates = mapOf(candidate.id to candidate), + releases = + mapOf( + "release-1" to Release("release-1", "candidate-1", ReleaseState.RELEASED, true, true) + ), + startRequests = + mapOf( + StartRequestKey("task-1", "request-1") to + StartRequestRecord( + StartRequestKey("task-1", "request-1"), + "sk-102", + "Second", + "sk-101", + ) + ), + syncConflicts = mapOf("sk-102" to SyncConflict("sk-102", subTaskB, listOf("src/App.kt"), 21)), + eventLog = + EventLog( + listOf(event), + listOf(AuditEntry("audit-1", human, "approve", "sk-101", occurredAtEpochMillis = 20)), + ), + ) +} + +private class FailingAtomicMoveFileSystem(delegate: FileSystem) : ForwardingFileSystem(delegate) { + override fun atomicMove(source: Path, target: Path) { + throw IOException("injected atomic move failure") + } +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/store/StoreIdAdapterTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/store/StoreIdAdapterTest.kt new file mode 100644 index 00000000..d1b1b15d --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/store/StoreIdAdapterTest.kt @@ -0,0 +1,160 @@ +package io.springkit.workflow.adapter.store + +import io.kotest.core.spec.style.FunSpec +import io.kotest.datatest.withData +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.collections.shouldNotContainDuplicates +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldEndWith +import io.kotest.matchers.string.shouldMatch +import io.kotest.matchers.types.shouldBeTypeOf +import io.springkit.workflow.application.IdKind +import io.springkit.workflow.application.IssueIdRequest +import io.springkit.workflow.application.IssueIdResponse +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.StoreSnapshotRequest +import io.springkit.workflow.application.StoreSnapshotResponse +import io.springkit.workflow.application.StoreTransactionRequest +import io.springkit.workflow.application.StoreTransactionResponse +import io.springkit.workflow.application.WorkflowStorePort +import java.util.Collections +import kotlin.concurrent.thread +import okio.Path.Companion.toPath +import okio.fakefilesystem.FakeFileSystem + +class StoreIdAdapterTest : + FunSpec({ + context("sequence 기반 식별자를 발급할 때") { + withData( + nameFn = { "${it.first}를 요청하면, ${it.second} 접두사의 연속된 ID를 발급합니다" }, + IdKind.REVIEW_REVISION to "rv", + IdKind.CHANGE_REVISION to "cr", + IdKind.THREAD to "thread", + IdKind.COMMENT to "comment", + IdKind.MERGE_QUEUE to "mq", + IdKind.CANDIDATE to "dc", + IdKind.RELEASE to "rel", + IdKind.AUDIT to "audit", + ) { (kind, prefix) -> + val store = OkioWorkflowStoreAdapter(FakeFileSystem(), "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/workflow/state.json".toPath()) + val result = StoreIdAdapter(store).issue(IssueIdRequest(kind, count = 3)) + + val success = result.shouldBeTypeOf>() + success.value.ids.map { it.value } shouldBe listOf("$prefix-1", "$prefix-2", "$prefix-3") + } + } + + context("Store를 재시작한 뒤 sequence 기반 식별자를 발급할 때") { + test("Store를 재시작하면, 이전에 발급한 다음 번호를 이어서 반환합니다") { + val fileSystem = FakeFileSystem() + val path = "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/workflow/state.json".toPath() + + StoreIdAdapter(OkioWorkflowStoreAdapter(fileSystem, path)) + .issue(IssueIdRequest(IdKind.REVIEW_REVISION, count = 2)) + .shouldBeTypeOf>() + val restarted = StoreIdAdapter(OkioWorkflowStoreAdapter(fileSystem, path)) + + val result = restarted.issue(IssueIdRequest(IdKind.REVIEW_REVISION)) + + result + .shouldBeTypeOf>() + .value + .ids + .single() + .value shouldBe "rv-3" + } + } + + context("여러 adapter 인스턴스가 같은 Store를 사용할 때") { + test("여러 adapter 인스턴스가 동시에 요청하면, 중복 없이 sequence를 증가시킵니다") { + val store = OkioWorkflowStoreAdapter(FakeFileSystem(), "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/workflow/state.json".toPath()) + val adapters = List(8) { StoreIdAdapter(store) } + val issued = Collections.synchronizedList(mutableListOf()) + val workers = adapters.map { adapter -> + thread(start = false) { + val result = adapter.issue(IssueIdRequest(IdKind.COMMENT, count = 4)) + issued += + result.shouldBeTypeOf>().value.ids.map { + it.value + } + } + } + + workers.forEach(Thread::start) + workers.forEach(Thread::join) + + issued shouldHaveSize 32 + issued.shouldNotContainDuplicates() + issued.map { it.removePrefix("comment-").toLong() }.sorted() shouldBe (1L..32L).toList() + } + } + + context("sequence가 없는 인프라 식별자를 발급할 때") { + test("안전하지 않은 요청 ID를 입력하면, 접미사를 보존하면서 충돌하지 않는 ID를 발급합니다") { + val adapter = + StoreIdAdapter( + OkioWorkflowStoreAdapter( + FakeFileSystem(), + "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/workflow/state.json".toPath(), + ) + ) + + val first = adapter.issue(IssueIdRequest(IdKind.TASK, "unsafe/request value")) + val second = adapter.issue(IssueIdRequest(IdKind.TASK, "unsafe/request value")) + + val firstId = + first.shouldBeTypeOf>().value.ids.single().value + val secondId = + second.shouldBeTypeOf>().value.ids.single().value + firstId shouldMatch Regex("[A-Za-z0-9._-]+") + secondId shouldMatch Regex("[A-Za-z0-9._-]+") + firstId shouldEndWith "unsafe-request-value" + secondId shouldEndWith "unsafe-request-value" + (firstId == secondId) shouldBe false + } + } + + context("Store transaction이 열리지 않을 때") { + test("Store transaction이 열리지 않으면, fallback ID를 만들지 않고 Store 오류를 반환합니다") { + val store = + FailingBeginStore( + OkioWorkflowStoreAdapter(FakeFileSystem(), "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/workflow/state.json".toPath()) + ) + + val result = StoreIdAdapter(store).issue(IssueIdRequest(IdKind.RELEASE)) + + val failure = result.shouldBeTypeOf() + failure.error.code shouldBe "LOCK_UNAVAILABLE" + store.rollbackCalled shouldBe false + } + } + + context("발급한 sequence를 snapshot으로 확인할 때") { + test("sequence 발급 개수를 요청하면, 해당 카운터만 요청한 개수만큼 증가합니다") { + val store = OkioWorkflowStoreAdapter(FakeFileSystem(), "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/workflow/state.json".toPath()) + + StoreIdAdapter(store).issue(IssueIdRequest(IdKind.AUDIT, count = 4)) + val snapshot = + store + .snapshot(StoreSnapshotRequest()) + .shouldBeTypeOf>() + + snapshot.value.snapshot.sequence.audit shouldBe 4 + snapshot.value.snapshot.sequence.review shouldBe 0 + snapshot.value.snapshot.sequence.change shouldBe 0 + } + } + }) + +private class FailingBeginStore(delegate: WorkflowStorePort) : WorkflowStorePort by delegate { + var rollbackCalled: Boolean = false + + override fun begin(request: StoreTransactionRequest): PortResult = + PortResult.Failure(PortError("LOCK_UNAVAILABLE", "저장소가 잠겨 있습니다.", retryable = true)) + + override fun rollback(request: StoreTransactionRequest): PortResult { + rollbackCalled = true + return PortResult.Failure(PortError("TRANSACTION_NOT_FOUND", "트랜잭션이 없습니다.")) + } +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/store/WorkflowStateJsonCodecTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/store/WorkflowStateJsonCodecTest.kt new file mode 100644 index 00000000..4af3feca --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/store/WorkflowStateJsonCodecTest.kt @@ -0,0 +1,322 @@ +package io.springkit.workflow.adapter.store + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.springkit.workflow.adapter.json.WorkflowJson +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.Approval +import io.springkit.workflow.domain.AuditEntry +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.CheckResult +import io.springkit.workflow.domain.CheckSummary +import io.springkit.workflow.domain.CiStatus +import io.springkit.workflow.domain.DeploymentCandidate +import io.springkit.workflow.domain.DeploymentCandidateState +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.EventLog +import io.springkit.workflow.domain.ExternalTaskId +import io.springkit.workflow.domain.GateRecorded +import io.springkit.workflow.domain.GateType +import io.springkit.workflow.domain.IdSequence +import io.springkit.workflow.domain.Integration +import io.springkit.workflow.domain.IntegrationState +import io.springkit.workflow.domain.MergeQueueEntry +import io.springkit.workflow.domain.MergeQueueState +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.Release +import io.springkit.workflow.domain.ReleaseState +import io.springkit.workflow.domain.ReviewComment +import io.springkit.workflow.domain.ReviewLevel +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.ReviewThread +import io.springkit.workflow.domain.Risk +import io.springkit.workflow.domain.StartRequestKey +import io.springkit.workflow.domain.StartRequestRecord +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskCleanupState +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.SyncConflict +import io.springkit.workflow.domain.Task +import io.springkit.workflow.domain.TaskState +import io.springkit.workflow.domain.ThreadState +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.ValidationStatus +import io.springkit.workflow.domain.WorkflowState +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspacePath +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject + +class WorkflowStateJsonCodecTest : + FunSpec({ + context("내구성 있는 모든 상태 영역을 JSON으로 인코딩하고 디코딩하면") { + test("내구성 있는 모든 상태 영역을 JSON으로 인코딩하고 디코딩하면, 원래 상태와 일치하고 문자열과 바이트 배열 입력을 모두 지원합니다") { + val state = fixture() + + val encoded = WorkflowStateJsonCodec.encodeToString(state) + val decoded = WorkflowStateJsonCodec.decode(encoded) + + decoded shouldBe state + WorkflowStateJsonCodec.decode(encoded.toByteArray()) shouldBe state + WorkflowStateJsonCodec.encodeToString(decoded) shouldBe encoded + } + } + + context("같은 상태를 여러 번 JSON으로 인코딩하면") { + test("같은 상태를 여러 번 JSON으로 인코딩하면, 항상 같은 문자열을 생성합니다") { + val state = fixture() + + WorkflowStateJsonCodec.encodeToString(state) shouldBe + WorkflowStateJsonCodec.encodeToString(state) + } + } + + context("이전 Feature Flag 필드가 포함된 상태를 디코딩하면") { + test("구형 필드를 무시하고 다시 인코딩할 때 제거합니다") { + val state = fixture() + val root = + WorkflowJson.format + .parseToJsonElement(WorkflowStateJsonCodec.encodeToString(state)) + .jsonObject + fun withLegacyFields(collection: String, id: String, fields: Map) = + JsonObject( + root.getValue(collection).jsonObject.let { records -> + records + (id to JsonObject(records.getValue(id).jsonObject + fields)) + } + ) + val legacyRoot = + JsonObject( + root + + ("sub_tasks" to + withLegacyFields( + "sub_tasks", + "sk-101", + mapOf( + "exposure" to JsonPrimitive("FEATURE_FLAG"), + "feature_flag_id" to JsonPrimitive("flag-1"), + ), + )) + + ("pull_requests" to + withLegacyFields( + "pull_requests", + "pr-1", + mapOf( + "exposure" to JsonPrimitive("FEATURE_FLAG"), + "feature_flag_id" to JsonPrimitive("flag-1"), + ), + )) + + ("releases" to + withLegacyFields( + "releases", + "release-1", + mapOf("feature_flag_id" to JsonPrimitive("flag-1")), + )) + ) + + val decoded = WorkflowStateJsonCodec.decode(legacyRoot) + val encoded = + WorkflowJson.format + .parseToJsonElement(WorkflowStateJsonCodec.encodeToString(decoded)) + .jsonObject + + decoded shouldBe state + encoded + .getValue("sub_tasks") + .jsonObject + .getValue("sk-101") + .jsonObject["feature_flag_id"] shouldBe null + encoded + .getValue("pull_requests") + .jsonObject + .getValue("pr-1") + .jsonObject["exposure"] shouldBe null + encoded + .getValue("releases") + .jsonObject + .getValue("release-1") + .jsonObject["feature_flag_id"] shouldBe null + } + } + + context("Merge 이후 정리 SHA checkpoint를 JSON으로 저장하면") { + test("다시 읽은 상태에 검증된 Git SHA가 보존됩니다") { + val base = fixture() + val subTask = + base.subTasks + .getValue("sk-101") + .copy( + state = SubTaskState.MERGED, + cleanupState = SubTaskCleanupState.REMOTE_BRANCH_REMOVED, + cleanupRevision = "0123456789abcdef0123456789abcdef01234567", + ) + val state = base.copy(subTasks = base.subTasks + (subTask.id to subTask)) + + val decoded = WorkflowStateJsonCodec.decode(WorkflowStateJsonCodec.encodeToString(state)) + + decoded.subTasks.getValue(subTask.id).cleanupRevision shouldBe subTask.cleanupRevision + } + } + + context("지원하지 않는 schemaVersion으로 상태를 디코딩하면") { + test("지원하지 않는 schemaVersion으로 상태를 디코딩하면, StateDecodeException을 발생시킵니다") { + val encoded = WorkflowStateJsonCodec.encodeToString(fixture()) + + shouldThrow { + WorkflowStateJsonCodec.decode(withRootField(encoded, "schemaVersion", JsonPrimitive(0))) + } + } + } + + context("음수 storeRevision으로 상태를 디코딩하면") { + test("음수 storeRevision으로 상태를 디코딩하면, StateDecodeException을 발생시킵니다") { + val encoded = WorkflowStateJsonCodec.encodeToString(fixture()) + + shouldThrow { + WorkflowStateJsonCodec.decode( + withRootField(encoded, "storeRevision", JsonPrimitive(-1)) + ) + } + } + } + + context("숫자가 아닌 storeRevision으로 상태를 디코딩하면") { + test("숫자가 아닌 storeRevision으로 상태를 디코딩하면, StateDecodeException을 발생시킵니다") { + shouldThrow { + WorkflowStateJsonCodec.decode("{\"storeRevision\":\"not-a-number\"}") + } + } + } + }) + +private fun withRootField(encoded: String, normalizedName: String, value: JsonPrimitive): String { + val root = WorkflowJson.format.parseToJsonElement(encoded).jsonObject + val name = root.keys.single { it.replace("_", "").equals(normalizedName, ignoreCase = true) } + return JsonObject(root + (name to value)).toString() +} + +private fun fixture(): WorkflowState { + val subTask = + SubTask( + id = "sk-101", + taskId = "TASK-1", + title = "Persist state", + state = SubTaskState.APPROVED, + workspace = + Workspace("ws-1", "sk-101", WorkspacePath("/tmp/sk-101"), "sk-101", dirty = true), + risk = Risk.HIGH, + ) + val human = Actor("alice", ActorKind.HUMAN, "Alice") + val comment = ReviewComment("comment-1", human, "Please keep the state stable", 11, "state.kt", 4) + val review = + ReviewRevision( + "rv-1", + 1, + "State persistence review", + listOf( + ReviewThread("thread-1", ReviewLevel.R, listOf(comment), ThreadState.RESOLVED, true) + ), + 10, + ) + val diff = Diff("diff-1", listOf("state.kt"), additions = 12, deletions = 2) + val change = ChangeRevision("cr-1", 1, diff, 10, providerRevision = "head-1") + val pullRequest = + PullRequest( + "pr-1", + "sk-101", + "Persist workflow state", + "Stores all workflow state in a deterministic JSON document.", + "develop", + PullRequestState.APPROVED, + Risk.HIGH, + review, + change, + Approval("approval-1", human, "cr-1", "diff-1", 12), + CiStatus.PASSED, + ) + val validation = Validation("build", "Build", ValidationStatus.PASSED, revision = "cr-1") + val check = CheckResult("test", "Tests", ValidationStatus.PASSED, "fp-1", "cr-1") + val queue = + MergeQueueEntry( + "mq-1", + "sk-101", + "pr-1", + "cr-1", + MergeQueueState.PASSED, + listOf(validation), + ) + val candidate = + DeploymentCandidate( + "candidate-1", + "main-1", + listOf("sk-101"), + mapOf("sk-101" to Risk.HIGH), + listOf(validation), + DeploymentCandidateState.CANDIDATE, + ) + val event = GateRecorded("sk-101", GateType.APPROVE, human, 20) + return WorkflowState( + storeRevision = 7, + sequence = + IdSequence( + subTask = 101, + review = 1, + change = 1, + thread = 1, + comment = 1, + approval = 1, + mergeQueue = 1, + candidate = 1, + release = 1, + audit = 1, + ), + tasks = + mapOf( + "task-1" to + Task( + "task-1", + ExternalTaskId("TASK-1"), + "Persistence", + TaskState.IN_PROGRESS, + listOf("sk-101"), + ) + ), + subTasks = mapOf("sk-101" to subTask), + pullRequests = mapOf("pr-1" to pullRequest), + checks = mapOf("sk-101" to CheckSummary("fp-1", "cr-1", listOf(check))), + integrations = mapOf("sk-101" to Integration("sk-101", IntegrationState.QUEUED, queue)), + mergeQueue = mapOf("mq-1" to queue), + deploymentCandidates = mapOf("candidate-1" to candidate), + releases = + mapOf( + "release-1" to Release("release-1", "candidate-1", ReleaseState.INTERNAL_VALIDATION) + ), + startRequests = + mapOf( + StartRequestKey("TASK-1", "req-1") to + StartRequestRecord( + StartRequestKey("TASK-1", "req-1"), + "sk-101", + "Persist state", + ) + ), + syncConflicts = mapOf("sk-101" to SyncConflict("sk-101", subTask, listOf("state.kt"), 21)), + eventLog = + EventLog( + listOf(event), + listOf( + AuditEntry( + "audit-1", + human, + "approve", + "sk-101", + changeRevisionId = "cr-1", + occurredAtEpochMillis = 20, + ) + ), + ), + ) +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/validation/LocalValidationAdapterTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/validation/LocalValidationAdapterTest.kt new file mode 100644 index 00000000..3e6c5838 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/adapter/validation/LocalValidationAdapterTest.kt @@ -0,0 +1,272 @@ +package io.springkit.workflow.adapter.validation + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeTypeOf +import io.springkit.workflow.application.GetValidationRequest +import io.springkit.workflow.application.GetValidationResponse +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.RunValidationRequest +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.domain.CheckResult +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.ValidationStatus +import java.nio.file.Files +import java.nio.file.Path + +class LocalValidationAdapterTest : + FunSpec({ + context("필수 검증을 실행하면") { + test("test, build, static 검증을 입력 순서대로 실행하면, 각 결과와 CheckSummary를 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "test passed", "")) + runner.enqueue(CommandResult(0, "build passed", "")) + runner.enqueue(CommandResult(0, "spotless check passed", "")) + val workspace = Path.of("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/workspace/sk-101") + val adapter = LocalValidationAdapter({ workspaceId -> workspace }, commandRunner = runner) + val request = request() + + val result = adapter.run(request).shouldBeTypeOf>().value + val response = + result.shouldBeTypeOf() + + response.validations.map { it.status } shouldBe + listOf(ValidationStatus.PASSED, ValidationStatus.PASSED, ValidationStatus.PASSED) + response.summary?.checks?.map { it.status } shouldBe + listOf(ValidationStatus.PASSED, ValidationStatus.PASSED, ValidationStatus.PASSED) + runner.invocations shouldContainExactly + listOf( + Invocation(listOf("./gradlew", "test"), workspace), + Invocation(listOf("./gradlew", "build"), workspace), + Invocation(listOf("./gradlew", "spotlessCheck"), workspace), + ) + } + + test("명령 토큰에 공백이 포함되어 있으면, 셸을 거치지 않고 토큰 목록을 전달합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "", "")) + val workspace = Path.of("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/workspace/sk 101") + val adapter = + LocalValidationAdapter( + workspacePathResolver = { workspace }, + validationCommands = + mapOf("test" to listOf("./gradlew", "test", "--tests", "A B")), + commandRunner = runner, + ) + + adapter.run(request(required = listOf(validation("test")))) + + runner.invocations shouldBe + listOf(Invocation(listOf("./gradlew", "test", "--tests", "A B"), workspace)) + } + + test("repository root에 wrapper가 없으면 tools/workflow wrapper 경로를 사용합니다") { + val workspace = Files.createTempDirectory("workflow-validation").toAbsolutePath() + val moduleDirectory = Files.createDirectories(workspace.resolve("tools/workflow")) + Files.createFile(moduleDirectory.resolve("gradlew")) + try { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "spotless check passed", "")) + val adapter = LocalValidationAdapter({ workspace }, commandRunner = runner) + + adapter.run(request(required = listOf(validation("static")))) + + runner.invocations shouldBe + listOf( + Invocation( + listOf("./gradlew", "spotlessCheck"), + moduleDirectory, + ) + ) + } finally { + workspace.toFile().deleteRecursively() + } + } + + test("repository root wrapper가 있으면 module wrapper보다 우선합니다") { + val workspace = Files.createTempDirectory("workflow-validation").toAbsolutePath() + Files.createFile(workspace.resolve("gradlew")) + Files.createDirectories(workspace.resolve("tools/workflow")) + Files.createFile(workspace.resolve("tools/workflow/gradlew")) + try { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(0, "spotless check passed", "")) + val adapter = LocalValidationAdapter({ workspace }, commandRunner = runner) + + adapter.run(request(required = listOf(validation("static")))) + + runner.invocations shouldBe + listOf( + Invocation( + listOf("./gradlew", "spotlessCheck"), + workspace, + ) + ) + } finally { + workspace.toFile().deleteRecursively() + } + } + } + + context("검증 명령이 실패하면") { + test("종료 코드가 0이 아니면, 해당 Validation과 CheckResult를 FAILED로 반환합니다") { + val runner = RecordingCommandRunner() + runner.enqueue(CommandResult(7, "", "tests failed")) + val adapter = LocalValidationAdapter({ Path.of("/workspace") }, commandRunner = runner) + + val response = + adapter + .run(request(required = listOf(validation("test")))) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + + response.validations.single().status shouldBe ValidationStatus.FAILED + response.validations.single().message shouldContain "tests failed" + response.summary?.checks?.single()?.status shouldBe ValidationStatus.FAILED + } + + test("첫 번째 명령이 예외를 발생시키면, 해당 검증을 실패로 기록하고 다음 명령을 실행합니다") { + val runner = RecordingCommandRunner() + runner.enqueueFailure() + runner.enqueue(CommandResult(0, "build passed", "")) + runner.enqueue(CommandResult(0, "spotless check passed", "")) + val workspace = Path.of("/workspace") + val adapter = LocalValidationAdapter({ workspace }, commandRunner = runner) + + val response = + adapter + .run(request()) + .shouldBeTypeOf>() + .value + .shouldBeTypeOf() + + response.validations.map { it.status } shouldBe + listOf( + ValidationStatus.FAILED, + ValidationStatus.PASSED, + ValidationStatus.PASSED, + ) + response.validations.first().message shouldContain "검증 명령 실행 중 예외" + runner.invocations shouldContainExactly + listOf( + Invocation(listOf("./gradlew", "test"), workspace), + Invocation(listOf("./gradlew", "build"), workspace), + Invocation(listOf("./gradlew", "spotlessCheck"), workspace), + ) + } + } + + context("Adapter 설정과 workspace를 확인하면") { + test("검증 명령이 매핑되지 않으면, 명령을 실행하지 않고 PortResult.Failure를 반환합니다") { + val runner = RecordingCommandRunner() + val adapter = + LocalValidationAdapter( + workspacePathResolver = { Path.of("/workspace") }, + validationCommands = emptyMap(), + commandRunner = runner, + ) + + val failure = + adapter + .run(request(required = listOf(validation("lint")))) + .shouldBeTypeOf() + + failure.error.code shouldBe "VALIDATION_COMMAND_NOT_CONFIGURED" + runner.invocations shouldBe emptyList() + } + + test("workspace 경로 조회가 예외를 발생시키면, 실행하지 않고 PortResult.Failure를 반환합니다") { + val runner = RecordingCommandRunner() + val adapter = + LocalValidationAdapter({ error("workspace unavailable") }, commandRunner = runner) + + val failure = + adapter + .run(request(required = listOf(validation("test")))) + .shouldBeTypeOf() + + failure.error.code shouldBe "WORKSPACE_PATH_UNAVAILABLE" + runner.invocations shouldBe emptyList() + } + } + + context("기존 검증 결과를 조회하면") { + test("조회 함수를 주입하면, get 결과를 그대로 반환합니다") { + val expected = + PortResult.Success( + GetValidationResponse( + validations = listOf(validation("test", ValidationStatus.PASSED)), + checks = + listOf( + CheckResult( + id = "test", + name = "test", + status = ValidationStatus.PASSED, + fingerprint = "fingerprint-1", + revision = "revision-1", + ) + ), + ) + ) + var requested: GetValidationRequest? = null + val adapter = + LocalValidationAdapter( + workspacePathResolver = { Path.of("/workspace") }, + existingResultLookup = { + requested = it + expected + }, + ) + val request = GetValidationRequest("workspace-1", "revision-1", "fingerprint-1") + + adapter.get(request) shouldBe expected + requested shouldBe request + } + + test("조회 함수를 주입하지 않으면, 빈 검증 결과를 반환합니다") { + val result = + LocalValidationAdapter({ Path.of("/workspace") }) + .get(GetValidationRequest(revision = "revision-1")) + + result shouldBe PortResult.Success(GetValidationResponse(emptyList(), emptyList())) + } + } + }) + +private fun request( + required: List = + listOf(validation("test"), validation("build"), validation("static")), +) = + RunValidationRequest( + workspaceId = "workspace-1", + revision = "revision-1", + fingerprint = "fingerprint-1", + required = required, + ) + +private fun validation(name: String, status: ValidationStatus = ValidationStatus.PENDING) = + Validation(id = name, name = name, status = status) + +private data class Invocation(val command: List, val workingDirectory: Path) + +private class RecordingCommandRunner : CommandRunner { + val invocations = mutableListOf() + private val results = ArrayDeque<() -> CommandResult>() + + fun enqueue(result: CommandResult) { + results.addLast { result } + } + + fun enqueueFailure() { + results.addLast { error("runner failed") } + } + + override fun run(command: List, workingDirectory: Path): CommandResult { + invocations += Invocation(command, workingDirectory) + return results.removeFirst().invoke() + } +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/application/DeliveryGateUseCasesTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/DeliveryGateUseCasesTest.kt new file mode 100644 index 00000000..cfa2488e --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/DeliveryGateUseCasesTest.kt @@ -0,0 +1,545 @@ +package io.springkit.workflow.application + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.AuditEntry +import io.springkit.workflow.domain.DeploymentCandidate +import io.springkit.workflow.domain.DeploymentCandidateState +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.GateRecorded +import io.springkit.workflow.domain.GateType +import io.springkit.workflow.domain.Release +import io.springkit.workflow.domain.ReleaseState +import io.springkit.workflow.domain.Risk +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.ValidationStatus +import io.springkit.workflow.domain.WorkflowResult + +class DeliveryGateUseCasesTest : + FunSpec({ + context("고위험 배포 Gate를 실행할 때") { + test("사람이 AWAITING_DEPLOY_APPROVAL 후보를 승인하면, Canary와 Gate 감사 정보를 함께 저장합니다") { + val candidate = deployCandidate() + val store = + RecordingGateStore(WorkflowStoreSnapshot("store-1", candidates = listOf(candidate))) + val deployment = RecordingDeploymentPort(candidate) + val useCase = useCase(store, deployment = deployment) + + val result = useCase.deploy(DeployGateRequest(candidate.id, "request-1")) + val success = result.shouldBeInstanceOf>() + + success.data.candidate.state shouldBe DeploymentCandidateState.CANARY + success.data.candidate shouldBe candidate.copy(state = DeploymentCandidateState.CANARY) + deployment.startedActor shouldBe human + store.written?.candidates?.single() shouldBe + candidate.copy(state = DeploymentCandidateState.CANARY) + store.written?.eventLog?.events.orEmpty() shouldContain + GateRecorded(candidate.id, GateType.DEPLOY, human, 1_000) + store.written?.eventLog?.audits.orEmpty() shouldContain + AuditEntry( + id = "audit-1", + actor = human, + action = "deploy", + targetId = candidate.id, + mainRevision = candidate.mainRevision, + occurredAtEpochMillis = 1_000, + ) + } + + test("사람이 NORMAL 후보를 승인하면, 배포 Gate 차단 오류와 롤백을 반환합니다") { + val candidate = deployCandidate(risk = Risk.NORMAL) + val store = + RecordingGateStore(WorkflowStoreSnapshot("store-1", candidates = listOf(candidate))) + val deployment = RecordingDeploymentPort(candidate) + val result = + useCase(store, deployment = deployment).deploy(DeployGateRequest(candidate.id)) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.DEPLOYMENT_GATE_BLOCKED + deployment.startCalls shouldBe 0 + store.calls shouldBe listOf("begin", "snapshot", "rollback") + } + + test("Agent가 배포 Gate를 실행하면, 사람 필요 오류와 상태 불변 결과를 반환합니다") { + val candidate = deployCandidate() + val store = + RecordingGateStore(WorkflowStoreSnapshot("store-1", candidates = listOf(candidate))) + val identity = RecordingIdentityPort(Actor("agent-1", ActorKind.AGENT), allowed = false) + val result = + DeliveryGateUseCases( + store, + RecordingDeploymentPort(candidate), + RecordingReleasePort(), + identity, + ) + .deploy(DeployGateRequest(candidate.id)) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.HUMAN_REQUIRED + store.calls shouldBe emptyList() + identity.authorizedCapability shouldBe Capability.DEPLOY + } + + test("Store 쓰기가 실패하면, 시작한 Canary 변경을 보상하고 커밋하지 않습니다") { + val candidate = deployCandidate() + val store = + RecordingGateStore( + WorkflowStoreSnapshot("store-1", candidates = listOf(candidate)), + failWrite = true, + ) + val compensation = DeliveryRecordingCompensationPort() + val deployment = RecordingDeploymentPort(candidate, compensatable = true) + val result = + useCase(store, deployment = deployment, compensation = compensation) + .deploy(DeployGateRequest(candidate.id, "request-1")) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.STALE_REVISION + compensation.operations shouldBe listOf("start-canary") + store.calls shouldBe listOf("begin", "snapshot", "write", "rollback") + store.written shouldBe null + } + + test("Deployment provider가 mainRevision을 바꾸어 반환하면, 불변식 오류와 롤백을 반환합니다") { + val candidate = deployCandidate() + val store = storeFor(candidate = candidate) + val deployment = RecordingDeploymentPort(candidate.copy(mainRevision = "main-2")) + + val result = + useCase(store, deployment = deployment).deploy(DeployGateRequest(candidate.id)) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.INVARIANT_VIOLATION + deployment.startCalls shouldBe 0 + store.written shouldBe null + store.calls shouldBe listOf("begin", "snapshot", "rollback") + } + + test("Deployment provider가 includedSubTasks를 바꾸어 반환하면, 불변식 오류와 롤백을 반환합니다") { + val candidate = deployCandidate() + val store = storeFor(candidate = candidate) + val deployment = + RecordingDeploymentPort( + candidate.copy( + includedSubTasks = listOf("sk-202"), + risks = mapOf("sk-202" to Risk.HIGH), + ) + ) + + val result = + useCase(store, deployment = deployment).deploy(DeployGateRequest(candidate.id)) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.INVARIANT_VIOLATION + store.written shouldBe null + } + + test("Deployment provider가 risks를 바꾸어 반환하면, 불변식 오류와 롤백을 반환합니다") { + val candidate = deployCandidate() + val store = storeFor(candidate = candidate) + val deployment = + RecordingDeploymentPort(candidate.copy(risks = mapOf("sk-101" to Risk.NORMAL))) + + val result = + useCase(store, deployment = deployment).deploy(DeployGateRequest(candidate.id)) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.INVARIANT_VIOLATION + store.written shouldBe null + } + + test("Deployment provider가 validations를 바꾸어 반환하면, 불변식 오류와 롤백을 반환합니다") { + val candidate = deployCandidate() + val store = storeFor(candidate = candidate) + val deployment = + RecordingDeploymentPort( + candidate.copy( + validations = + listOf(Validation("validation-2", "test", ValidationStatus.PASSED)) + ) + ) + + val result = + useCase(store, deployment = deployment).deploy(DeployGateRequest(candidate.id)) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.INVARIANT_VIOLATION + store.written shouldBe null + } + + test("Deployment provider가 Gate 상태를 바꾸어 반환하면, 불변식 오류와 롤백을 반환합니다") { + val candidate = deployCandidate() + val store = storeFor(candidate = candidate) + val deployment = + RecordingDeploymentPort(candidate.copy(state = DeploymentCandidateState.CANARY)) + + val result = + useCase(store, deployment = deployment).deploy(DeployGateRequest(candidate.id)) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.INVARIANT_VIOLATION + store.written shouldBe null + } + + test("Deployment start 응답이 validations를 바꾸면, 불변식 오류와 롤백을 반환합니다") { + val candidate = deployCandidate() + val store = storeFor(candidate = candidate) + val deployment = + RecordingDeploymentPort( + candidate, + startCandidate = + candidate.copy( + state = DeploymentCandidateState.CANARY, + validations = + listOf(Validation("validation-2", "test", ValidationStatus.PASSED)), + ), + ) + + val result = + useCase(store, deployment = deployment).deploy(DeployGateRequest(candidate.id)) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.INVARIANT_VIOLATION + store.written shouldBe null + store.calls shouldBe listOf("begin", "snapshot", "rollback") + } + } + + context("외부 공개 Gate를 실행할 때") { + test("productionReady와 내부 검수를 통과한 공개 대상을 승인하면, Rollout과 Gate 감사 정보를 함께 저장합니다") { + val candidate = productionCandidate() + val release = release() + val store = + RecordingGateStore( + WorkflowStoreSnapshot( + "store-1", + candidates = listOf(candidate), + releases = listOf(release), + ) + ) + val releasePort = RecordingReleasePort(release) + val result = + useCase(store, release = releasePort) + .release(ReleaseGateRequest(release.id, "request-2")) + val success = result.shouldBeInstanceOf>() + + success.data.release.state shouldBe ReleaseState.ROLLOUT + success.data.release shouldBe release.copy(state = ReleaseState.ROLLOUT) + releasePort.startedActor shouldBe human + store.written?.releases?.single() shouldBe release.copy(state = ReleaseState.ROLLOUT) + store.written?.eventLog?.events.orEmpty() shouldContain + GateRecorded(release.id, GateType.RELEASE, human, 1_000) + store.written?.eventLog?.audits.orEmpty() shouldContain + AuditEntry( + id = "audit-1", + actor = human, + action = "release", + targetId = release.id, + mainRevision = candidate.mainRevision, + occurredAtEpochMillis = 1_000, + ) + } + + test("내부 검수가 끝나지 않은 공개 대상을 승인하면, 공개 Gate 차단 오류와 롤백을 반환합니다") { + val candidate = productionCandidate() + val release = release(internalValidationPassed = false) + val store = + RecordingGateStore( + WorkflowStoreSnapshot( + "store-1", + candidates = listOf(candidate), + releases = listOf(release), + ) + ) + val releasePort = RecordingReleasePort(release) + val result = useCase(store, release = releasePort).release(ReleaseGateRequest(release.id)) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.RELEASE_GATE_BLOCKED + releasePort.startCalls shouldBe 0 + store.calls shouldBe listOf("begin", "snapshot", "rollback") + } + + test("Release provider가 candidateId를 바꾸어 반환하면, 불변식 오류와 롤백을 반환합니다") { + val candidate = productionCandidate() + val release = release() + val store = storeFor(candidate = candidate, release = release) + val releasePort = RecordingReleasePort(release.copy(candidateId = "candidate-2")) + + val result = useCase(store, release = releasePort).release(ReleaseGateRequest(release.id)) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.INVARIANT_VIOLATION + releasePort.startCalls shouldBe 0 + store.written shouldBe null + } + + test("Release provider가 production readiness를 바꾸어 반환하면, 불변식 오류와 롤백을 반환합니다") { + val candidate = productionCandidate() + val release = release() + val store = storeFor(candidate = candidate, release = release) + val releasePort = RecordingReleasePort(release.copy(productionReady = false)) + + val result = useCase(store, release = releasePort).release(ReleaseGateRequest(release.id)) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.INVARIANT_VIOLATION + store.written shouldBe null + } + + test("Release provider가 내부 검수 readiness를 바꾸어 반환하면, 불변식 오류와 롤백을 반환합니다") { + val candidate = productionCandidate() + val release = release() + val store = storeFor(candidate = candidate, release = release) + val releasePort = RecordingReleasePort(release.copy(internalValidationPassed = false)) + + val result = useCase(store, release = releasePort).release(ReleaseGateRequest(release.id)) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.INVARIANT_VIOLATION + store.written shouldBe null + } + + test("Release provider가 Gate 상태를 바꾸어 반환하면, 불변식 오류와 롤백을 반환합니다") { + val candidate = productionCandidate() + val release = release() + val store = storeFor(candidate = candidate, release = release) + val releasePort = RecordingReleasePort(release.copy(state = ReleaseState.ROLLOUT)) + + val result = useCase(store, release = releasePort).release(ReleaseGateRequest(release.id)) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.INVARIANT_VIOLATION + store.written shouldBe null + } + } + }) + +private val human = Actor("human-1", ActorKind.HUMAN) + +private fun useCase( + store: RecordingGateStore, + deployment: DeploymentPort = RecordingDeploymentPort(), + release: ReleasePort = RecordingReleasePort(), + compensation: CompensationPort = DeliveryRecordingCompensationPort(), +): DeliveryGateUseCases = + DeliveryGateUseCases( + store, + deployment, + release, + RecordingIdentityPort(human), + idPort = RecordingIdPort(), + clockPort = RecordingClockPort(), + compensationPort = compensation, + ) + +private fun storeFor( + candidate: DeploymentCandidate, + release: Release? = null, +): RecordingGateStore = + RecordingGateStore( + WorkflowStoreSnapshot( + "store-1", + candidates = listOf(candidate), + releases = listOfNotNull(release), + ) + ) + +private fun deployCandidate( + risk: Risk = Risk.HIGH, + state: DeploymentCandidateState = DeploymentCandidateState.AWAITING_DEPLOY_APPROVAL, +): DeploymentCandidate = + DeploymentCandidate( + id = "candidate-1", + mainRevision = "main-1", + includedSubTasks = listOf("sk-101"), + risks = mapOf("sk-101" to risk), + state = state, + ) + +private fun productionCandidate(): DeploymentCandidate = + DeploymentCandidate( + id = "candidate-1", + mainRevision = "main-1", + includedSubTasks = listOf("sk-101"), + risks = mapOf("sk-101" to Risk.NORMAL), + validations = listOf(Validation("validation-1", "test", ValidationStatus.PASSED)), + state = DeploymentCandidateState.PRODUCTION, + ) + +private fun release( + productionReady: Boolean = true, + internalValidationPassed: Boolean = true, + state: ReleaseState = ReleaseState.AWAITING_RELEASE_APPROVAL, +): Release = + Release( + id = "release-1", + candidateId = "candidate-1", + state = state, + productionReady = productionReady, + internalValidationPassed = internalValidationPassed, + ) + +private class RecordingIdentityPort( + private val actor: Actor, + private val allowed: Boolean = true, +) : IdentityPort { + var authorizedCapability: Capability? = null + + override fun currentActor(request: CurrentActorRequest): PortResult = + PortResult.Success(CurrentActorResponse(actor)) + + override fun authorize(request: AuthorizeRequest): PortResult { + authorizedCapability = request.capability + return PortResult.Success(AuthorizeResponse(allowed)) + } +} + +private class RecordingDeploymentPort( + private var candidate: DeploymentCandidate = deployCandidate(), + private val compensatable: Boolean = false, + private val startCandidate: DeploymentCandidate? = null, +) : DeploymentPort { + var startCalls: Int = 0 + var startedActor: Actor? = null + + override fun createCandidate( + request: CreateCandidateRequest + ): PortResult = unsupported() + + override fun getCandidate(request: GetCandidateRequest): PortResult = + PortResult.Success(GetCandidateResponse(candidate)) + + override fun validate(request: ValidateCandidateRequest): PortResult = + unsupported() + + override fun startCanary(request: StartCanaryRequest): PortResult { + startCalls += 1 + startedActor = request.actor + candidate = startCandidate ?: candidate.copy(state = DeploymentCandidateState.CANARY) + return PortResult.Success( + StartCanaryResponse(candidate, change("start-canary", compensatable)), + ) + } + + override fun promoteProduction( + request: PromoteProductionRequest + ): PortResult = unsupported() + + private fun unsupported(): PortResult = + PortResult.Failure(PortError(FailureCode.STATE_CONFLICT.name, "지원하지 않는 테스트 동작입니다.")) +} + +private class RecordingReleasePort( + private var release: Release? = null, + private val startRelease: Release? = null, +) : ReleasePort { + var startCalls: Int = 0 + var startedActor: Actor? = null + + override fun get(request: GetReleaseRequest): PortResult = + PortResult.Success(GetReleaseResponse(listOfNotNull(release))) + + override fun create(request: CreateReleaseRequest): PortResult = + unsupported() + + override fun validateInternal( + request: ValidateReleaseRequest + ): PortResult = unsupported() + + override fun start(request: StartReleaseRequest): PortResult { + startCalls += 1 + startedActor = request.actor + val started = startRelease ?: release!!.copy(state = ReleaseState.ROLLOUT) + release = started + return PortResult.Success(StartReleaseResponse(started, change("start-release"))) + } + + override fun continueRollout( + request: ContinueReleaseRequest + ): PortResult = unsupported() + + private fun unsupported(): PortResult = + PortResult.Failure(PortError(FailureCode.STATE_CONFLICT.name, "지원하지 않는 테스트 동작입니다.")) +} + +private class RecordingGateStore( + private var snapshot: WorkflowStoreSnapshot, + private val failWrite: Boolean = false, +) : WorkflowStorePort { + val calls = mutableListOf() + var written: WorkflowStoreSnapshot? = null + + override fun begin(request: StoreTransactionRequest): PortResult { + calls += "begin" + return PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.OPEN) + ) + } + + override fun snapshot(request: StoreSnapshotRequest): PortResult { + calls += "snapshot" + return PortResult.Success(StoreSnapshotResponse(snapshot)) + } + + override fun write(request: StoreWriteRequest): PortResult { + calls += "write" + if (failWrite) { + return PortResult.Failure( + PortError(FailureCode.STALE_REVISION.name, "저장소 revision이 변경되었습니다.") + ) + } + written = request.snapshot + snapshot = request.snapshot + return PortResult.Success(StoreWriteResponse("store-2")) + } + + override fun append(request: StoreEventRequest): PortResult = + PortResult.Failure(PortError(FailureCode.STATE_CONFLICT.name, "원자 쓰기 테스트에서는 호출하지 않습니다.")) + + override fun commit(request: StoreTransactionRequest): PortResult { + calls += "commit" + return PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.COMMITTED) + ) + } + + override fun rollback(request: StoreTransactionRequest): PortResult { + calls += "rollback" + return PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.ROLLED_BACK) + ) + } +} + +private class DeliveryRecordingCompensationPort : CompensationPort { + val operations = mutableListOf() + + override fun compensate(request: CompensateRequest): PortResult { + operations += request.change.operation + return PortResult.Success(CompensateResponse(request.change)) + } +} + +private class RecordingIdPort : IdPort { + override fun issue(request: IssueIdRequest): PortResult = + PortResult.Success(IssueIdResponse(listOf(IssuedId(request.kind, "audit-1")))) +} + +private class RecordingClockPort : ClockPort { + override fun now(request: NowRequest): PortResult = + PortResult.Success(NowResponse(1_000)) +} + +private fun change(operation: String, compensatable: Boolean = false): ChangeReceipt = + ChangeReceipt( + id = "change-$operation", + operation = operation, + compensation = + if (compensatable) Compensation("comp-$operation", "undo-$operation", operation) + else null, + ) diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/application/DeploymentLifecycleUseCaseTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/DeploymentLifecycleUseCaseTest.kt new file mode 100644 index 00000000..f3fa277f --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/DeploymentLifecycleUseCaseTest.kt @@ -0,0 +1,554 @@ +package io.springkit.workflow.application + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.domain.DeploymentCandidate +import io.springkit.workflow.domain.DeploymentCandidateState +import io.springkit.workflow.domain.EventLog +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.Integration +import io.springkit.workflow.domain.IntegrationState +import io.springkit.workflow.domain.MergeRecorded +import io.springkit.workflow.domain.Risk +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.ValidationStatus +import io.springkit.workflow.domain.WorkflowResult + +class DeploymentLifecycleUseCaseTest : + FunSpec({ + context("normal 배포 후보를 만들면") { + test("normal 배포 후보를 만들면, 고정된 main revision과 SubTask를 보존하고 Canary를 자동으로 시작합니다") { + val fixture = DeploymentLifecycleFixture() + + val result = fixture.useCase().create(DeploymentLifecycleRequest("main-1")) + + val response = result.successData() + response.candidate.state shouldBe DeploymentCandidateState.CANARY + response.candidate.mainRevision shouldBe "main-1" + response.candidate.includedSubTasks shouldContainExactly listOf("sk-101") + response.candidate.risks shouldBe mapOf("sk-101" to Risk.NORMAL) + fixture.deployment.startCanaryCalls shouldBe 1 + } + } + + context("Production 이후의 main revision으로 후보를 만들면") { + test("Production 이후 main revision으로 후보를 만들면, merge된 모든 SubTask를 merge 순서대로 누적합니다") { + val fixture = DeploymentLifecycleFixture() + val secondSubTask = SubTask("sk-102", "task-1", "두 번째 배포 변경") + val thirdSubTask = SubTask("sk-103", "task-1", "세 번째 배포 변경") + fixture.store.current = + fixture.store.current.copy( + subTasks = fixture.store.current.subTasks + secondSubTask + thirdSubTask, + integrations = + listOf( + Integration("sk-101", IntegrationState.MERGED, mainRevision = "main-1"), + Integration("sk-102", IntegrationState.MERGED, mainRevision = "main-2"), + Integration("sk-103", IntegrationState.MERGED, mainRevision = "main-3"), + ), + candidates = + listOf( + DeploymentCandidate( + id = "candidate-production", + mainRevision = "main-1", + includedSubTasks = listOf("sk-101"), + risks = mapOf("sk-101" to Risk.NORMAL), + validations = + listOf( + Validation( + "validation-production", + "필수 검증", + ValidationStatus.PASSED, + ) + ), + state = DeploymentCandidateState.PRODUCTION, + ) + ), + eventLog = + EventLog( + events = + listOf( + MergeRecorded("sk-101", "main-1"), + MergeRecorded("sk-102", "main-2"), + MergeRecorded("sk-103", "main-3"), + ) + ), + ) + + val result = fixture.useCase().create(DeploymentLifecycleRequest("main-3")) + + val candidate = result.successData().candidate + candidate.mainRevision shouldBe "main-3" + candidate.includedSubTasks shouldContainExactly listOf("sk-102", "sk-103") + candidate.risks shouldBe + mapOf( + "sk-102" to Risk.NORMAL, + "sk-103" to Risk.NORMAL, + ) + fixture.deployment.createCandidateCalls shouldBe 1 + } + + test("활성 후보가 있으면, 추가 merge revision이 기존 후보를 변경하지 않습니다") { + val fixture = DeploymentLifecycleFixture() + val secondSubTask = SubTask("sk-102", "task-1", "두 번째 배포 변경") + fixture.store.current = + fixture.store.current.copy( + subTasks = fixture.store.current.subTasks + secondSubTask, + integrations = + listOf( + Integration("sk-101", IntegrationState.MERGED, mainRevision = "main-1"), + Integration("sk-102", IntegrationState.MERGED, mainRevision = "main-2"), + ), + eventLog = + EventLog( + events = + listOf( + MergeRecorded("sk-101", "main-1"), + MergeRecorded("sk-102", "main-2"), + ) + ), + ) + fixture.prepareCandidate(DeploymentCandidateState.AWAITING_DEPLOY_APPROVAL) + + val result = fixture.useCase().create(DeploymentLifecycleRequest("main-2")) + + val candidate = result.successData() + candidate.idempotent shouldBe true + candidate.candidate.mainRevision shouldBe "main-1" + result + .shouldBeInstanceOf>() + .next + .single() + .action shouldBe "approve_deployment" + candidate.candidate.includedSubTasks shouldContainExactly listOf("sk-101") + fixture.deployment.createCandidateCalls shouldBe 0 + fixture.store.current.candidates.single().mainRevision shouldBe "main-1" + } + + test("현재 Production보다 이전인 target revision이면, 상태 충돌로 차단합니다") { + val fixture = DeploymentLifecycleFixture() + val secondSubTask = SubTask("sk-102", "task-1", "두 번째 배포 변경") + fixture.store.current = + fixture.store.current.copy( + subTasks = fixture.store.current.subTasks + secondSubTask, + integrations = + listOf( + Integration("sk-101", IntegrationState.MERGED, mainRevision = "main-1"), + Integration("sk-102", IntegrationState.MERGED, mainRevision = "main-2"), + ), + candidates = + listOf( + DeploymentCandidate( + id = "candidate-old", + mainRevision = "main-1", + includedSubTasks = listOf("sk-101"), + risks = mapOf("sk-101" to Risk.NORMAL), + state = DeploymentCandidateState.FAILED, + ), + DeploymentCandidate( + id = "candidate-production", + mainRevision = "main-2", + includedSubTasks = listOf("sk-101", "sk-102"), + risks = + mapOf( + "sk-101" to Risk.NORMAL, + "sk-102" to Risk.NORMAL, + ), + validations = + listOf( + Validation( + "validation-production", + "필수 검증", + ValidationStatus.PASSED, + ) + ), + state = DeploymentCandidateState.PRODUCTION, + ), + ), + eventLog = + EventLog( + events = + listOf( + MergeRecorded("sk-101", "main-1"), + MergeRecorded("sk-102", "main-2"), + ) + ), + ) + + val result = fixture.useCase().create(DeploymentLifecycleRequest("main-1")) + + result.failureData().code shouldBe io.springkit.workflow.domain.FailureCode.STATE_CONFLICT + fixture.deployment.createCandidateCalls shouldBe 0 + } + } + + context("high 배포 후보를 만들면") { + test("high 배포 후보를 만들면, 필수 validation 뒤 AWAITING_DEPLOY_APPROVAL 상태로 대기합니다") { + val fixture = DeploymentLifecycleFixture(risk = Risk.HIGH) + + val result = fixture.useCase().create(DeploymentLifecycleRequest("main-1")) + + result.successData().candidate.state shouldBe + DeploymentCandidateState.AWAITING_DEPLOY_APPROVAL + fixture.deployment.startCanaryCalls shouldBe 0 + } + } + + context("DEPLOYMENT_CHANGED 이벤트를 받으면") { + test("DEPLOYMENT_CHANGED 이벤트를 수신하면, 필수 validation을 통과한 normal 후보를 Canary로 진행합니다") { + val fixture = DeploymentLifecycleFixture() + fixture.prepareCandidate(DeploymentCandidateState.VALIDATING) + + val result = fixture.useCase().handle(deploymentChangedEvent("deployment-event-1")) + + result.successData().candidate.state shouldBe DeploymentCandidateState.CANARY + fixture.deployment.getCandidateCalls shouldBe 1 + fixture.deployment.validateCalls shouldBe 1 + fixture.deployment.startCanaryCalls shouldBe 1 + } + + test("high 후보의 필수 validation이 통과하면, 배포 승인 대기로 전환합니다") { + val fixture = DeploymentLifecycleFixture(risk = Risk.HIGH) + fixture.prepareCandidate(DeploymentCandidateState.VALIDATING) + + val result = fixture.useCase().handle(deploymentChangedEvent("deployment-event-2")) + + result.successData().candidate.state shouldBe + DeploymentCandidateState.AWAITING_DEPLOY_APPROVAL + fixture.deployment.startCanaryCalls shouldBe 0 + } + + test("필수 validation이 끝나지 않았으면, VALIDATING 상태를 유지합니다") { + val fixture = DeploymentLifecycleFixture() + fixture.deployment.setValidationResult( + listOf(Validation("validation-1", "필수 검증", ValidationStatus.PENDING)) + ) + fixture.prepareCandidate(DeploymentCandidateState.VALIDATING) + + val result = fixture.useCase().handle(deploymentChangedEvent("deployment-event-3")) + + result.successData().candidate.state shouldBe DeploymentCandidateState.VALIDATING + fixture.deployment.startCanaryCalls shouldBe 0 + } + + test("같은 event id를 다시 받으면, provider를 호출하지 않고 멱등 처리합니다") { + val fixture = DeploymentLifecycleFixture() + fixture.deployment.setValidationResult( + listOf(Validation("validation-1", "필수 검증", ValidationStatus.PENDING)) + ) + fixture.prepareCandidate(DeploymentCandidateState.VALIDATING) + val event = deploymentChangedEvent("deployment-event-duplicate") + + fixture.useCase().handle(event).successData() + val repeated = fixture.useCase().handle(event).successData() + + repeated.idempotent shouldBe true + fixture.deployment.getCandidateCalls shouldBe 1 + fixture.deployment.validateCalls shouldBe 1 + } + + test("필수 validation이 실패하면, FAILED 상태로 저장합니다") { + val fixture = DeploymentLifecycleFixture() + fixture.deployment.setValidationResult( + listOf( + Validation( + "validation-1", + "필수 검증", + ValidationStatus.FAILED, + message = "검증 실패", + ) + ) + ) + fixture.prepareCandidate(DeploymentCandidateState.VALIDATING) + + val result = fixture.useCase().handle(deploymentChangedEvent("deployment-event-4")) + + val response = result.successData() + response.candidate.state shouldBe DeploymentCandidateState.FAILED + response.failureStage shouldBe DeploymentFailureStage.VALIDATION + } + + test("이미 더 진행된 후보이면, provider를 다시 호출하지 않고 멱등 성공합니다") { + val fixture = DeploymentLifecycleFixture() + fixture.prepareCandidate(DeploymentCandidateState.CANARY) + + val result = fixture.useCase().handle(deploymentChangedEvent("deployment-event-5")) + + result.successData().idempotent shouldBe true + fixture.deployment.getCandidateCalls shouldBe 0 + fixture.deployment.validateCalls shouldBe 0 + fixture.deployment.startCanaryCalls shouldBe 0 + } + + test("provider identity가 달라지면, 후보를 역행하지 않고 불변식 오류를 반환합니다") { + val fixture = DeploymentLifecycleFixture(validateWithDifferentRevision = true) + fixture.prepareCandidate(DeploymentCandidateState.VALIDATING) + + val result = fixture.useCase().handle(deploymentChangedEvent("deployment-event-6")) + + result.failureData().code shouldBe + io.springkit.workflow.domain.FailureCode.INVARIANT_VIOLATION + fixture.store.writes shouldBe 0 + fixture.store.rollbacks shouldBe 1 + fixture.compensation.calls shouldBe 1 + } + } + + context("Canary 완료 결과를 반영하면") { + test("Canary가 성공하면, 같은 candidate를 Production으로 승격하고 다시 받아도 멱등 처리합니다") { + val fixture = DeploymentLifecycleFixture() + val created = fixture.useCase().create(DeploymentLifecycleRequest("main-1")).successData() + val request = + DeploymentCanaryEventRequest( + candidateId = created.candidate.id, + outcome = DeploymentCanaryOutcome.SUCCEEDED, + eventId = "canary-event-1", + ) + + val promoted = fixture.useCase().handle(request).successData() + val repeated = fixture.useCase().handle(request).successData() + + promoted.candidate.id shouldBe created.candidate.id + promoted.candidate.state shouldBe DeploymentCandidateState.PRODUCTION + repeated.idempotent shouldBe true + fixture.deployment.promoteCalls shouldBe 1 + } + + test("Canary가 실패하면, candidate를 FAILED로 저장하고 같은 결과를 멱등 처리합니다") { + val fixture = DeploymentLifecycleFixture() + val created = fixture.useCase().create(DeploymentLifecycleRequest("main-1")).successData() + val request = + DeploymentCanaryEventRequest( + candidateId = created.candidate.id, + outcome = DeploymentCanaryOutcome.FAILED, + eventId = "canary-event-2", + ) + + val failed = fixture.useCase().handle(request).successData() + val repeated = fixture.useCase().handle(request).successData() + + failed.candidate.state shouldBe DeploymentCandidateState.FAILED + failed.failureStage shouldBe DeploymentFailureStage.CANARY + repeated.idempotent shouldBe true + fixture.store.current.candidates.single().state shouldBe DeploymentCandidateState.FAILED + } + } + + context("provider 응답이 candidate identity와 다르면") { + test("provider 응답이 candidate identity와 다르면, 상태를 저장하지 않고 provider 변경을 보상합니다") { + val fixture = DeploymentLifecycleFixture(validateWithDifferentRevision = true) + + val result = fixture.useCase().create(DeploymentLifecycleRequest("main-1")) + + result.failureData().code shouldBe + io.springkit.workflow.domain.FailureCode.INVARIANT_VIOLATION + fixture.store.writes shouldBe 0 + fixture.store.rollbacks shouldBe 1 + fixture.compensation.calls shouldBe 2 + } + } + + context("Store transaction 중 provider 동작이 실패하면") { + test("Store transaction 중 provider 동작이 실패하면, rollback 후 이미 적용된 변경을 역순으로 보상합니다") { + val fixture = DeploymentLifecycleFixture(validationFailure = true) + + val result = fixture.useCase().create(DeploymentLifecycleRequest("main-1")) + + result.failureData().code shouldBe + io.springkit.workflow.domain.FailureCode.EXTERNAL_FAILURE + fixture.store.writes shouldBe 0 + fixture.store.rollbacks shouldBe 1 + fixture.compensation.calls shouldBe 2 + } + } + }) + +private fun deploymentChangedEvent(id: String): ExternalEvent = + ExternalEvent( + id = id, + kind = ExternalEventKind.DEPLOYMENT_CHANGED, + targetId = "candidate-1", + occurredAtEpochMillis = 1, + ) + +private fun WorkflowResult.successData(): DeploymentLifecycleResponse = + shouldBeInstanceOf>().data + +private fun WorkflowResult.failureData() = + shouldBeInstanceOf().data + +private class DeploymentLifecycleFixture( + private val risk: Risk = Risk.NORMAL, + private val validationFailure: Boolean = false, + private val validateWithDifferentRevision: Boolean = false, +) { + val store = DeploymentStore() + val compensation = DeploymentCompensationPort() + val deployment = + DeploymentProvider( + risk = risk, + validationFailure = validationFailure, + validateWithDifferentRevision = validateWithDifferentRevision, + ) + + init { + store.current = + WorkflowStoreSnapshot( + revision = "store-1", + subTasks = listOf(SubTask("sk-101", "task-1", "배포 변경", risk = risk)), + integrations = + listOf(Integration("sk-101", IntegrationState.MERGED, mainRevision = "main-1")), + ) + } + + fun useCase() = + DeploymentLifecycleUseCase( + storePort = store, + deploymentPort = deployment, + compensationPort = compensation, + ) + + fun prepareCandidate(state: DeploymentCandidateState) { + val candidate = + DeploymentCandidate( + id = "candidate-1", + mainRevision = "main-1", + includedSubTasks = listOf("sk-101"), + risks = mapOf("sk-101" to risk), + state = state, + validations = emptyList(), + ) + store.current = store.current.copy(candidates = listOf(candidate)) + deployment.setCandidate(candidate) + } +} + +private class DeploymentStore : WorkflowStorePort { + lateinit var current: WorkflowStoreSnapshot + var writes = 0 + var rollbacks = 0 + + override fun snapshot(request: StoreSnapshotRequest): PortResult = + PortResult.Success(StoreSnapshotResponse(current)) + + override fun begin(request: StoreTransactionRequest): PortResult = + PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.OPEN) + ) + + override fun write(request: StoreWriteRequest): PortResult { + writes += 1 + current = request.snapshot + return PortResult.Success(StoreWriteResponse("store-${writes + 1}")) + } + + override fun commit(request: StoreTransactionRequest): PortResult = + PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.COMMITTED) + ) + + override fun rollback(request: StoreTransactionRequest): PortResult { + rollbacks += 1 + return PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.ROLLED_BACK) + ) + } + + override fun append(request: StoreEventRequest): PortResult = + PortResult.Success(StoreEventResponse(request.event, current.revision)) +} + +private class DeploymentCompensationPort : CompensationPort { + var calls = 0 + + override fun compensate(request: CompensateRequest): PortResult { + calls += 1 + return PortResult.Success(CompensateResponse(request.change)) + } +} + +private class DeploymentProvider( + private val risk: Risk, + private val validationFailure: Boolean, + private val validateWithDifferentRevision: Boolean, +) : DeploymentPort { + var createCandidateCalls = 0 + var getCandidateCalls = 0 + var validateCalls = 0 + var startCanaryCalls = 0 + var promoteCalls = 0 + private var validationResult: List? = null + private var candidate = + DeploymentCandidate("candidate-1", "main-1", listOf("sk-101"), mapOf("sk-101" to risk)) + + override fun createCandidate( + request: CreateCandidateRequest + ): PortResult { + createCandidateCalls += 1 + candidate = + DeploymentCandidate( + "candidate-1", + request.mainRevision, + request.includedSubTasks, + request.risks, + ) + return PortResult.Success( + CreateCandidateResponse(candidate, change("create-candidate")), + ) + } + + override fun getCandidate(request: GetCandidateRequest): PortResult = + PortResult.Success(GetCandidateResponse(candidate)).also { getCandidateCalls += 1 } + + override fun validate(request: ValidateCandidateRequest): PortResult { + validateCalls += 1 + if (validationFailure) { + return PortResult.Failure( + PortError("EXTERNAL_FAILURE", "provider validation failed"), + change("validate-candidate"), + ) + } + candidate = + candidate.copy( + mainRevision = + if (validateWithDifferentRevision) "other-main" else candidate.mainRevision, + validations = + validationResult + ?: listOf(Validation("validation-1", "필수 검증", ValidationStatus.PASSED)), + ) + return PortResult.Success( + ValidateCandidateResponse(candidate, change("validate-candidate")), + ) + } + + fun setCandidate(value: DeploymentCandidate) { + candidate = value + } + + fun setValidationResult(value: List) { + validationResult = value + } + + override fun startCanary(request: StartCanaryRequest): PortResult { + startCanaryCalls += 1 + candidate = candidate.copy(state = DeploymentCandidateState.CANARY) + return PortResult.Success(StartCanaryResponse(candidate, change("start-canary"))) + } + + override fun promoteProduction( + request: PromoteProductionRequest + ): PortResult { + promoteCalls += 1 + candidate = candidate.copy(state = DeploymentCandidateState.PRODUCTION) + return PortResult.Success(PromoteProductionResponse(candidate, change("promote-production"))) + } + + private fun change(operation: String) = + ChangeReceipt( + id = operation, + operation = operation, + compensation = Compensation("comp-$operation", operation, "key-$operation"), + ) +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/application/MergeQueueLifecycleUseCaseTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/MergeQueueLifecycleUseCaseTest.kt new file mode 100644 index 00000000..acf3b815 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/MergeQueueLifecycleUseCaseTest.kt @@ -0,0 +1,310 @@ +package io.springkit.workflow.application + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.ExternalTaskId +import io.springkit.workflow.domain.Integration +import io.springkit.workflow.domain.IntegrationState +import io.springkit.workflow.domain.MergeQueueEntry +import io.springkit.workflow.domain.MergeQueueState +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.Task +import io.springkit.workflow.domain.TaskState +import io.springkit.workflow.domain.WorkflowResult + +class MergeQueueLifecycleUseCaseTest : + FunSpec({ + context("Merge Queue 검증이 통과한 SubTask를 통합하면") { + test("SubTask를 통합하면, squash merge 결과를 저장하고 PR과 SubTask를 Merged로 전이하며 이벤트를 기록합니다") { + val store = LifecycleStore(snapshot()) + val mergeQueue = LifecycleMergeQueuePort() + val task = LifecycleTaskPort() + + val result = useCase(store, mergeQueue, task).merge(MergeQueueLifecycleRequest("sk-101")) + + val response = + result.shouldBeInstanceOf>().data + response.integration.state shouldBe IntegrationState.MERGED + response.integration.mainRevision shouldBe "main-2" + response.subTask.state shouldBe SubTaskState.MERGED + response.pullRequest.state shouldBe PullRequestState.MERGED + store.written?.mergeQueue?.single()?.state shouldBe MergeQueueState.MERGED + store.written?.eventLog?.events?.single()?.targetId shouldBe "sk-101" + task.requests.single().state shouldBe SubTaskState.MERGED + mergeQueue.requests.single().expectedChangeRevisionId shouldBe "cr-1" + } + + test("이미 통합된 상태를 다시 요청하면, 원격 merge 없이 저장된 결과를 반환합니다") { + val current = + snapshot() + .copy( + subTasks = listOf(subTask().copy(state = SubTaskState.MERGED)), + pullRequests = listOf(pullRequest().copy(state = PullRequestState.MERGED)), + integrations = + listOf( + Integration( + "sk-101", + IntegrationState.MERGED, + queue().copy(state = MergeQueueState.MERGED), + "main-2", + "squash-2", + ) + ), + mergeQueue = listOf(queue().copy(state = MergeQueueState.MERGED)), + ) + val store = LifecycleStore(current) + val mergeQueue = LifecycleMergeQueuePort() + + val result = useCase(store, mergeQueue).merge(MergeQueueLifecycleRequest("sk-101")) + + result.shouldBeInstanceOf>() + mergeQueue.requests shouldBe emptyList() + store.written shouldBe null + } + } + + context("Merge Queue 검증이 통과하지 않은 상태에서 통합을 요청하면") { + test("provider 상태가 PASSED이면, Store에 상태를 반영한 뒤 squash merge를 실행합니다") { + val store = LifecycleStore(snapshot(queueState = MergeQueueState.VALIDATING)) + val mergeQueue = LifecycleMergeQueuePort(queue(MergeQueueState.PASSED)) + + val result = useCase(store, mergeQueue).merge(MergeQueueLifecycleRequest("sk-101")) + + result.shouldBeInstanceOf>() + store.writes.first().mergeQueue.single().state shouldBe MergeQueueState.PASSED + store.written?.mergeQueue?.single()?.state shouldBe MergeQueueState.MERGED + mergeQueue.requests.size shouldBe 1 + } + + test("Store queue revision이 없어도 PR provider revision이 있으면 보완하고 통합합니다") { + val legacyQueue = queue().copy(providerRevision = null) + val store = LifecycleStore(snapshot().copy(mergeQueue = listOf(legacyQueue))) + val mergeQueue = LifecycleMergeQueuePort(queue().copy(providerRevision = "head-1")) + + val result = useCase(store, mergeQueue).merge(MergeQueueLifecycleRequest("sk-101")) + + result.shouldBeInstanceOf>() + store.writes.first().mergeQueue.single().providerRevision shouldBe "head-1" + mergeQueue.requests.size shouldBe 1 + } + + test("PR provider revision이 없으면 diff identity가 head처럼 보여도 통합하지 않습니다") { + val legacyPullRequest = + pullRequest() + .copy( + changeRevision = ChangeRevision("cr-1", 1, Diff("diff-1")), + ) + val store = LifecycleStore(snapshot().copy(pullRequests = listOf(legacyPullRequest))) + val mergeQueue = LifecycleMergeQueuePort(queue().copy(providerRevision = "diff-1")) + + val result = useCase(store, mergeQueue).merge(MergeQueueLifecycleRequest("sk-101")) + + result.shouldBeInstanceOf().data.code shouldBe + io.springkit.workflow.domain.FailureCode.STALE_REVISION + mergeQueue.requests shouldBe emptyList() + store.written shouldBe null + } + + test("provider head가 바뀌면, queue 시점 revision을 보존하고 squash merge를 막습니다") { + val store = LifecycleStore(snapshot(queueState = MergeQueueState.VALIDATING)) + val mergeQueue = + LifecycleMergeQueuePort( + queue(MergeQueueState.PASSED).copy(providerRevision = "head-2") + ) + + val result = useCase(store, mergeQueue).merge(MergeQueueLifecycleRequest("sk-101")) + val retry = useCase(store, mergeQueue).merge(MergeQueueLifecycleRequest("sk-101")) + + result.shouldBeInstanceOf().data.code shouldBe + io.springkit.workflow.domain.FailureCode.STALE_REVISION + retry.shouldBeInstanceOf().data.code shouldBe + io.springkit.workflow.domain.FailureCode.STALE_REVISION + store.writes shouldBe emptyList() + mergeQueue.requests shouldBe emptyList() + } + + test("provider head revision이 없으면, Store를 갱신하지 않고 통합을 차단합니다") { + val store = LifecycleStore(snapshot()) + val mergeQueue = + LifecycleMergeQueuePort(queue(MergeQueueState.PASSED).copy(providerRevision = null)) + + val result = useCase(store, mergeQueue).merge(MergeQueueLifecycleRequest("sk-101")) + + result.shouldBeInstanceOf().data.code shouldBe + io.springkit.workflow.domain.FailureCode.STALE_REVISION + store.writes shouldBe emptyList() + mergeQueue.requests shouldBe emptyList() + } + + test("Merge Queue 검증이 통과하지 않으면, squash merge를 호출하지 않고 Merge Queue 오류를 반환합니다") { + val store = LifecycleStore(snapshot(queueState = MergeQueueState.VALIDATING)) + val mergeQueue = LifecycleMergeQueuePort(queue(MergeQueueState.VALIDATING)) + + val result = useCase(store, mergeQueue).merge(MergeQueueLifecycleRequest("sk-101")) + + result.shouldBeInstanceOf().data.code shouldBe + io.springkit.workflow.domain.FailureCode.MERGE_QUEUE_FAILED + mergeQueue.requests shouldBe emptyList() + store.written shouldBe null + } + + test("provider가 다른 통합 대상을 반환하면, 상태를 저장하지 않습니다") { + val store = LifecycleStore(snapshot()) + val mergeQueue = + LifecycleMergeQueuePort( + integration = + Integration( + "sk-other", + IntegrationState.MERGED, + queue().copy(subTaskId = "sk-other", state = MergeQueueState.MERGED), + "main-2", + "squash-2", + ) + ) + + val result = useCase(store, mergeQueue).merge(MergeQueueLifecycleRequest("sk-101")) + + result.shouldBeInstanceOf().data.code shouldBe + io.springkit.workflow.domain.FailureCode.INVARIANT_VIOLATION + store.written shouldBe null + } + } + }) + +private fun useCase( + store: LifecycleStore, + mergeQueue: LifecycleMergeQueuePort, + task: LifecycleTaskPort? = null, +) = MergeQueueLifecycleUseCase(store, mergeQueue, task) + +private fun snapshot(queueState: MergeQueueState = MergeQueueState.PASSED): WorkflowStoreSnapshot { + val task = + Task("task-1", ExternalTaskId("TASK-1"), "기능 작업", TaskState.IN_PROGRESS, listOf("sk-101")) + return WorkflowStoreSnapshot( + revision = "store-1", + tasks = listOf(task), + subTasks = listOf(subTask()), + pullRequests = listOf(pullRequest()), + mergeQueue = listOf(queue(queueState)), + ) +} + +private fun subTask() = + SubTask("sk-101", "task-1", "변경 작업", SubTaskState.QUEUED, pullRequestId = "pr-1") + +private fun pullRequest() = + PullRequest( + id = "pr-1", + subTaskId = "sk-101", + title = "변경 작업", + body = "본문", + base = "main", + state = PullRequestState.QUEUED, + reviewRevision = ReviewRevision("rv-1", 1, "본문"), + changeRevision = ChangeRevision("cr-1", 1, Diff("diff-1"), providerRevision = "head-1"), + ) + +private fun queue(state: MergeQueueState = MergeQueueState.PASSED) = + MergeQueueEntry("mq-1", "sk-101", "pr-1", "cr-1", state, providerRevision = "head-1") + +private class LifecycleStore(initial: WorkflowStoreSnapshot) : WorkflowStorePort { + var current = initial + var written: WorkflowStoreSnapshot? = null + val writes = mutableListOf() + private var transactions = emptySet() + + override fun snapshot(request: StoreSnapshotRequest): PortResult = + PortResult.Success(StoreSnapshotResponse(current)) + + override fun begin(request: StoreTransactionRequest): PortResult { + transactions += request.transactionId + return PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.OPEN) + ) + } + + override fun write(request: StoreWriteRequest): PortResult { + written = request.snapshot + writes += request.snapshot + current = request.snapshot + return PortResult.Success(StoreWriteResponse("store-2")) + } + + override fun commit(request: StoreTransactionRequest): PortResult = + PortResult.Success( + StoreTransactionResponse( + request.transactionId, + StoreTransactionState.COMMITTED, + "store-2", + ) + ) + + override fun rollback(request: StoreTransactionRequest): PortResult = + PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.ROLLED_BACK) + ) + + override fun append(request: StoreEventRequest): PortResult = + PortResult.Success(StoreEventResponse(request.event, current.revision)) +} + +private class LifecycleMergeQueuePort( + private val providerEntry: MergeQueueEntry = queue(), + private val integration: Integration = + Integration( + "sk-101", + IntegrationState.MERGED, + providerEntry.copy(state = MergeQueueState.MERGED), + "main-2", + "squash-2", + ), +) : MergeQueuePort { + val requests = mutableListOf() + + override fun enqueue(request: EnqueueMergeRequest): PortResult = + error("사용하지 않는 포트 동작입니다") + + override fun get(request: GetMergeQueueRequest): PortResult = + PortResult.Success(GetMergeQueueResponse(listOf(providerEntry))) + + override fun merge(request: MergeQueueMergeRequest): PortResult { + requests += request + return PortResult.Success( + MergeQueueMergeResponse(integration, ChangeReceipt("merge-1", "squash-merge")) + ) + } +} + +private class LifecycleTaskPort : TaskPort { + val requests = mutableListOf() + + override fun get(request: TaskLookupRequest): PortResult = + error("사용하지 않는 포트 동작입니다") + + override fun getSubTask(request: SubTaskLookupRequest): PortResult = + error("사용하지 않는 포트 동작입니다") + + override fun createSubTask(request: CreateSubTaskRequest): PortResult = + error("사용하지 않는 포트 동작입니다") + + override fun updateSubTask( + request: UpdateExternalSubTaskRequest + ): PortResult { + requests += request + return PortResult.Success( + UpdateExternalSubTaskResponse( + request.externalTaskId, + request.subTaskId, + request.state, + ChangeReceipt("task-1", "update-subtask"), + ) + ) + } +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/application/PortsTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/PortsTest.kt new file mode 100644 index 00000000..9cb0f61f --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/PortsTest.kt @@ -0,0 +1,67 @@ +package io.springkit.workflow.application + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind + +class PortsTest : + FunSpec({ + context("변경 결과를 확인할 때") { + test("보상 정보가 있는 변경 결과를 생성하면, 변경과 보상 작업을 노출합니다") { + val compensation = + Compensation( + id = "comp-1", + operation = "remove-worktree", + idempotencyKey = "request-1", + ) + val receipt = + ChangeReceipt( + id = "change-1", + operation = "create-worktree", + compensation = compensation, + ) + val result = PortResult.Success(value = "workspace-1", change = receipt) + + result.change.shouldNotBeNull().compensation?.operation shouldBe "remove-worktree" + result.change.status shouldBe ChangeStatus.APPLIED + } + } + + context("포트 계약을 확인할 때") { + test("인가 요청과 응답을 생성하면, 도메인 값과 허용 여부를 보존합니다") { + val request = + AuthorizeRequest(Actor("human-1", ActorKind.HUMAN), Capability.APPROVE, "sk-1") + val response = AuthorizeResponse(allowed = true) + + request.capability shouldBe Capability.APPROVE + response.allowed shouldBe true + } + } + + context("트랜잭션 요청을 확인할 때") { + test("낙관적 리비전과 멱등성 키를 지정하면, 두 값을 요청에 보존합니다") { + val request = + StoreTransactionRequest( + transactionId = "tx-1", + expectedRevision = "store-4", + idempotencyKey = "request-1", + ) + + request.expectedRevision shouldBe "store-4" + request.idempotencyKey shouldBe "request-1" + } + } + + context("워크플로 저장소 스냅샷을 확인할 때") { + test("리비전만 지정하면, 복구 가능한 모든 상태 영역을 빈 값으로 보존합니다") { + val snapshot = WorkflowStoreSnapshot(revision = "store-1") + + snapshot.checks shouldBe emptyMap() + snapshot.mergeQueue shouldBe emptyList() + snapshot.startRequests shouldBe emptyMap() + snapshot.syncConflicts shouldBe emptyMap() + } + } + }) diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/application/PostMergeCleanupUseCaseTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/PostMergeCleanupUseCaseTest.kt new file mode 100644 index 00000000..46de7417 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/PostMergeCleanupUseCaseTest.kt @@ -0,0 +1,768 @@ +package io.springkit.workflow.application + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskCleanupState +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspacePath + +class PostMergeCleanupUseCaseTest : + FunSpec({ + context("MERGED parent의 정리 요청을 처리할 때") { + test("child가 parent를 참조하면, restack 후 branch와 worktree를 정리합니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED) + val child = subTask("sk-child", requires = parent.id) + val git = CleanupGit() + val store = + CleanupStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(parent, child))) + + val result = + PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + .execute(PostMergeCleanupRequest(parent.id)) + val response = + result.shouldBeInstanceOf>().data + + response.blocks shouldBe emptyList() + response.state shouldBe PostMergeCleanupState.COMPLETED + response.restackedSubTaskIds shouldBe listOf(child.id) + response.removedRemoteBranches shouldBe listOf(parent.branch) + response.removedWorkspaces shouldBe listOf("ws-${parent.id}") + response.removedLocalBranches shouldBe listOf(parent.branch) + git.operations.indexOf("restack:${child.id}") shouldBe 1 + git.operations shouldContain "remote:${parent.branch}" + git.operations shouldContain "worktree:ws-${parent.id}" + git.operations.last() shouldBe "fetch:origin/main" + git.remoteExpectedRevisions[parent.branch] shouldBe PARENT_SHA + git.localExpectedRevisions[parent.branch] shouldBe PARENT_SHA + } + + test("요청하지 않은 다른 merged SubTask이면, branch와 worktree를 정리하지 않습니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED) + val other = subTask("sk-other", SubTaskState.MERGED) + val git = + CleanupGit( + remoteBranches = + listOf( + RemoteBranch("origin", parent.branch, PARENT_SHA), + RemoteBranch("origin", other.branch, "other-revision"), + ) + ) + val store = + CleanupStore( + WorkflowStoreSnapshot("store-1", subTasks = listOf(parent, other)), + ) + + val result = + PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + .execute(PostMergeCleanupRequest(parent.id)) + val response = + result.shouldBeInstanceOf>().data + + response.blocks shouldBe emptyList() + response.state shouldBe PostMergeCleanupState.COMPLETED + response.removedRemoteBranches shouldBe listOf(parent.branch) + git.operations shouldContain "remote:${parent.branch}" + git.operations.any { it == "remote:${other.branch}" } shouldBe false + git.localExpectedRevisions.keys shouldBe setOf(parent.branch) + } + + test("게시되지 않은 변경이 있는 worktree이면, 정리를 차단합니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED) + val git = CleanupGit(parentDirty = true) + val store = CleanupStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(parent))) + + val result = + PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + .execute(PostMergeCleanupRequest(parent.id)) + val response = + result.shouldBeInstanceOf>().data + + response.state shouldBe PostMergeCleanupState.BLOCKED + response.blocks.map { it.code } shouldContain "WORKTREE_DIRTY" + git.operations.any { it.startsWith("remote:") } shouldBe false + git.operations shouldContain "fetch:origin/main" + git.operations.any { it.startsWith("worktree:") } shouldBe false + git.operations.any { it.startsWith("local:") } shouldBe false + } + + test("Worktree HEAD가 원격 revision과 다르면, 게시되지 않은 commit으로 정리를 차단합니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED) + val git = CleanupGit(parentRevision = "local-revision") + val store = CleanupStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(parent))) + + val result = + PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + .execute(PostMergeCleanupRequest(parent.id)) + val response = + result.shouldBeInstanceOf>().data + + response.state shouldBe PostMergeCleanupState.BLOCKED + response.blocks.map { it.code } shouldContain "UNPUSHED_COMMIT" + git.operations.any { it.startsWith("remote:") } shouldBe false + git.operations.any { it.startsWith("worktree:") } shouldBe false + git.operations.any { it.startsWith("local:") } shouldBe false + } + + test("원격 Branch가 이미 삭제되었으면, 저장된 diff identity로 로컬 정리를 재시도합니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED, pullRequestId = "pr-parent") + val git = + CleanupGit( + parentRevision = PARENT_SHA, + parentFingerprint = PARENT_FINGERPRINT, + remoteBranches = emptyList(), + ) + val store = + CleanupStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(parent), + pullRequests = listOf(pullRequest("pr-parent", PARENT_FINGERPRINT)), + ) + ) + + val result = + PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + .execute(PostMergeCleanupRequest(parent.id)) + val response = + result.shouldBeInstanceOf>().data + + response.state shouldBe PostMergeCleanupState.COMPLETED + response.removedRemoteBranches shouldBe emptyList() + response.removedWorkspaces shouldBe listOf("ws-${parent.id}") + response.removedLocalBranches shouldBe listOf(parent.branch) + git.operations.any { it.startsWith("remote:") } shouldBe false + git.localExpectedRevisions[parent.branch] shouldBe PARENT_SHA + } + + test("원격 삭제 뒤 저장된 Git SHA checkpoint로 worktree와 local branch를 재시도합니다") { + val parent = + subTask("sk-parent", SubTaskState.MERGED) + .copy( + cleanupState = SubTaskCleanupState.REMOTE_BRANCH_REMOVED, + cleanupRevision = PARENT_SHA, + ) + val git = CleanupGit(remoteBranches = emptyList()) + val store = CleanupStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(parent))) + + val response = + PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + response.state shouldBe PostMergeCleanupState.COMPLETED + git.operations.any { it.startsWith("remote:") } shouldBe false + git.localExpectedRevisions[parent.branch] shouldBe PARENT_SHA + store.current.subTasks.single().cleanupRevision shouldBe PARENT_SHA + } + + test("SHA checkpoint가 있고 원격과 worktree가 모두 사라졌으면 local branch 재시도를 계속합니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED).copy(cleanupRevision = PARENT_SHA) + val git = CleanupGit(remoteBranches = emptyList(), worktreeMissingInitially = true) + val store = CleanupStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(parent))) + + val response = + PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + response.state shouldBe PostMergeCleanupState.COMPLETED + response.removedWorkspaces shouldBe emptyList() + response.removedLocalBranches shouldBe listOf(parent.branch) + git.operations.any { it.startsWith("remote:") } shouldBe false + git.operations.any { it.startsWith("worktree:") } shouldBe false + git.localExpectedRevisions[parent.branch] shouldBe PARENT_SHA + } + + test("원격 Branch가 없고 기대 revision을 증명할 수 없으면, 로컬 정리를 차단합니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED) + val git = CleanupGit(remoteBranches = emptyList()) + val store = CleanupStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(parent))) + + val result = + PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + .execute(PostMergeCleanupRequest(parent.id)) + val response = + result.shouldBeInstanceOf>().data + + response.state shouldBe PostMergeCleanupState.BLOCKED + response.blocks.map { it.code } shouldContain "EXPECTED_REVISION_UNAVAILABLE" + git.operations.any { it.startsWith("worktree:") } shouldBe false + git.operations.any { it.startsWith("local:") } shouldBe false + } + + test("원격 삭제 전 SHA checkpoint 저장이 실패하면 삭제 작업을 진행하지 않습니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED, pullRequestId = "pr-parent") + val git = CleanupGit() + val store = + CleanupStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(parent), + pullRequests = + listOf( + pullRequest( + "pr-parent", + "patch-fingerprint", + providerRevision = PARENT_SHA, + ) + ), + ), + failWriteRevision = true, + ) + + val response = + PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + response.state shouldBe PostMergeCleanupState.BLOCKED + response.blocks.map { it.phase } shouldContain "store" + git.operations.any { it.startsWith("remote:") } shouldBe false + git.operations.any { it.startsWith("worktree:") } shouldBe false + git.operations.any { it.startsWith("local:") } shouldBe false + } + + test("원격 revision이 provider SHA와 다르면 원격 Branch를 삭제하지 않습니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED, pullRequestId = "pr-parent") + val git = + CleanupGit(remoteBranches = listOf(RemoteBranch("origin", parent.branch, REMOTE_SHA))) + val store = + CleanupStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(parent), + pullRequests = + listOf( + pullRequest( + "pr-parent", + "patch-fingerprint", + providerRevision = PROVIDER_SHA, + ) + ), + ) + ) + + val response = + PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + response.state shouldBe PostMergeCleanupState.BLOCKED + response.blocks.map { it.code } shouldContain "PROVIDER_REVISION_MISMATCH" + git.operations.any { it.startsWith("remote:") } shouldBe false + } + + test("local branch 정리가 실패하면, 성공한 원격과 worktree 정리 단계를 저장하고 재시도합니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED, pullRequestId = "pr-parent") + val git = CleanupGit(failLocalOnce = true) + val store = + CleanupStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(parent), + pullRequests = listOf(pullRequest("pr-parent", PARENT_FINGERPRINT)), + ) + ) + val useCase = PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + + val first = + useCase + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + first.state shouldBe PostMergeCleanupState.BLOCKED + store.current.subTasks.single().cleanupState shouldBe SubTaskCleanupState.WORKTREE_REMOVED + val operationsAfterFailure = git.operations.toList() + + val retry = + useCase + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + retry.state shouldBe PostMergeCleanupState.COMPLETED + git.operations.count { it == "remote:${parent.branch}" } shouldBe 1 + git.operations.count { it == "worktree:ws-${parent.id}" } shouldBe 1 + git.operations.drop(operationsAfterFailure.size) shouldBe + listOf("local:${parent.branch}", "fetch:origin/main") + store.current.subTasks.single().cleanupState shouldBe SubTaskCleanupState.COMPLETED + } + + test("worktree 삭제 뒤 상태 저장이 실패하면, 재시도에서 삭제된 worktree를 건너뜁니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED, pullRequestId = "pr-parent") + val git = CleanupGit(failInspectAfterWorktreeRemoval = true) + val store = + CleanupStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(parent), + pullRequests = listOf(pullRequest("pr-parent", PARENT_FINGERPRINT)), + ), + failWriteState = SubTaskCleanupState.WORKTREE_REMOVED, + ) + val useCase = PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + + val first = + useCase + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + first.state shouldBe PostMergeCleanupState.BLOCKED + store.current.subTasks.single().cleanupState shouldBe + SubTaskCleanupState.REMOTE_BRANCH_REMOVED + val retry = + useCase + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + retry.state shouldBe PostMergeCleanupState.COMPLETED + git.operations.count { it == "worktree:ws-${parent.id}" } shouldBe 1 + git.operations.count { it == "remote:${parent.branch}" } shouldBe 1 + } + + test("원격 삭제 뒤 상태 저장이 실패하면, 재시도에서 원격 삭제를 반복하지 않습니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED, pullRequestId = "pr-parent") + val git = CleanupGit() + val store = + CleanupStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(parent), + pullRequests = listOf(pullRequest("pr-parent", PARENT_FINGERPRINT)), + ), + failWriteState = SubTaskCleanupState.REMOTE_BRANCH_REMOVED, + ) + val useCase = PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + + val first = + useCase + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + first.state shouldBe PostMergeCleanupState.BLOCKED + val retry = + useCase + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + retry.state shouldBe PostMergeCleanupState.COMPLETED + git.operations.count { it == "remote:${parent.branch}" } shouldBe 1 + } + + test("local branch 삭제가 저장된 뒤 완료 저장이 실패하면, 재시도에서 삭제를 반복하지 않습니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED, pullRequestId = "pr-parent") + val git = CleanupGit() + val store = + CleanupStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(parent), + pullRequests = listOf(pullRequest("pr-parent", PARENT_FINGERPRINT)), + ), + failWriteState = SubTaskCleanupState.COMPLETED, + ) + val useCase = PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + + val first = + useCase + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + first.state shouldBe PostMergeCleanupState.BLOCKED + store.current.subTasks.single().cleanupState shouldBe + SubTaskCleanupState.LOCAL_BRANCH_REMOVED + val retry = + useCase + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + retry.state shouldBe PostMergeCleanupState.COMPLETED + git.operations.count { it == "local:${parent.branch}" } shouldBe 1 + store.current.subTasks.single().cleanupState shouldBe SubTaskCleanupState.COMPLETED + } + + test("main revision 갱신이 실패하면, 재시도에서 branch 삭제 없이 갱신을 다시 시도합니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED, pullRequestId = "pr-parent") + val git = CleanupGit(failRefreshOnce = true) + val store = + CleanupStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(parent), + pullRequests = listOf(pullRequest("pr-parent", PARENT_FINGERPRINT)), + ) + ) + val useCase = PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + + val first = + useCase + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + first.state shouldBe PostMergeCleanupState.BLOCKED + store.current.subTasks.single().cleanupState shouldBe + SubTaskCleanupState.LOCAL_BRANCH_REMOVED + val firstOperations = git.operations.toList() + val retry = + useCase + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + retry.state shouldBe PostMergeCleanupState.COMPLETED + git.operations.count { it == "remote:${parent.branch}" } shouldBe 1 + git.operations.count { it == "worktree:ws-${parent.id}" } shouldBe 1 + git.operations.count { it == "local:${parent.branch}" } shouldBe 1 + git.operations.drop(firstOperations.size) shouldBe listOf("fetch:origin/main") + store.current.subTasks.single().cleanupState shouldBe SubTaskCleanupState.COMPLETED + } + + test("완료된 정리를 다시 실행하면, 삭제 단계를 다시 호출하지 않습니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED, pullRequestId = "pr-parent") + val git = CleanupGit() + val store = + CleanupStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(parent), + pullRequests = listOf(pullRequest("pr-parent", PARENT_FINGERPRINT)), + ) + ) + val useCase = PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + + useCase.execute(PostMergeCleanupRequest(parent.id)) + val operationsAfterCompletion = git.operations.toList() + val rerun = + useCase + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + rerun.state shouldBe PostMergeCleanupState.COMPLETED + git.operations shouldBe operationsAfterCompletion + } + + test("workspace가 없는 merged SubTask이면, local branch까지 정리하고 단계를 저장합니다") { + val parent = + subTask("sk-parent", SubTaskState.MERGED, pullRequestId = "pr-parent") + .copy(workspace = null) + val git = CleanupGit() + val store = + CleanupStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(parent), + pullRequests = listOf(pullRequest("pr-parent", PARENT_FINGERPRINT)), + ) + ) + + val result = + PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + result.state shouldBe PostMergeCleanupState.COMPLETED + git.operations shouldContain "remote:${parent.branch}" + git.operations shouldContain "local:${parent.branch}" + git.operations.any { it.startsWith("worktree:") } shouldBe false + store.current.subTasks.single().cleanupState shouldBe SubTaskCleanupState.COMPLETED + } + + test("main refresh 뒤 completion 대상이 없으면, 완료 저장을 차단합니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED, pullRequestId = "pr-parent") + val store = + CleanupStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(parent), + pullRequests = listOf(pullRequest("pr-parent", PARENT_FINGERPRINT)), + ) + ) + val git = + CleanupGit( + afterRefresh = { store.current = store.current.copy(subTasks = emptyList()) } + ) + + val result = + PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + result.state shouldBe PostMergeCleanupState.BLOCKED + result.blocks.single().code shouldBe "SUBTASK_NOT_FOUND" + } + + test("main refresh 뒤 completion 상태가 다르면, 상태 충돌로 완료를 차단합니다") { + val parent = subTask("sk-parent", SubTaskState.MERGED, pullRequestId = "pr-parent") + val store = + CleanupStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(parent), + pullRequests = listOf(pullRequest("pr-parent", PARENT_FINGERPRINT)), + ) + ) + val git = + CleanupGit( + afterRefresh = { + store.current = + store.current.copy( + subTasks = + store.current.subTasks.map { + it.copy(cleanupState = SubTaskCleanupState.WORKTREE_REMOVED) + } + ) + } + ) + + val result = + PostMergeCleanupUseCase(git, NoopCleanupReview(), store) + .execute(PostMergeCleanupRequest(parent.id)) + .shouldBeInstanceOf>() + .data + + result.state shouldBe PostMergeCleanupState.BLOCKED + result.blocks.single().code shouldBe "STATE_CONFLICT" + } + } + }) + +private const val PARENT_SHA = "0123456789abcdef0123456789abcdef01234567" +private const val PROVIDER_SHA = "1123456789abcdef0123456789abcdef01234567" +private const val REMOTE_SHA = "2123456789abcdef0123456789abcdef01234567" +private const val PARENT_FINGERPRINT = "parent-diff-fingerprint" + +private fun subTask( + id: String, + state: SubTaskState = SubTaskState.DEVELOPMENT, + requires: String? = null, + pullRequestId: String? = null, +) = + SubTask( + id = id, + taskId = "task-1", + title = id, + state = state, + workspace = Workspace("ws-$id", id, WorkspacePath("/managed/$id"), id), + requires = requires, + pullRequestId = pullRequestId, + ) + +private fun pullRequest(id: String, diffIdentity: String, providerRevision: String? = null) = + PullRequest( + id = id, + subTaskId = "sk-parent", + title = "parent", + body = "body", + base = "main", + state = PullRequestState.MERGED, + reviewRevision = ReviewRevision("review-$id", 1, "body"), + changeRevision = + ChangeRevision( + "change-$id", + 1, + Diff(diffIdentity), + providerRevision = providerRevision, + ), + ) + +private class CleanupStore( + var current: WorkflowStoreSnapshot, + private var failWriteState: SubTaskCleanupState? = null, + private var failWriteRevision: Boolean = false, +) : WorkflowStorePort { + override fun snapshot(request: StoreSnapshotRequest) = + PortResult.Success(StoreSnapshotResponse(current)) + + override fun begin(request: StoreTransactionRequest) = + PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.OPEN) + ) + + override fun write(request: StoreWriteRequest): PortResult { + if (failWriteRevision && request.snapshot.subTasks.singleOrNull()?.cleanupRevision != null) { + failWriteRevision = false + return PortResult.Failure(PortError("STORE_DOWN", "store checkpoint write failed")) + } + val newState = request.snapshot.subTasks.singleOrNull()?.cleanupState + if (failWriteState != null && newState == failWriteState) { + failWriteState = null + return PortResult.Failure(PortError("STORE_DOWN", "store write failed")) + } + current = request.snapshot.copy(revision = "${request.snapshot.revision}-next") + return PortResult.Success(StoreWriteResponse(current.revision)) + } + + override fun commit(request: StoreTransactionRequest) = + PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.COMMITTED) + ) + + override fun rollback(request: StoreTransactionRequest) = + PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.ROLLED_BACK) + ) + + override fun append(request: StoreEventRequest) = + PortResult.Success(StoreEventResponse(request.event, current.revision)) +} + +private class CleanupGit( + private val parentDirty: Boolean = false, + private val parentRevision: String = PARENT_SHA, + private val parentFingerprint: String = "ws-sk-parent-fingerprint", + remoteBranches: List = listOf(RemoteBranch("origin", "sk-parent", PARENT_SHA)), + private var failLocalOnce: Boolean = false, + private var failInspectAfterWorktreeRemoval: Boolean = false, + private var failRefreshOnce: Boolean = false, + private val worktreeMissingInitially: Boolean = false, + private val afterRefresh: (() -> Unit)? = null, +) : GitPort { + val operations = mutableListOf() + val remoteExpectedRevisions = mutableMapOf() + val localExpectedRevisions = mutableMapOf() + private var remoteBranches = remoteBranches.toMutableList() + private val removedWorktrees = + mutableSetOf().apply { + if (worktreeMissingInitially) add("ws-sk-parent") + } + + override fun refreshMain(request: MainRevisionRequest): PortResult { + operations += "fetch:${request.remote}/${request.branch}" + if (failRefreshOnce) { + failRefreshOnce = false + return PortResult.Failure(PortError("MAIN_REVISION_REFRESH_FAILED", "fetch failed")) + } + afterRefresh?.invoke() + return PortResult.Success(MainRevisionResponse("main-revision")) + } + + override fun inspect(request: GitInspectRequest): PortResult { + if ( + request.workspaceId in removedWorktrees && + (failInspectAfterWorktreeRemoval || worktreeMissingInitially) + ) { + return PortResult.Failure(PortError("WORKSPACE_NOT_FOUND", "workspace is missing")) + } + return PortResult.Success( + GitInspectResponse( + GitStatus( + revision = + if (request.workspaceId == "ws-sk-parent") parentRevision + else "${request.workspaceId}-revision", + fingerprint = + if (request.workspaceId == "ws-sk-parent") parentFingerprint + else "${request.workspaceId}-fingerprint", + dirty = parentDirty && request.workspaceId == "ws-sk-parent", + ) + ) + ) + } + + override fun createBranch(request: CreateBranchRequest) = error("not used") + + override fun createWorktree(request: CreateWorktreeRequest) = error("not used") + + override fun restack(request: RestackRequest): PortResult { + operations += "restack:${request.workspaceId.removePrefix("ws-")}" + return PortResult.Success( + RestackResponse( + revision = "${request.workspaceId}-restacked", + fingerprint = "fingerprint", + diffChanged = false, + change = ChangeReceipt("restack-${request.workspaceId}", "restack"), + ) + ) + } + + override fun removeBranch(request: RemoveBranchRequest): PortResult { + operations += "local:${request.branch}" + localExpectedRevisions[request.branch] = request.expectedRevision + if (failLocalOnce) { + failLocalOnce = false + return PortResult.Failure(PortError("LOCAL_BRANCH_BUSY", "local branch is busy")) + } + return PortResult.Success( + RemoveBranchResponse( + request.branch, + ChangeReceipt("local-${request.branch}", "remove-branch"), + ) + ) + } + + override fun listRemoteBranches(request: ListRemoteBranchesRequest) = + PortResult.Success(ListRemoteBranchesResponse(remoteBranches)) + + override fun removeRemoteBranch( + request: RemoveRemoteBranchRequest + ): PortResult { + operations += "remote:${request.branch}" + remoteExpectedRevisions[request.branch] = request.expectedRevision + remoteBranches.removeAll { it.remote == request.remote && it.branch == request.branch } + return PortResult.Success( + RemoveRemoteBranchResponse( + request.remote, + request.branch, + ChangeReceipt("remote-${request.branch}", "remove-remote-branch"), + ) + ) + } + + override fun removeWorktree(request: RemoveWorktreeRequest): PortResult { + operations += "worktree:${request.workspaceId}" + removedWorktrees += request.workspaceId + return PortResult.Success( + RemoveWorktreeResponse( + request.workspaceId, + request.path, + ChangeReceipt("worktree-${request.workspaceId}", "remove-worktree"), + ) + ) + } + + override fun continueRestack(request: ContinueRestackRequest) = error("not used") + + override fun abortRestack(request: AbortRestackRequest) = error("not used") +} + +private class NoopCleanupReview : ReviewPort { + override fun open(request: OpenReviewRequest) = error("not used") + + override fun get(request: GetReviewRequest) = error("not used") + + override fun update(request: UpdateReviewRequest) = error("not used") + + override fun comment(request: AddReviewCommentRequest) = error("not used") + + override fun reply(request: ReplyReviewThreadRequest) = error("not used") + + override fun resolve(request: ResolveReviewThreadRequest) = error("not used") + + override fun ready(request: ReadyReviewRequest) = error("not used") + + override fun approve(request: ApproveReviewRequest) = error("not used") +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/application/ReleaseLifecycleUseCasesTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/ReleaseLifecycleUseCasesTest.kt new file mode 100644 index 00000000..b8a94d09 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/ReleaseLifecycleUseCasesTest.kt @@ -0,0 +1,398 @@ +package io.springkit.workflow.application + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.adapter.store.OkioWorkflowStoreAdapter +import io.springkit.workflow.adapter.store.StoreIdAdapter +import io.springkit.workflow.domain.DeploymentCandidate +import io.springkit.workflow.domain.DeploymentCandidateState +import io.springkit.workflow.domain.DeploymentRecorded +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.Release +import io.springkit.workflow.domain.ReleaseRecorded +import io.springkit.workflow.domain.ReleaseState +import io.springkit.workflow.domain.Risk +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.ValidationStatus +import io.springkit.workflow.domain.WorkflowResult +import okio.Path.Companion.toPath +import okio.fakefilesystem.FakeFileSystem + +class ReleaseLifecycleUseCasesTest : + FunSpec({ + context("Production 후보를 공개할 때") { + test("내부 검수가 성공하면, AWAITING_RELEASE_APPROVAL과 사람 승인 행동을 반환합니다") { + val store = ReleaseRecordingStore(snapshot()) + val releasePort = ReleaseRecordingPort() + val result = useCase(store, releasePort).create(createRequest()) + + val success = + result.shouldBeInstanceOf>() + success.data.release.state shouldBe ReleaseState.AWAITING_RELEASE_APPROVAL + success.data.next.single().action shouldBe "approve_release" + store.written?.eventLog?.events.orEmpty() shouldContain + ReleaseRecorded("release-1", ReleaseState.AWAITING_RELEASE_APPROVAL, 0) + } + + test("Production Event를 다시 처리하면, Release를 중복 생성하지 않고 기존 결과를 반환합니다") { + val store = ReleaseRecordingStore(snapshot()) + val releasePort = ReleaseRecordingPort() + val useCase = useCase(store, releasePort) + + useCase.handleDeployment( + DeploymentRecorded("candidate-1", DeploymentCandidateState.PRODUCTION) + ) + val result = + useCase.handleDeployment( + DeploymentRecorded("candidate-1", DeploymentCandidateState.PRODUCTION) + ) + + result.shouldBeInstanceOf>() + releasePort.createCalls shouldBe 1 + releasePort.validateCalls shouldBe 1 + } + + test("여러 SubTask가 포함되어도 candidate당 Release 하나를 생성합니다") { + val store = + ReleaseRecordingStore(snapshot(includedSubTaskIds = listOf("sk-101", "sk-102"))) + val releasePort = ReleaseRecordingPort() + val result = + useCase(store, releasePort) + .handleDeploymentAll( + DeploymentRecorded("candidate-1", DeploymentCandidateState.PRODUCTION) + ) + + val success = + result.shouldBeInstanceOf>>() + success.data.map { it.release.candidateId } shouldBe listOf("candidate-1") + success.data.map { it.release.id }.distinct().size shouldBe 1 + releasePort.createCalls shouldBe 1 + releasePort.validateCalls shouldBe 1 + store.written?.releases?.map { it.candidateId } shouldBe listOf("candidate-1") + } + + test("실제 Store에서 Release ID를 발급하면, candidate에 연결해 저장합니다") { + val fileSystem = FakeFileSystem() + val path = "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/workflow/state.json".toPath() + val store = OkioWorkflowStoreAdapter(fileSystem, path) + val initial = + snapshot(includedSubTaskIds = listOf("sk-101", "sk-102")).copy(revision = "0") + writeSnapshot(store, initial) + val releasePort = ReleaseRecordingPort() + + val result = + ReleaseLifecycleUseCases(store, releasePort, idPort = StoreIdAdapter(store)) + .handleDeploymentAll( + DeploymentRecorded("candidate-1", DeploymentCandidateState.PRODUCTION) + ) + + val success = + result.shouldBeInstanceOf>>() + success.data.map { it.release.id } shouldBe listOf("rel-1") + success.data.map { it.release.candidateId } shouldBe listOf("candidate-1") + releasePort.createCalls shouldBe 1 + releasePort.validateCalls shouldBe 1 + val stored = + store + .snapshot(StoreSnapshotRequest()) + .shouldBeInstanceOf>() + stored.value.snapshot.revision shouldBe "3" + stored.value.snapshot.sequence.release shouldBe 1 + stored.value.snapshot.releases.map { it.candidateId } shouldBe listOf("candidate-1") + } + + test("candidate Release가 이미 있으면, 재처리에서 기존 Release를 반환합니다") { + val existing = + release( + state = ReleaseState.AWAITING_RELEASE_APPROVAL, + ) + val store = ReleaseRecordingStore(snapshot(release = existing)) + val releasePort = ReleaseRecordingPort(existing) + val result = + useCase(store, releasePort) + .handleDeploymentAll( + DeploymentRecorded("candidate-1", DeploymentCandidateState.PRODUCTION) + ) + + val success = + result.shouldBeInstanceOf>>() + success.data.map { it.release.id } shouldBe listOf(existing.id) + releasePort.createCalls shouldBe 0 + releasePort.validateCalls shouldBe 0 + } + + test("SubTask 정보가 없어도 Production candidate에 Release를 생성합니다") { + val store = + ReleaseRecordingStore( + snapshot(includedSubTaskIds = emptyList(), includeSubTasks = false) + ) + val result = + useCase(store, ReleaseRecordingPort()) + .handleDeploymentAll( + DeploymentRecorded("candidate-1", DeploymentCandidateState.PRODUCTION) + ) + + val success = + result.shouldBeInstanceOf>>() + success.data.size shouldBe 1 + success.data.single().release.candidateId shouldBe "candidate-1" + } + } + + context("점진 공개가 완료된 상태에서") { + test("rollout 완료 Event를 반영하면, CLEANUP_REQUIRED와 Cleanup SubTask 행동을 반환합니다") { + val release = release(state = ReleaseState.ROLLOUT) + val store = ReleaseRecordingStore(snapshot(release = release)) + val result = + useCase(store, ReleaseRecordingPort(release)) + .handle(ReleaseRecorded(release.id, ReleaseState.RELEASED)) + + val success = + result.shouldBeInstanceOf>() + success.data.release.state shouldBe ReleaseState.CLEANUP_REQUIRED + success.data.next.single().action shouldBe "create_cleanup_subtask" + } + + test("오래된 Release Event를 반영하면, 현재 상태를 되돌리지 않습니다") { + val release = release(state = ReleaseState.CLEANUP_REQUIRED) + val store = ReleaseRecordingStore(snapshot(release = release)) + val result = + useCase(store, ReleaseRecordingPort(release)) + .handle(ReleaseRecorded(release.id, ReleaseState.INTERNAL_VALIDATION)) + + val success = + result.shouldBeInstanceOf>() + success.data.release.state shouldBe ReleaseState.CLEANUP_REQUIRED + store.written shouldBe null + } + } + + context("Release 공급자 또는 Store 작업이 실패할 때") { + test("Store 쓰기가 실패하면, 공급자 변경을 역순으로 보상하고 롤백합니다") { + val store = ReleaseRecordingStore(snapshot(), failWrite = true) + val releasePort = ReleaseRecordingPort() + val compensation = ReleaseRecordingCompensation() + val result = useCase(store, releasePort, compensation).create(createRequest()) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.STATE_CONFLICT + compensation.operations shouldBe listOf("validate", "create") + store.calls shouldBe listOf("snapshot", "begin", "snapshot", "write", "rollback") + store.written shouldBe null + } + + test("내부 검수가 실패하면, Release를 저장하지 않고 생성 변경을 보상합니다") { + val store = ReleaseRecordingStore(snapshot()) + val releasePort = ReleaseRecordingPort(failValidation = true) + val compensation = ReleaseRecordingCompensation() + val result = useCase(store, releasePort, compensation).create(createRequest()) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.EXTERNAL_FAILURE + compensation.operations shouldBe listOf("create") + store.calls shouldBe listOf("snapshot", "begin", "snapshot", "rollback") + store.written shouldBe null + } + } + }) + +private fun createRequest() = + CreateReleaseLifecycleRequest( + candidateId = "candidate-1", + releaseId = "release-1", + requestId = "request-1", + ) + +private fun snapshot( + release: Release? = null, + includedSubTaskIds: List = listOf("sk-101"), + includeSubTasks: Boolean = true, +): WorkflowStoreSnapshot = + WorkflowStoreSnapshot( + revision = "store-1", + subTasks = + if (includeSubTasks) { + includedSubTaskIds.mapIndexed { index, id -> + SubTask(id, "task-1", "SubTask ${index + 1}") + } + } else { + emptyList() + }, + candidates = listOf(productionCandidate(includedSubTaskIds)), + releases = listOfNotNull(release), + ) + +private fun writeSnapshot(store: WorkflowStorePort, snapshot: WorkflowStoreSnapshot) { + val transaction = StoreTransactionRequest("seed", snapshot.revision, "seed") + store.begin(transaction) + store.write(StoreWriteRequest(transaction.transactionId, snapshot.revision, snapshot)) + store.commit(transaction) +} + +private fun productionCandidate(includedSubTaskIds: List) = + DeploymentCandidate( + id = "candidate-1", + mainRevision = "main-1", + includedSubTasks = includedSubTaskIds, + risks = includedSubTaskIds.associateWith { Risk.NORMAL }, + validations = listOf(Validation("validation-1", "test", ValidationStatus.PASSED)), + state = DeploymentCandidateState.PRODUCTION, + ) + +private fun release( + state: ReleaseState, + internalValidationPassed: Boolean = true, +) = + Release( + id = "release-1", + candidateId = "candidate-1", + state = state, + productionReady = true, + internalValidationPassed = internalValidationPassed, + ) + +private fun useCase( + store: ReleaseRecordingStore, + releasePort: ReleaseRecordingPort, + compensation: ReleaseRecordingCompensation = ReleaseRecordingCompensation(), +) = + ReleaseLifecycleUseCases( + store, + releasePort, + idPort = ReleaseLifecycleIdPort(), + compensationPort = compensation, + ) + +private class ReleaseLifecycleIdPort : IdPort { + private var nextReleaseId = 1 + + override fun issue(request: IssueIdRequest): PortResult = + PortResult.Success( + IssueIdResponse( + listOf( + IssuedId( + request.kind, + if (request.kind == IdKind.RELEASE) "release-${nextReleaseId++}" + else "tx-${request.requestId}", + ) + ) + ) + ) +} + +private class ReleaseRecordingStore( + private var current: WorkflowStoreSnapshot, + private val failWrite: Boolean = false, +) : WorkflowStorePort { + val calls = mutableListOf() + var written: WorkflowStoreSnapshot? = null + + override fun begin(request: StoreTransactionRequest): PortResult { + calls += "begin" + return PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.OPEN) + ) + } + + override fun snapshot(request: StoreSnapshotRequest): PortResult { + calls += "snapshot" + return PortResult.Success(StoreSnapshotResponse(current)) + } + + override fun write(request: StoreWriteRequest): PortResult { + calls += "write" + if (failWrite) return PortResult.Failure(PortError("STATE_CONFLICT", "write failed")) + written = request.snapshot + current = request.snapshot.copy(revision = "store-2") + return PortResult.Success(StoreWriteResponse("store-2")) + } + + override fun append(request: StoreEventRequest): PortResult = + PortResult.Failure(PortError("UNSUPPORTED", "append is not used")) + + override fun commit(request: StoreTransactionRequest): PortResult { + calls += "commit" + return PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.COMMITTED) + ) + } + + override fun rollback(request: StoreTransactionRequest): PortResult { + calls += "rollback" + return PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.ROLLED_BACK) + ) + } +} + +private class ReleaseRecordingPort( + initial: Release? = null, + private val failValidation: Boolean = false, +) : ReleasePort { + private val current = + mutableMapOf().apply { + initial?.let { put(it.id, it) } + } + var createCalls = 0 + var validateCalls = 0 + + override fun get(request: GetReleaseRequest): PortResult = + PortResult.Success( + GetReleaseResponse( + current.values.filter { request.releaseId == null || it.id == request.releaseId } + ) + ) + + override fun create(request: CreateReleaseRequest): PortResult { + createCalls += 1 + val created = + Release( + id = request.releaseId, + candidateId = request.candidateId, + state = ReleaseState.SAFE_DEFAULT, + ) + current[created.id] = created + return PortResult.Success(CreateReleaseResponse(created, change("create"))) + } + + override fun validateInternal( + request: ValidateReleaseRequest + ): PortResult { + validateCalls += 1 + if (failValidation) + return PortResult.Failure(PortError("EXTERNAL_FAILURE", "validation failed")) + val validated = + current + .getValue(request.releaseId) + .copy(state = ReleaseState.INTERNAL_VALIDATION, internalValidationPassed = true) + current[validated.id] = validated + return PortResult.Success(ValidateReleaseResponse(validated, change("validate"))) + } + + override fun start(request: StartReleaseRequest): PortResult = + PortResult.Failure(PortError("UNSUPPORTED", "start is not used")) + + override fun continueRollout( + request: ContinueReleaseRequest + ): PortResult = + PortResult.Failure(PortError("UNSUPPORTED", "continue is not used")) +} + +private class ReleaseRecordingCompensation : CompensationPort { + val operations = mutableListOf() + + override fun compensate(request: CompensateRequest): PortResult { + operations += request.change.operation + return PortResult.Success(CompensateResponse(request.change)) + } +} + +private fun change(operation: String) = + ChangeReceipt( + id = "change-$operation", + operation = operation, + compensation = Compensation("comp-$operation", "undo-$operation", "undo-$operation"), + ) diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/application/ReviewGateUseCasesTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/ReviewGateUseCasesTest.kt new file mode 100644 index 00000000..add48fa3 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/ReviewGateUseCasesTest.kt @@ -0,0 +1,422 @@ +package io.springkit.workflow.application + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.Approval +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.CiStatus +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.MergeQueueEntry +import io.springkit.workflow.domain.MergeQueueState +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.ReviewComment +import io.springkit.workflow.domain.ReviewLevel +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.ReviewThread +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.WorkflowResult + +class ReviewGateUseCasesTest : + FunSpec({ + context("Ready Gate를 기록할 때") { + test("사람이 현재 review revision을 지정하면, PR과 SubTask를 Ready로 저장하고 감사 기록을 남깁니다") { + val store = + ReviewGateStore( + snapshot( + subTaskState = SubTaskState.DRAFT, + pullRequestState = PullRequestState.DRAFT, + ) + ) + val review = ReviewGateReviewPort() + val useCase = useCase(store, review) + + val result = useCase.ready(ReadyGateRequest("sk-101", "rv-1")) + + val success = result.shouldBeInstanceOf>() + success.data.pullRequest.state shouldBe PullRequestState.READY + store.written?.subTasks?.single()?.state shouldBe SubTaskState.READY + store.written?.eventLog?.audits?.single()?.action shouldBe "ready" + review.readyCalls shouldBe 1 + } + + test("사람이 아닌 주체가 Ready를 요청하면, 사람 결정 필요 오류로 거절하고 외부 변경을 호출하지 않습니다") { + val store = + ReviewGateStore( + snapshot( + subTaskState = SubTaskState.DRAFT, + pullRequestState = PullRequestState.DRAFT, + ) + ) + val review = ReviewGateReviewPort() + val identity = ReviewGateIdentityPort(Actor("agent-1", ActorKind.AGENT)) + val result = useCase(store, review, identity).ready(ReadyGateRequest("sk-101", "rv-1")) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.HUMAN_REQUIRED + review.readyCalls shouldBe 0 + store.beginCalls shouldBe 0 + } + + test("현재 review revision과 다른 값을 지정하면, 오래된 revision 오류로 거절합니다") { + val store = + ReviewGateStore( + snapshot( + subTaskState = SubTaskState.DRAFT, + pullRequestState = PullRequestState.DRAFT, + ) + ) + val review = ReviewGateReviewPort() + val result = useCase(store, review).ready(ReadyGateRequest("sk-101", "rv-old")) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.STALE_REVISION + review.readyCalls shouldBe 0 + store.written shouldBe null + } + } + + context("Approve Gate를 기록할 때") { + test("현재 코드와 CI를 확인하면, 승인을 기록하고 PR과 SubTask를 Queue 상태로 저장합니다") { + val store = + ReviewGateStore( + snapshot( + subTaskState = SubTaskState.READY, + pullRequestState = PullRequestState.READY, + ) + ) + val review = ReviewGateReviewPort() + val queue = ReviewGateMergeQueuePort() + val result = + useCase(store, review, ReviewGateIdentityPort(), queue) + .approve(ApproveGateRequest("sk-101", "cr-1", "diff-1")) + + val success = result.shouldBeInstanceOf>() + success.data.pullRequest.state shouldBe PullRequestState.QUEUED + success.data.mergeQueue.state shouldBe MergeQueueState.QUEUED + store.written?.subTasks?.single()?.state shouldBe SubTaskState.QUEUED + store.written?.pullRequests?.single()?.approval?.active shouldBe true + store.written?.eventLog?.audits?.single()?.action shouldBe "approve" + queue.requests.single().expectedRevision shouldBe "store-1" + } + + test("CI가 통과하지 않으면, 승인과 Queue 등록을 수행하지 않습니다") { + val pr = pullRequest(PullRequestState.READY).copy(ci = CiStatus.FAILED) + val store = ReviewGateStore(snapshot(pr = pr, subTaskState = SubTaskState.READY)) + val review = ReviewGateReviewPort() + val queue = ReviewGateMergeQueuePort() + val result = + useCase(store, review, ReviewGateIdentityPort(), queue) + .approve(ApproveGateRequest("sk-101", "cr-1", "diff-1")) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.CI_NOT_PASSED + review.approveCalls shouldBe 0 + queue.requests shouldBe emptyList() + store.written shouldBe null + } + + test("해결되지 않은 R thread가 있으면, 승인과 Queue 등록을 수행하지 않습니다") { + val thread = + ReviewThread( + "thread-1", + ReviewLevel.R, + listOf( + ReviewComment("comment-1", Actor("reviewer", ActorKind.HUMAN), "수정이 필요합니다") + ), + ) + val pr = + pullRequest(PullRequestState.READY) + .copy(reviewRevision = reviewRevision(threads = listOf(thread))) + val store = ReviewGateStore(snapshot(pr = pr, subTaskState = SubTaskState.READY)) + val review = ReviewGateReviewPort() + val queue = ReviewGateMergeQueuePort() + val result = + useCase(store, review, ReviewGateIdentityPort(), queue) + .approve(ApproveGateRequest("sk-101", "cr-1", "diff-1")) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.REQUIRED_THREAD_OPEN + review.approveCalls shouldBe 0 + queue.requests shouldBe emptyList() + } + + test("직접 선행 SubTask가 통합되지 않았으면, dependency 오류로 Queue 진입을 막습니다") { + val parent = SubTask("sk-100", "task-1", "parent", state = SubTaskState.REVIEW) + val current = + subTask(SubTaskState.READY, PullRequestState.READY).copy(requires = parent.id) + val store = ReviewGateStore(snapshot(subTask = current, extraSubTasks = listOf(parent))) + val review = ReviewGateReviewPort() + val queue = ReviewGateMergeQueuePort() + val result = + useCase(store, review, ReviewGateIdentityPort(), queue) + .approve(ApproveGateRequest("sk-101", "cr-1", "diff-1")) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.DEPENDENCY_NOT_MERGED + review.approveCalls shouldBe 0 + queue.requests shouldBe emptyList() + } + + test("다른 diff identity를 지정하면, 오래된 diff 오류로 거절합니다") { + val store = + ReviewGateStore( + snapshot( + subTaskState = SubTaskState.READY, + pullRequestState = PullRequestState.READY, + ) + ) + val review = ReviewGateReviewPort() + val queue = ReviewGateMergeQueuePort() + val result = + useCase(store, review, ReviewGateIdentityPort(), queue) + .approve(ApproveGateRequest("sk-101", "cr-1", "diff-old")) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.STALE_DIFF_IDENTITY + review.approveCalls shouldBe 0 + queue.requests shouldBe emptyList() + } + } + + context("Approve 이후 저장소 쓰기가 실패할 때") { + test("외부 변경에 보상 정보가 있으면, Queue와 리뷰 승인을 역순으로 보상하고 저장소를 롤백합니다") { + val store = + ReviewGateStore( + snapshot( + subTaskState = SubTaskState.READY, + pullRequestState = PullRequestState.READY, + ), + failWrite = true, + ) + val review = ReviewGateReviewPort() + val queue = ReviewGateMergeQueuePort() + val compensation = ReviewGateCompensationPort() + val result = + useCase(store, review, ReviewGateIdentityPort(), queue, compensation) + .approve(ApproveGateRequest("sk-101", "cr-1", "diff-1")) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.STATE_CONFLICT + compensation.operations shouldBe listOf("queue-change", "approval-change") + store.rollbackCalls shouldBe 1 + } + } + }) + +private fun useCase( + store: ReviewGateStore, + review: ReviewGateReviewPort, + identity: ReviewGateIdentityPort = ReviewGateIdentityPort(), + queue: ReviewGateMergeQueuePort? = null, + compensation: ReviewGateCompensationPort? = null, +) = + ReviewGateUseCases( + identityPort = identity, + reviewPort = review, + storePort = store, + mergeQueuePort = queue, + compensationPort = compensation, + ) + +private fun snapshot( + subTaskState: SubTaskState = SubTaskState.READY, + pullRequestState: PullRequestState = PullRequestState.READY, + pr: PullRequest = pullRequest(pullRequestState), + subTask: SubTask = subTask(subTaskState, pullRequestState), + extraSubTasks: List = emptyList(), +) = + WorkflowStoreSnapshot( + revision = "store-1", + subTasks = listOf(subTask) + extraSubTasks, + pullRequests = listOf(pr), + ) + +private fun subTask(state: SubTaskState, pullRequestState: PullRequestState) = + SubTask( + id = "sk-101", + taskId = "task-1", + title = "change", + state = state, + pullRequestId = "pr-1", + ) + +private fun pullRequest(state: PullRequestState) = + PullRequest( + id = "pr-1", + subTaskId = "sk-101", + title = "change", + body = "review body", + base = "main", + state = state, + reviewRevision = reviewRevision(), + changeRevision = ChangeRevision("cr-1", 1, Diff("diff-1")), + ci = CiStatus.PASSED, + ) + +private fun reviewRevision(threads: List = emptyList()) = + ReviewRevision("rv-1", 1, "review body", threads = threads) + +private class ReviewGateIdentityPort( + private val current: Actor = Actor("human-1", ActorKind.HUMAN), +) : IdentityPort { + val authorizations = mutableListOf() + + override fun currentActor(request: CurrentActorRequest): PortResult = + PortResult.Success(CurrentActorResponse(current)) + + override fun authorize(request: AuthorizeRequest): PortResult { + authorizations += request + return PortResult.Success(AuthorizeResponse(allowed = true)) + } +} + +private class ReviewGateReviewPort : ReviewPort { + var readyCalls = 0 + var approveCalls = 0 + + override fun open(request: OpenReviewRequest): PortResult = error("not used") + + override fun get(request: GetReviewRequest): PortResult = error("not used") + + override fun update(request: UpdateReviewRequest): PortResult = + error("not used") + + override fun comment(request: AddReviewCommentRequest): PortResult = + error("not used") + + override fun reply(request: ReplyReviewThreadRequest): PortResult = + error("not used") + + override fun resolve( + request: ResolveReviewThreadRequest + ): PortResult = error("not used") + + override fun ready(request: ReadyReviewRequest): PortResult { + readyCalls += 1 + return PortResult.Success( + ReadyReviewResponse( + pullRequest = pullRequest(PullRequestState.READY), + change = receipt("ready-change"), + ), + ) + } + + override fun approve(request: ApproveReviewRequest): PortResult { + approveCalls += 1 + val actor = request.actor + val approval = + Approval( + id = "approval-1", + actor = actor, + changeRevisionId = request.changeRevisionId, + diffIdentity = request.diffIdentity, + ) + return PortResult.Success( + ApproveReviewResponse( + pullRequest = pullRequest(PullRequestState.APPROVED).copy(approval = approval), + approval = approval, + change = receipt("approval-change"), + ), + ) + } +} + +private class ReviewGateMergeQueuePort : MergeQueuePort { + val requests = mutableListOf() + + override fun enqueue(request: EnqueueMergeRequest): PortResult { + requests += request + return PortResult.Success( + EnqueueMergeResponse( + MergeQueueEntry( + "mq-1", + request.subTaskId, + request.pullRequestId, + request.changeRevisionId, + ), + receipt("queue-change"), + ), + ) + } + + override fun get(request: GetMergeQueueRequest): PortResult = + error("not used") + + override fun merge(request: MergeQueueMergeRequest): PortResult = + error("not used") +} + +private class ReviewGateCompensationPort : CompensationPort { + val operations = mutableListOf() + + override fun compensate(request: CompensateRequest): PortResult { + operations += request.change.id + return PortResult.Success(CompensateResponse(request.change)) + } +} + +private class ReviewGateStore( + private var current: WorkflowStoreSnapshot, + private val failWrite: Boolean = false, +) : WorkflowStorePort { + var beginCalls = 0 + var rollbackCalls = 0 + var written: WorkflowStoreSnapshot? = null + + override fun begin(request: StoreTransactionRequest): PortResult { + beginCalls += 1 + return PortResult.Success( + StoreTransactionResponse( + request.transactionId, + StoreTransactionState.OPEN, + current.revision, + ), + ) + } + + override fun snapshot(request: StoreSnapshotRequest): PortResult = + PortResult.Success(StoreSnapshotResponse(current)) + + override fun write(request: StoreWriteRequest): PortResult { + if (failWrite) return PortResult.Failure(PortError("STATE_CONFLICT", "write failed")) + written = request.snapshot + current = request.snapshot.copy(revision = "store-2") + return PortResult.Success(StoreWriteResponse("store-2")) + } + + override fun append(request: StoreEventRequest): PortResult = + error("not used") + + override fun commit(request: StoreTransactionRequest): PortResult = + PortResult.Success( + StoreTransactionResponse( + request.transactionId, + StoreTransactionState.COMMITTED, + "store-2", + ), + ) + + override fun rollback(request: StoreTransactionRequest): PortResult { + rollbackCalls += 1 + return PortResult.Success( + StoreTransactionResponse( + request.transactionId, + StoreTransactionState.ROLLED_BACK, + current.revision, + ), + ) + } +} + +private fun receipt(id: String): ChangeReceipt = + ChangeReceipt( + id = id, + operation = id, + compensation = Compensation("comp-$id", "undo-$id", "key-$id"), + ) diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/application/ReviewLifecycleUseCasesTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/ReviewLifecycleUseCasesTest.kt new file mode 100644 index 00000000..d31607a0 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/ReviewLifecycleUseCasesTest.kt @@ -0,0 +1,608 @@ +package io.springkit.workflow.application + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.AiReviewStatus +import io.springkit.workflow.domain.Approval +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.CheckResult +import io.springkit.workflow.domain.CheckSummary +import io.springkit.workflow.domain.CiRun +import io.springkit.workflow.domain.CiStatus +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.ExternalTaskId +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.ReviewOpened +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.Risk +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.ValidationStatus +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspacePath + +class ReviewLifecycleUseCasesTest : + FunSpec({ + context("Review를 처음 게시하는 상황에서") { + test("현재 fingerprint의 모든 check가 PASSED이면, 제목과 base를 만들고 Draft PR과 게이트를 저장합니다") { + val fixture = ReviewLifecycleFixture() + + val result = + fixture + .useCases() + .open( + OpenReviewLifecycleRequest( + workspaceId = "ws-101", + subTaskId = "sk-101", + body = validReviewBody(), + risk = Risk.NORMAL, + ) + ) + + val response = + result + .shouldBeInstanceOf>() + .data + .shouldBeInstanceOf() + response.pullRequest.title shouldBe "[sk-101] 변경 설명" + response.pullRequest.state shouldBe PullRequestState.DRAFT + response.pullRequest.changeRevision.diff.identity shouldBe "published-fingerprint" + fixture.publish.requests.single().commitTitle shouldBe "[sk-101] 변경 설명" + fixture.review.openRequests.single().base shouldBe "main" + fixture.ci.starts shouldBe 1 + fixture.ai.starts shouldBe 1 + fixture.store.current.pullRequests.single().id shouldBe "pr-101" + fixture.store.current.eventLog.events.single().shouldBeInstanceOf() + } + + test("현재 fingerprint와 check가 일치하지 않으면, 외부 게시 없이 CHECK 오류를 반환합니다") { + val fixture = ReviewLifecycleFixture(statusFingerprint = "changed-fingerprint") + + val result = + fixture + .useCases() + .open( + OpenReviewLifecycleRequest( + workspaceId = "ws-101", + subTaskId = "sk-101", + body = validReviewBody(), + risk = Risk.NORMAL, + ) + ) + + result.shouldBeInstanceOf().data.code shouldBe + io.springkit.workflow.domain.FailureCode.STALE_DIFF_IDENTITY + fixture.publish.requests shouldBe emptyList() + fixture.store.current.pullRequests shouldBe emptyList() + } + + test("AI Review 공급자가 없으면, Draft PR을 열고 AI Review를 PENDING으로 유지합니다") { + val fixture = ReviewLifecycleFixture(withAiReview = false) + + val result = + fixture + .useCases() + .open( + OpenReviewLifecycleRequest( + workspaceId = "ws-101", + subTaskId = "sk-101", + body = validReviewBody(), + risk = Risk.NORMAL, + ) + ) + + val response = + result.shouldBeInstanceOf>().data + response.pullRequest.aiReview shouldBe AiReviewStatus.PENDING + fixture.ai.starts shouldBe 0 + fixture.store.current.pullRequests.single().aiReview shouldBe AiReviewStatus.PENDING + } + } + + context("PR 본문 템플릿을 검증하는 상황에서") { + test("필수 제목이 없으면, 포트를 호출하지 않고 본문 오류를 SubTask 대상으로 반환합니다") { + val fixture = ReviewLifecycleFixture() + + val result = + fixture + .useCases() + .open( + OpenReviewLifecycleRequest( + workspaceId = "ws-101", + subTaskId = "sk-101", + body = "# 해결하려는 문제\n내용", + risk = Risk.NORMAL, + ) + ) + + val failure = result.shouldBeInstanceOf().data + failure.code shouldBe FailureCode.INVALID_ARGUMENT + failure.message shouldBe "PR 본문에 '왜 지금 해결해야 하는가' 제목이 없습니다." + failure.blockedBy.single().target shouldBe "sk-101" + fixture.git.inspectCalls shouldBe 0 + fixture.publish.requests shouldBe emptyList() + fixture.review.getCalls shouldBe 0 + fixture.review.openRequests shouldBe emptyList() + fixture.store.snapshotCalls shouldBe 0 + fixture.store.beginCalls shouldBe 0 + } + + test("필수 제목의 순서가 잘못되면, 포트를 호출하지 않고 제목 순서 오류를 반환합니다") { + val fixture = ReviewLifecycleFixture() + val body = + validReviewBody() + .replace("## 해결하려는 문제", "## 임시 제목") + .replace("## 왜 지금 해결해야 하는가", "## 해결하려는 문제") + .replace("## 임시 제목", "## 왜 지금 해결해야 하는가") + + val result = + fixture + .useCases() + .open( + OpenReviewLifecycleRequest( + workspaceId = "ws-101", + subTaskId = "sk-101", + body = body, + risk = Risk.NORMAL, + ) + ) + + val failure = result.shouldBeInstanceOf().data + failure.code shouldBe FailureCode.INVALID_ARGUMENT + failure.message shouldBe + "PR 본문 제목 순서가 잘못되었습니다. '해결하려는 문제' 다음에 '왜 지금 해결해야 하는가' 제목이 있어야 합니다." + failure.blockedBy.single().target shouldBe "sk-101" + fixture.git.inspectCalls shouldBe 0 + fixture.review.openRequests shouldBe emptyList() + fixture.store.snapshotCalls shouldBe 0 + } + + test("본문 갱신의 제목이 누락되면, Review 조회 전에 PR 대상으로 오류를 반환합니다") { + val fixture = ReviewLifecycleFixture(withPullRequest = true) + + val result = + fixture + .useCases() + .update( + UpdateReviewLifecycleRequest( + workspaceId = "ws-101", + subTaskId = "sk-101", + pullRequestId = "pr-101", + expectedReviewRevisionId = "rv-1", + body = "갱신된 본문", + ) + ) + + val failure = result.shouldBeInstanceOf().data + failure.code shouldBe FailureCode.INVALID_ARGUMENT + failure.message shouldBe "PR 본문에 '해결하려는 문제' 제목이 없습니다." + failure.blockedBy.single().target shouldBe "pr-101" + fixture.git.inspectCalls shouldBe 0 + fixture.review.getCalls shouldBe 0 + fixture.review.updateCalls shouldBe 0 + fixture.store.snapshotCalls shouldBe 0 + fixture.store.beginCalls shouldBe 0 + } + } + + context("Review를 갱신하는 상황에서") { + test("본문만 바뀌면, change revision과 승인과 게이트를 유지하고 review revision만 갱신합니다") { + val fixture = ReviewLifecycleFixture(withPullRequest = true) + + val result = + fixture + .useCases() + .update( + UpdateReviewLifecycleRequest( + workspaceId = "ws-101", + subTaskId = "sk-101", + pullRequestId = "pr-101", + expectedReviewRevisionId = "rv-1", + body = validReviewBody(), + ) + ) + + val response = + result + .shouldBeInstanceOf>() + .data + .shouldBeInstanceOf() + response.codeChanged shouldBe false + response.pullRequest.reviewRevision.id shouldBe "rv-2" + response.pullRequest.changeRevision.id shouldBe "cr-1" + response.pullRequest.approval?.active shouldBe true + response.pullRequest.ci shouldBe CiStatus.PASSED + response.pullRequest.aiReview shouldBe AiReviewStatus.PASSED + fixture.publish.requests shouldBe emptyList() + fixture.ci.starts shouldBe 0 + fixture.ai.starts shouldBe 0 + } + + test("fingerprint가 바뀌면, PASSED check 이후 새 change revision과 무효화된 게이트를 게시합니다") { + val fixture = + ReviewLifecycleFixture( + withPullRequest = true, + statusFingerprint = "fingerprint-2", + statusRevision = "head-2", + ) + fixture.store.current = + fixture.store.current.copy( + checks = mapOf("sk-101" to check("fingerprint-2", "head-2")) + ) + + val result = + fixture + .useCases() + .update( + UpdateReviewLifecycleRequest( + workspaceId = "ws-101", + subTaskId = "sk-101", + pullRequestId = "pr-101", + expectedReviewRevisionId = "rv-1", + ) + ) + + val response = + result + .shouldBeInstanceOf>() + .data + .shouldBeInstanceOf() + response.codeChanged shouldBe true + response.pullRequest.reviewRevision.id shouldBe "rv-2" + response.pullRequest.changeRevision.id shouldBe "cr-2" + response.pullRequest.changeRevision.diff.identity shouldBe "published-fingerprint" + response.pullRequest.approval shouldBe null + fixture.publish.requests.single().commitTitle shouldBe "[sk-101] 변경 설명" + fixture.ci.starts shouldBe 1 + fixture.ai.starts shouldBe 1 + } + + test("CI 시작이 실패하면, Store를 커밋하지 않고 외부 변경 보상을 요청합니다") { + val fixture = ReviewLifecycleFixture(ciFailure = true) + + val result = + fixture + .useCases() + .open( + OpenReviewLifecycleRequest( + workspaceId = "ws-101", + subTaskId = "sk-101", + body = validReviewBody(), + risk = Risk.NORMAL, + ) + ) + + result.shouldBeInstanceOf().data.code shouldBe + io.springkit.workflow.domain.FailureCode.EXTERNAL_FAILURE + fixture.store.rollbackCalls shouldBe 1 + fixture.store.current.pullRequests shouldBe emptyList() + fixture.compensation.calls shouldBe 2 + } + } + }) + +private class ReviewLifecycleFixture( + statusFingerprint: String = "fingerprint-1", + statusRevision: String = "head-1", + withPullRequest: Boolean = false, + private val ciFailure: Boolean = false, + private val withAiReview: Boolean = true, +) { + val store = LifecycleFakeStore(snapshot(withPullRequest)) + val workspace = LifecycleFakeWorkspacePort() + val git = LifecycleFakeGitPort(statusFingerprint, statusRevision) + val publish = LifecycleFakeGitPublishPort() + val review = LifecycleFakeReviewPort() + val ci = LifecycleFakeCiPort(ciFailure) + val ai = LifecycleFakeAiPort() + val compensation = LifecycleFakeCompensationPort() + + init { + if (withPullRequest) store.current = store.current.copy(pullRequests = listOf(pullRequest())) + } + + fun useCases() = + ReviewLifecycleUseCases( + workspacePort = workspace, + gitPort = git, + gitPublishPort = publish, + reviewPort = review, + ciPort = ci, + aiReviewPort = ai.takeIf { withAiReview }, + storePort = store, + compensationPort = compensation, + ) +} + +private fun testWorkspace(): Workspace = + Workspace("ws-101", "sk-101", WorkspacePath("workspace/sk-101"), "sk-101") + +private fun validReviewBody(): String = + listOf( + "해결하려는 문제", + "왜 지금 해결해야 하는가", + "어떻게 해결했는가", + "한계와 트레이드오프", + "기존 기능에 미치는 영향", + "Edge Case와 실패 시나리오", + "검토한 대안과 선택 이유", + "리뷰 포인트", + ) + .joinToString("\n\n") { heading -> "## $heading\n설명" } + +private fun snapshot(withPullRequest: Boolean): WorkflowStoreSnapshot = + WorkflowStoreSnapshot( + revision = "0", + sequence = + io.springkit.workflow.domain.IdSequence( + review = if (withPullRequest) 1 else 0, + change = if (withPullRequest) 1 else 0, + ), + tasks = + listOf( + io.springkit.workflow.domain.Task( + "task-101", + ExternalTaskId("TASK-101"), + "작업", + subTaskIds = listOf("sk-101"), + ) + ), + subTasks = + listOf( + SubTask( + "sk-101", + "task-101", + "변경 설명", + state = if (withPullRequest) SubTaskState.DRAFT else SubTaskState.DEVELOPMENT, + workspace = testWorkspace(), + pullRequestId = if (withPullRequest) "pr-101" else null, + ) + ), + workspaces = listOf(testWorkspace()), + checks = mapOf("sk-101" to check("fingerprint-1", "head-1")), + pullRequests = if (withPullRequest) listOf(pullRequest()) else emptyList(), + ) + +private fun check(fingerprint: String, revision: String): CheckSummary = + CheckSummary( + fingerprint = fingerprint, + revision = revision, + checks = + listOf( + CheckResult("check-test", "test", ValidationStatus.PASSED, fingerprint, revision), + CheckResult("check-build", "build", ValidationStatus.PASSED, fingerprint, revision), + ), + ) + +private fun reviewRevision(id: String, body: String): ReviewRevision = + ReviewRevision(id, id.removePrefix("rv-").toLong(), body) + +private fun pullRequest(): PullRequest = + PullRequest( + id = "pr-101", + subTaskId = "sk-101", + title = "[sk-101] 변경 설명", + body = validReviewBody(), + base = "main", + state = PullRequestState.DRAFT, + reviewRevision = reviewRevision("rv-1", validReviewBody()), + changeRevision = ChangeRevision("cr-1", 1, Diff("fingerprint-1")), + approval = + Approval("approval-1", Actor("human-1", ActorKind.HUMAN), "cr-1", "fingerprint-1"), + ci = CiStatus.PASSED, + aiReview = AiReviewStatus.PASSED, + ) + +private class LifecycleFakeWorkspacePort : WorkspacePort { + override fun get(request: WorkspaceLookupRequest): PortResult = + PortResult.Success( + WorkspaceLookupResponse( + Workspace("ws-101", "sk-101", WorkspacePath("workspace/sk-101"), "sk-101") + ) + ) + + override fun create(request: CreateWorkspaceRequest): PortResult = + unsupported() + + override fun delete(request: DeleteWorkspaceRequest): PortResult = + unsupported() +} + +private class LifecycleFakeGitPort(private val fingerprint: String, private val revision: String) : + GitPort { + var inspectCalls = 0 + + override fun inspect(request: GitInspectRequest): PortResult = + PortResult.Success(GitInspectResponse(GitStatus(revision, fingerprint, dirty = true))).also { + inspectCalls += 1 + } + + override fun refreshMain(request: MainRevisionRequest): PortResult = + unsupported() + + override fun createBranch(request: CreateBranchRequest): PortResult = + unsupported() + + override fun createWorktree(request: CreateWorktreeRequest): PortResult = + unsupported() + + override fun restack(request: RestackRequest): PortResult = unsupported() + + override fun removeBranch(request: RemoveBranchRequest): PortResult = + unsupported() +} + +private class LifecycleFakeGitPublishPort : GitPublishPort { + val requests = mutableListOf() + + override fun publish(request: PublishBranchRequest): PortResult { + requests += request + return PortResult.Success( + PublishBranchResponse( + request.branch, + "commit-2", + "published-fingerprint", + true, + receipt("publish"), + ) + ) + } +} + +private class LifecycleFakeReviewPort : ReviewPort { + val openRequests = mutableListOf() + var getCalls = 0 + var updateCalls = 0 + private var current = pullRequest() + + override fun open(request: OpenReviewRequest): PortResult { + openRequests += request + current = + PullRequest( + request.subTaskId.let { "pr-101" }, + request.subTaskId, + request.title, + request.body, + request.base, + PullRequestState.DRAFT, + request.risk, + request.reviewRevision, + request.changeRevision, + ) + return PortResult.Success(OpenReviewResponse(current, receipt("open"))) + } + + override fun get(request: GetReviewRequest): PortResult = + PortResult.Success(GetReviewResponse(current)).also { getCalls += 1 } + + override fun update(request: UpdateReviewRequest): PortResult { + updateCalls += 1 + current = + current.copy( + body = request.body ?: current.body, + reviewRevision = request.reviewRevision ?: current.reviewRevision, + changeRevision = request.changeRevision ?: current.changeRevision, + ) + return PortResult.Success( + UpdateReviewResponse(current, request.changeRevision != null, receipt("update")) + ) + } + + override fun comment(request: AddReviewCommentRequest): PortResult = + unsupported() + + override fun reply(request: ReplyReviewThreadRequest): PortResult = + unsupported() + + override fun resolve( + request: ResolveReviewThreadRequest + ): PortResult = unsupported() + + override fun ready(request: ReadyReviewRequest): PortResult = unsupported() + + override fun approve(request: ApproveReviewRequest): PortResult = + unsupported() +} + +private class LifecycleFakeCiPort(private val fail: Boolean) : CiPort { + var starts = 0 + + override fun start(request: StartCiRequest): PortResult { + starts += 1 + if (fail) return PortResult.Failure(PortError("CI_START_FAILED", "CI 시작 실패")) + return PortResult.Success( + StartCiResponse(CiRun("ci-1", CiStatus.RUNNING, revision = request.revision), receipt("ci")) + ) + } + + override fun get(request: GetCiRequest): PortResult = unsupported() +} + +private class LifecycleFakeAiPort : AiReviewPort { + var starts = 0 + + override fun start(request: StartAiReviewRequest): PortResult { + starts += 1 + return PortResult.Success(StartAiReviewResponse(AiReviewStatus.RUNNING, receipt("ai"))) + } + + override fun get(request: GetAiReviewRequest): PortResult = unsupported() +} + +private class LifecycleFakeCompensationPort : CompensationPort { + var calls = 0 + + override fun compensate(request: CompensateRequest): PortResult { + calls += 1 + return PortResult.Success(CompensateResponse(request.change)) + } +} + +private class LifecycleFakeStore(initial: WorkflowStoreSnapshot) : WorkflowStorePort { + var current = initial + var rollbackCalls = 0 + var snapshotCalls = 0 + var beginCalls = 0 + private var pending: WorkflowStoreSnapshot? = null + + override fun snapshot(request: StoreSnapshotRequest): PortResult = + PortResult.Success(StoreSnapshotResponse(current)).also { snapshotCalls += 1 } + + override fun begin(request: StoreTransactionRequest): PortResult { + beginCalls += 1 + return PortResult.Success( + StoreTransactionResponse( + request.transactionId, + StoreTransactionState.OPEN, + current.revision, + ) + ) + } + + override fun write(request: StoreWriteRequest): PortResult { + pending = request.snapshot.copy(revision = (current.revision.toLong() + 1).toString()) + return PortResult.Success(StoreWriteResponse(checkNotNull(pending).revision)) + } + + override fun append(request: StoreEventRequest): PortResult { + val base = pending ?: current + pending = base.copy(eventLog = base.eventLog.append(request.event, request.audit)) + return PortResult.Success(StoreEventResponse(request.event, checkNotNull(pending).revision)) + } + + override fun commit(request: StoreTransactionRequest): PortResult { + current = pending ?: current + pending = null + return PortResult.Success( + StoreTransactionResponse( + request.transactionId, + StoreTransactionState.COMMITTED, + current.revision, + ) + ) + } + + override fun rollback(request: StoreTransactionRequest): PortResult { + rollbackCalls += 1 + pending = null + return PortResult.Success( + StoreTransactionResponse( + request.transactionId, + StoreTransactionState.ROLLED_BACK, + current.revision, + ) + ) + } +} + +private fun receipt(operation: String): ChangeReceipt = + ChangeReceipt("change-$operation", operation) + +private fun unsupported(): PortResult = + PortResult.Failure(PortError("UNSUPPORTED", "테스트에서 지원하지 않는 동작입니다.")) diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/application/ReviewUseCasesTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/ReviewUseCasesTest.kt new file mode 100644 index 00000000..9deee805 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/ReviewUseCasesTest.kt @@ -0,0 +1,546 @@ +package io.springkit.workflow.application + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.adapter.store.OkioWorkflowStoreAdapter +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.AiReviewStatus +import io.springkit.workflow.domain.Approval +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.CiStatus +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.ReviewComment +import io.springkit.workflow.domain.ReviewLevel +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.ReviewThread +import io.springkit.workflow.domain.Risk +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.ThreadState +import io.springkit.workflow.domain.WorkflowResult +import okio.Path.Companion.toPath +import okio.fakefilesystem.FakeFileSystem + +class ReviewUseCasesTest : + FunSpec({ + context("리뷰 본문을 수정하는 상황에서") { + test("코드가 바뀌지 않으면, 기존 코드 게이트를 유지합니다") { + val before = pullRequest() + val after = + before.copy( + body = "new body", + reviewRevision = revision("rv-2", "new body"), + ) + val port = + FakeReviewPort(before, UpdateReviewResponse(after, codeChanged = false, receipt())) + + val result = + ReviewUseCases(port) + .update( + UpdateReviewRequest( + pullRequestId = before.id, + expectedReviewRevisionId = "rv-1", + body = "new body", + ), + ) + + val response = + result.shouldBeInstanceOf>().data + response.pullRequest.changeRevision.id shouldBe "cr-1" + response.pullRequest.ci shouldBe CiStatus.PASSED + response.pullRequest.aiReview shouldBe AiReviewStatus.PASSED + response.pullRequest.approval?.active shouldBe true + } + + test("코드가 바뀌면, 새 변경 리비전을 만들고 게이트를 무효화합니다") { + val before = pullRequest() + val after = + before.copy( + reviewRevision = revision("rv-2", "same body"), + changeRevision = ChangeRevision("cr-2", 2, Diff("diff-2")), + ) + val port = + FakeReviewPort(before, UpdateReviewResponse(after, codeChanged = true, receipt())) + + val result = + ReviewUseCases(port) + .update( + UpdateReviewRequest( + pullRequestId = before.id, + expectedReviewRevisionId = "rv-1", + changeRevision = after.changeRevision, + ), + ) + + val response = + result.shouldBeInstanceOf>().data + response.pullRequest.changeRevision.id shouldBe "cr-2" + response.pullRequest.ci shouldBe CiStatus.PENDING + response.pullRequest.aiReview shouldBe AiReviewStatus.PENDING + response.pullRequest.approval shouldBe null + } + + test("오래된 리비전을 지정하면, 제공자 갱신 전에 오래된 리비전 오류로 거부합니다") { + val before = pullRequest() + val port = FakeReviewPort(before, null) + + val result = + ReviewUseCases(port) + .update( + UpdateReviewRequest( + pullRequestId = before.id, + expectedReviewRevisionId = "rv-old", + body = "new body", + ), + ) + + result.shouldBeInstanceOf().data.code shouldBe + io.springkit.workflow.domain.FailureCode.STALE_REVISION + port.updateCalls shouldBe 0 + } + } + + context("리뷰 댓글을 등록하는 상황에서") { + test("에이전트 댓글을 등록하면, 게시 전에 댓글 본문에 [Agent]를 표시합니다") { + val before = pullRequest() + val port = FakeReviewPort(before, null) + val actor = Actor("agent-1", ActorKind.AGENT) + val comment = ReviewComment("comment-1", actor, "확인이 필요합니다.") + + ReviewUseCases(port) + .comment(AddReviewCommentRequest("pr-1", "rv-1", actor, ReviewLevel.R, comment)) + + port.lastComment?.comment?.body shouldBe "[Agent] 확인이 필요합니다." + } + } + + context("리뷰 스레드를 해결하는 상황에서") { + test("사람 댓글이 포함된 스레드를 에이전트가 해결하면, 사람 확인 필요 오류로 거부합니다") { + val human = Actor("human-1", ActorKind.HUMAN) + val thread = + ReviewThread( + id = "thread-1", + level = ReviewLevel.R, + comments = listOf(ReviewComment("comment-1", human, "수정이 필요합니다.")), + ) + val before = + pullRequest() + .copy(reviewRevision = revision("rv-1", "body").copy(threads = listOf(thread))) + val port = FakeReviewPort(before, null) + + val result = + ReviewUseCases(port) + .resolve( + ResolveReviewThreadRequest( + "pr-1", + "rv-1", + "thread-1", + Actor("agent-1", ActorKind.AGENT), + ), + ) + + result.shouldBeInstanceOf().data.code shouldBe + io.springkit.workflow.domain.FailureCode.HUMAN_REQUIRED + port.resolveCalls shouldBe 0 + } + } + + context("리뷰 댓글, 답글, 해결 결과를 Store에 반영하는 상황에서") { + test("댓글, 답글, 해결 결과를 저장하면, 각 변경 결과를 원자적으로 저장해 다음 작업이 새 revision을 읽습니다") { + val before = + pullRequest() + .copy( + reviewRevision = + revision("rv-1", "body") + .copy( + threads = + listOf( + ReviewThread( + id = "thread-1", + level = ReviewLevel.R, + comments = + listOf( + ReviewComment( + "comment-1", + Actor("agent-1", ActorKind.AGENT), + "기존 의견", + ) + ), + ) + ) + ) + ) + val commentRevision = + revision("rv-2", "body") + .copy( + threads = + before.reviewRevision.threads + + ReviewThread( + id = "thread-2", + level = ReviewLevel.C, + comments = + listOf( + ReviewComment( + "comment-2", + Actor("agent-1", ActorKind.AGENT), + "[Agent] 새 의견", + ) + ), + ) + ) + val replyRevision = + revision("rv-3", "body") + .copy( + threads = + commentRevision.threads.map { thread -> + if (thread.id == "thread-1") { + thread.reply( + ReviewComment( + "comment-3", + Actor("agent-1", ActorKind.AGENT), + "[Agent] 답변", + ) + ) + } else { + thread + } + } + ) + val resolveRevision = + revision("rv-4", "body") + .copy( + threads = + replyRevision.threads.map { thread -> + if (thread.id == "thread-1") thread.copy(state = ThreadState.RESOLVED) + else thread + } + ) + val store = OkioWorkflowStoreAdapter(FakeFileSystem(), "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/workflow/state.json".toPath()) + seed(store, before) + val port = + RevisionReviewPort( + before, + AddReviewCommentResponse(commentRevision, "thread-2", receipt("comment")), + ReplyReviewThreadResponse(replyRevision, receipt("reply")), + ResolveReviewThreadResponse(resolveRevision, receipt("resolve")), + ) + val useCases = ReviewUseCases(port, storePort = store) + val actor = Actor("agent-1", ActorKind.AGENT) + + useCases.comment( + AddReviewCommentRequest( + "pr-1", + "rv-1", + actor, + ReviewLevel.C, + ReviewComment("comment-2", actor, "새 의견"), + ) + ) + storedPullRequest(store).reviewRevision.id shouldBe "rv-2" + + useCases.reply( + ReplyReviewThreadRequest( + "pr-1", + "rv-2", + "thread-1", + ReviewComment("comment-3", actor, "답변"), + ) + ) + storedPullRequest(store).reviewRevision.id shouldBe "rv-3" + + useCases.resolve(ResolveReviewThreadRequest("pr-1", "rv-3", "thread-1", actor)) + val stored = storedPullRequest(store) + stored.reviewRevision.id shouldBe "rv-4" + stored.reviewRevision.threads.first { it.id == "thread-1" }.state shouldBe + ThreadState.RESOLVED + port.getCalls shouldBe 3 + } + + test("Store의 revision이 바뀌면, 외부 Review 변경 없이 충돌을 반환합니다") { + val providerPullRequest = pullRequest() + val storePullRequest = + providerPullRequest.copy(reviewRevision = ReviewRevision("rv-store", 99, "body")) + val store = OkioWorkflowStoreAdapter(FakeFileSystem(), "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/workflow/state.json".toPath()) + seed(store, storePullRequest) + val port = + RevisionReviewPort( + providerPullRequest, + AddReviewCommentResponse( + revision("rv-2", "body"), + "thread-2", + receipt("comment"), + ), + null, + null, + ) + + val result = + ReviewUseCases(port, storePort = store) + .comment( + AddReviewCommentRequest( + "pr-1", + "rv-1", + Actor("agent-1", ActorKind.AGENT), + ReviewLevel.R, + ReviewComment( + "comment-2", + Actor("agent-1", ActorKind.AGENT), + "의견", + ), + ) + ) + + result.shouldBeInstanceOf().data.code shouldBe + io.springkit.workflow.domain.FailureCode.STALE_REVISION + port.commentCalls shouldBe 0 + } + + test("같은 답글만 원격 revision에 추가됐으면, 재시도 결과를 Store에 복구합니다") { + val actor = Actor("agent-1", ActorKind.AGENT) + val thread = + ReviewThread( + id = "thread-1", + level = ReviewLevel.R, + comments = + listOf(ReviewComment("root-1", Actor("human-1", ActorKind.HUMAN), "요청")), + ) + val before = + pullRequest() + .copy( + reviewRevision = revision("rv-1", "body").copy(threads = listOf(thread)), + ) + val repliedRevision = + revision("rv-2", "body") + .copy( + threads = listOf(thread.reply(ReviewComment("reply-1", actor, "[Agent] 답변"))), + ) + val remote = before.copy(reviewRevision = repliedRevision) + val store = OkioWorkflowStoreAdapter(FakeFileSystem(), "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/workflow/retry.json".toPath()) + seed(store, before) + val port = + FakeReviewPort( + remote, + null, + recoveredReply = ReplyReviewThreadResponse(repliedRevision, receipt("reply")), + ) + + val result = + ReviewUseCases(port, storePort = store) + .reply( + ReplyReviewThreadRequest( + "pr-1", + "rv-1", + "thread-1", + ReviewComment("reply-1", actor, "답변"), + ), + ) + + result.shouldBeInstanceOf>() + storedPullRequest(store).reviewRevision shouldBe repliedRevision + port.recoverCalls shouldBe 1 + port.replyCalls shouldBe 0 + } + + test("다른 원격 변경이 섞였으면, 답글 재시도 복구를 거부합니다") { + val actor = Actor("agent-1", ActorKind.AGENT) + val current = pullRequest().copy(reviewRevision = revision("rv-2", "body changed")) + val store = + OkioWorkflowStoreAdapter(FakeFileSystem(), "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/workflow/stale-retry.json".toPath()) + seed(store, pullRequest()) + val port = FakeReviewPort(current, null) + + val result = + ReviewUseCases(port, storePort = store) + .reply( + ReplyReviewThreadRequest( + "pr-1", + "rv-1", + "thread-1", + ReviewComment("reply-1", actor, "답변"), + ), + ) + + result.shouldBeInstanceOf().data.code shouldBe + io.springkit.workflow.domain.FailureCode.STALE_REVISION + storedPullRequest(store).reviewRevision.id shouldBe "rv-1" + port.replyCalls shouldBe 0 + } + } + }) + +private fun pullRequest(): PullRequest = + PullRequest( + id = "pr-1", + subTaskId = "sk-27", + title = "Review", + body = "body", + base = "main", + state = PullRequestState.REVIEW, + risk = Risk.NORMAL, + reviewRevision = revision("rv-1", "body"), + changeRevision = ChangeRevision("cr-1", 1, Diff("diff-1")), + approval = + Approval( + id = "approval-1", + actor = Actor("human-1", ActorKind.HUMAN), + changeRevisionId = "cr-1", + diffIdentity = "diff-1", + ), + ci = CiStatus.PASSED, + aiReview = AiReviewStatus.PASSED, + ) + +private fun revision(id: String, body: String): ReviewRevision = + ReviewRevision(id = id, number = id.removePrefix("rv-").toLong(), body = body) + +private fun receipt(operation: String = "update"): ChangeReceipt = + ChangeReceipt("change-$operation", "review-$operation") + +private fun seed(store: WorkflowStorePort, pullRequest: PullRequest) { + val request = StoreTransactionRequest("seed", expectedRevision = "0", idempotencyKey = "seed") + store.begin(request) + store.write( + StoreWriteRequest( + transactionId = request.transactionId, + expectedRevision = "0", + snapshot = + WorkflowStoreSnapshot( + "0", + subTasks = listOf(SubTask("sk-27", "task-1", "review")), + pullRequests = listOf(pullRequest), + ), + ) + ) + store.commit(request) +} + +private fun storedPullRequest(store: WorkflowStorePort): PullRequest = + (store.snapshot(StoreSnapshotRequest()).shouldBeInstanceOf>().value + as StoreSnapshotResponse) + .snapshot + .pullRequests + .single() + +private class RevisionReviewPort( + private var current: PullRequest, + private val commentResponse: AddReviewCommentResponse?, + private val replyResponse: ReplyReviewThreadResponse?, + private val resolveResponse: ResolveReviewThreadResponse?, +) : ReviewPort { + var getCalls: Int = 0 + var commentCalls: Int = 0 + + override fun open(request: OpenReviewRequest): PortResult = unsupported() + + override fun get(request: GetReviewRequest): PortResult { + getCalls += 1 + return PortResult.Success(GetReviewResponse(current)) + } + + override fun update(request: UpdateReviewRequest): PortResult = + unsupported() + + override fun comment(request: AddReviewCommentRequest): PortResult { + commentCalls += 1 + val response = commentResponse ?: return unsupported() + current = current.copy(reviewRevision = response.reviewRevision) + return PortResult.Success(response) + } + + override fun reply(request: ReplyReviewThreadRequest): PortResult { + val response = replyResponse ?: return unsupported() + current = current.copy(reviewRevision = response.reviewRevision) + return PortResult.Success(response) + } + + override fun resolve( + request: ResolveReviewThreadRequest, + ): PortResult { + val response = resolveResponse ?: return unsupported() + current = current.copy(reviewRevision = response.reviewRevision) + return PortResult.Success(response) + } + + override fun ready(request: ReadyReviewRequest): PortResult = unsupported() + + override fun approve(request: ApproveReviewRequest): PortResult = + unsupported() + + private fun unsupported(): PortResult = + PortResult.Failure(PortError("STATE_CONFLICT", "unsupported in test")) +} + +private class FakeReviewPort( + private var current: PullRequest, + private val updateResponse: UpdateReviewResponse?, + private val recoveredReply: ReplyReviewThreadResponse? = null, +) : ReviewPort { + var updateCalls: Int = 0 + var resolveCalls: Int = 0 + var lastComment: AddReviewCommentRequest? = null + var recoverCalls: Int = 0 + var replyCalls: Int = 0 + + override fun open(request: OpenReviewRequest): PortResult = unsupported() + + override fun get(request: GetReviewRequest): PortResult = + PortResult.Success(GetReviewResponse(current)) + + override fun update(request: UpdateReviewRequest): PortResult { + updateCalls += 1 + val response = updateResponse ?: return unsupported() + current = response.pullRequest + return PortResult.Success(response) + } + + override fun comment(request: AddReviewCommentRequest): PortResult { + lastComment = request + return PortResult.Success( + AddReviewCommentResponse( + current.reviewRevision, + "thread-1", + ChangeReceipt("change-comment", "review-comment"), + ) + ) + } + + override fun reply(request: ReplyReviewThreadRequest): PortResult { + replyCalls += 1 + return unsupported() + } + + override fun recoverReply( + request: ReplyReviewThreadRequest, + ): PortResult { + recoverCalls += 1 + return PortResult.Success(recoveredReply) + } + + override fun resolve( + request: ResolveReviewThreadRequest, + ): PortResult { + resolveCalls += 1 + return PortResult.Success( + ResolveReviewThreadResponse( + current.reviewRevision.copy( + threads = + current.reviewRevision.threads.map { + if (it.id == request.threadId) it.copy(state = ThreadState.RESOLVED) else it + } + ), + ChangeReceipt("change-resolve", "review-resolve"), + ) + ) + } + + override fun ready(request: ReadyReviewRequest): PortResult = unsupported() + + override fun approve(request: ApproveReviewRequest): PortResult = + unsupported() + + private fun unsupported(): PortResult = + PortResult.Failure(PortError("STATE_CONFLICT", "unsupported in test")) +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/application/StackSyncUseCasesTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/StackSyncUseCasesTest.kt new file mode 100644 index 00000000..9e82dd61 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/StackSyncUseCasesTest.kt @@ -0,0 +1,295 @@ +package io.springkit.workflow.application + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.domain.Dependency +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.SyncConflict +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspacePath + +class StackSyncUseCasesTest : + FunSpec({ + context("상위 작업에 쌓는 상황에서") { + test("직접 의존성을 지정하면, 상위 작업 리비전을 기준으로 기록합니다") { + val parent = subTask("sk-parent") + val child = subTask("sk-child") + val store = + RecordingStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(parent, child))) + val git = RecordingGit() + val useCase = StackSyncUseCases(git, NoopReviewPort(), store) + + val result = useCase.stack(StackRequest(child.id, requires = parent.id)) + + val response = result.shouldBeInstanceOf>().data + response.baseBranch shouldBe parent.branch + response.baseRevision shouldBe "parent-revision" + response.subTask.requires shouldBe parent.id + git.lastRestack?.baseBranch shouldBe parent.branch + git.lastRestack?.baseRevision shouldBe "parent-revision" + store.written?.dependencies shouldBe listOf(Dependency(child.id, parent.id)) + } + + test("의존성 선택지를 모두 생략하거나 함께 지정하면, 잘못된 인자로 거부합니다") { + val child = subTask("sk-child") + val store = RecordingStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(child))) + val useCase = StackSyncUseCases(RecordingGit(), NoopReviewPort(), store) + + val missing = useCase.stack(StackRequest(child.id)) + val both = useCase.stack(StackRequest(child.id, requires = "sk-parent", clear = true)) + + missing.shouldBeInstanceOf().data.code shouldBe + FailureCode.INVALID_ARGUMENT + both.shouldBeInstanceOf().data.code shouldBe + FailureCode.INVALID_ARGUMENT + } + } + + context("동기화하는 상황에서") { + test("병합된 상위 작업의 의존성을 동기화하면, 기준을 main으로 되돌립니다") { + val parent = subTask("sk-parent", state = SubTaskState.MERGED) + val child = subTask("sk-child", requires = parent.id) + val store = + RecordingStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(parent, child), + dependencies = listOf(Dependency(child.id, parent.id)), + ), + ) + val git = RecordingGit() + val useCase = StackSyncUseCases(git, NoopReviewPort(), store) + + val result = useCase.sync(SyncRequest(child.id)) + + val response = result.shouldBeInstanceOf>().data + response.baseBranch shouldBe "main" + response.baseRevision shouldBe "main-revision" + response.subTask.requires shouldBe null + store.written?.subTasks?.single { it.id == child.id }?.requires shouldBe null + store.written?.dependencies.orEmpty() shouldBe emptyList() + git.lastRestack?.baseBranch shouldBe "main" + } + + test("동기화 충돌 상태에서 프로세스를 재시작하면, 저장된 복구 상태로 두 번째 자동 동기화를 막습니다") { + val child = subTask("sk-child") + val store = RecordingStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(child))) + val git = + RecordingGit( + RestackResponse( + revision = "child-revision", + fingerprint = "child-fingerprint", + diffChanged = false, + conflicts = listOf("src/main.kt"), + change = ChangeReceipt("restack-1", "restack"), + ), + ) + val useCase = StackSyncUseCases(git, NoopReviewPort(), store) + + val first = useCase.sync(SyncRequest(child.id)) + val restarted = StackSyncUseCases(git, NoopReviewPort(), store) + val second = restarted.sync(SyncRequest(child.id)) + + val firstFailure = first.shouldBeInstanceOf() + firstFailure.data.code shouldBe FailureCode.SYNC_CONFLICT + firstFailure.data.workspace?.path?.value shouldBe "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/managed/sk-child" + firstFailure.data.conflicts.map { it.path to it.kind } shouldBe + listOf("src/main.kt" to "content") + second.shouldBeInstanceOf().data.code shouldBe + FailureCode.SYNC_CONFLICT + git.restackCalls shouldBe 1 + store.current.syncConflicts.keys shouldBe setOf(child.id) + } + + test("충돌이 해결된 상태에서 sync --continue를 실행하면, rebase continue 후 충돌 상태를 제거합니다") { + val child = subTask("sk-child") + val conflict = SyncConflict(child.id, child, listOf("src/main.kt"), 21) + val store = + RecordingStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(child), + syncConflicts = mapOf(child.id to conflict), + ), + ) + val git = RecordingGit() + val useCase = StackSyncUseCases(git, NoopReviewPort(), store) + + val result = useCase.sync(SyncRequest(child.id, continueSync = true)) + + result.shouldBeInstanceOf>() + git.continueCalls shouldBe 1 + git.restackCalls shouldBe 0 + store.current.syncConflicts shouldBe emptyMap() + } + + test("활성 충돌 상태에서 sync --abort를 실행하면, rebase abort 후 충돌 상태를 제거합니다") { + val child = subTask("sk-child") + val conflict = SyncConflict(child.id, child, listOf("src/main.kt"), 21) + val store = + RecordingStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(child), + syncConflicts = mapOf(child.id to conflict), + ), + ) + val git = RecordingGit() + val useCase = StackSyncUseCases(git, NoopReviewPort(), store) + + val result = useCase.sync(SyncRequest(child.id, abort = true)) + + result.shouldBeInstanceOf>() + git.abortCalls shouldBe 1 + store.current.syncConflicts shouldBe emptyMap() + } + + test("동기화 취소 전에 기준 revision을 확인하지 못하면, rebase abort와 충돌 상태 변경을 수행하지 않습니다") { + val child = subTask("sk-child") + val conflict = SyncConflict(child.id, child, listOf("src/main.kt"), 21) + val store = + RecordingStore( + WorkflowStoreSnapshot( + "store-1", + subTasks = listOf(child), + syncConflicts = mapOf(child.id to conflict), + ), + ) + val git = + RecordingGit( + refreshMainResult = + PortResult.Failure(PortError("REMOTE_FAILURE", "main 조회에 실패했습니다.")) + ) + val useCase = StackSyncUseCases(git, NoopReviewPort(), store) + + val result = useCase.sync(SyncRequest(child.id, abort = true)) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.EXTERNAL_FAILURE + git.abortCalls shouldBe 0 + store.current.syncConflicts.keys shouldBe setOf(child.id) + } + } + }) + +private fun subTask( + id: String, + state: SubTaskState = SubTaskState.DEVELOPMENT, + requires: String? = null, +): SubTask = + SubTask( + id = id, + taskId = "task-1", + title = id, + state = state, + workspace = Workspace("ws-$id", id, WorkspacePath("/managed/$id"), id), + requires = requires, + ) + +private class RecordingGit( + var restackResponse: RestackResponse = + RestackResponse( + revision = "child-revision", + fingerprint = "child-fingerprint", + diffChanged = false, + change = ChangeReceipt("restack-1", "restack"), + ), + var refreshMainResult: PortResult = + PortResult.Success(MainRevisionResponse("main-revision")), +) : GitPort { + var restackCalls = 0 + var continueCalls = 0 + var abortCalls = 0 + var lastRestack: RestackRequest? = null + + override fun refreshMain(request: MainRevisionRequest) = refreshMainResult + + override fun inspect(request: GitInspectRequest) = + PortResult.Success( + GitInspectResponse( + GitStatus( + revision = + if (request.workspaceId.contains("parent")) "parent-revision" + else "child-revision", + fingerprint = "fingerprint-${request.workspaceId}", + dirty = false, + ), + ), + ) + + override fun createBranch(request: CreateBranchRequest) = error("not used") + + override fun createWorktree(request: CreateWorktreeRequest) = error("not used") + + override fun restack(request: RestackRequest): PortResult { + restackCalls += 1 + lastRestack = request + return PortResult.Success(restackResponse) + } + + override fun continueRestack( + request: ContinueRestackRequest + ): PortResult { + continueCalls += 1 + return PortResult.Success(ContinueRestackResponse(ChangeReceipt("continue-1", "continue"))) + } + + override fun abortRestack(request: AbortRestackRequest): PortResult { + abortCalls += 1 + return PortResult.Success(AbortRestackResponse(ChangeReceipt("abort-1", "abort"))) + } + + override fun removeBranch(request: RemoveBranchRequest) = error("not used") +} + +private class RecordingStore(var current: WorkflowStoreSnapshot) : WorkflowStorePort { + var written: WorkflowStoreSnapshot? = null + + override fun snapshot(request: StoreSnapshotRequest) = + PortResult.Success(StoreSnapshotResponse(current)) + + override fun begin(request: StoreTransactionRequest) = + PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.OPEN), + ) + + override fun commit(request: StoreTransactionRequest) = + PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.COMMITTED), + ) + + override fun rollback(request: StoreTransactionRequest) = + PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.ROLLED_BACK), + ) + + override fun write(request: StoreWriteRequest): PortResult { + written = request.snapshot + current = request.snapshot + return PortResult.Success(StoreWriteResponse("store-2")) + } + + override fun append(request: StoreEventRequest) = error("not used") +} + +private class NoopReviewPort : ReviewPort { + override fun open(request: OpenReviewRequest) = error("not used") + + override fun get(request: GetReviewRequest) = error("not used") + + override fun update(request: UpdateReviewRequest) = error("not used") + + override fun comment(request: AddReviewCommentRequest) = error("not used") + + override fun reply(request: ReplyReviewThreadRequest) = error("not used") + + override fun resolve(request: ResolveReviewThreadRequest) = error("not used") + + override fun ready(request: ReadyReviewRequest) = error("not used") + + override fun approve(request: ApproveReviewRequest) = error("not used") +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/application/StartCheckUseCasesTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/StartCheckUseCasesTest.kt new file mode 100644 index 00000000..ca56361a --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/StartCheckUseCasesTest.kt @@ -0,0 +1,710 @@ +package io.springkit.workflow.application + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.domain.CheckResult +import io.springkit.workflow.domain.CheckSummary +import io.springkit.workflow.domain.ExternalTaskId +import io.springkit.workflow.domain.IdSequence +import io.springkit.workflow.domain.StartRequestKey +import io.springkit.workflow.domain.StartRequestRecord +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.Task +import io.springkit.workflow.domain.ValidationStatus +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspacePath + +class StartCheckUseCasesTest : + FunSpec({ + context("Worktree 변경을 검증할 때") { + test("현재 revision과 fingerprint를 검증하면, 게시 가능한 결과를 저장합니다") { + val workspace = Workspace("ws-1", "sk-101", WorkspacePath("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/managed/sk-101"), "sk-101") + val subTask = SubTask("sk-101", "task-1", "change") + val git = FakeGit(GitStatus("commit-1", "fingerprint-1", dirty = true)) + val validations = RecordingValidationPort() + val store = FakeStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(subTask))) + val useCase = + CheckUseCase( + FakeWorkspace(workspace), + git, + store, + validations, + ) + + val result = useCase.execute(CheckRequest(workspaceId = workspace.id)) + val success = result.shouldBeInstanceOf>() + + validations.requiredNames shouldBe listOf("test", "build", "static") + validations.request?.revision shouldBe "commit-1" + validations.request?.fingerprint shouldBe "fingerprint-1" + success.data.publishable shouldBe true + success.data.summary.fingerprint shouldBe "fingerprint-1" + store.written?.checks?.get("sk-101") shouldBe success.data.summary + } + + test("ValidationPort summary에서 static 검사가 빠지면, 불변식 오류로 거부합니다") { + val workspace = Workspace("ws-1", "sk-101", WorkspacePath("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/managed/sk-101"), "sk-101") + val subTask = SubTask("sk-101", "task-1", "change") + val incompleteSummary = + CheckSummary( + fingerprint = "fingerprint-1", + revision = "commit-1", + checks = + listOf("test", "build").map { id -> + CheckResult( + id = id, + name = id, + status = ValidationStatus.PASSED, + fingerprint = "fingerprint-1", + revision = "commit-1", + ) + }, + ) + val store = FakeStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(subTask))) + val useCase = + CheckUseCase( + FakeWorkspace(workspace), + FakeGit(GitStatus("commit-1", "fingerprint-1", dirty = true)), + store, + RecordingValidationPort(summary = incompleteSummary), + ) + + val result = useCase.execute(CheckRequest(workspaceId = workspace.id)) + + result.shouldBeInstanceOf().data.code shouldBe + io.springkit.workflow.domain.FailureCode.INVARIANT_VIOLATION + store.written shouldBe null + } + + test("필수 static 검사가 FAILED이면, 결과를 저장하고 게시 불가로 반환합니다") { + val workspace = Workspace("ws-1", "sk-101", WorkspacePath("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/managed/sk-101"), "sk-101") + val subTask = SubTask("sk-101", "task-1", "change") + val failedSummary = + CheckSummary( + fingerprint = "fingerprint-1", + revision = "commit-1", + checks = + listOf("test", "build", "static").map { id -> + val failed = id == "static" + CheckResult( + id = id, + name = id, + status = + if (failed) ValidationStatus.FAILED else ValidationStatus.PASSED, + fingerprint = "fingerprint-1", + revision = "commit-1", + message = if (failed) "formatting failed" else null, + ) + }, + ) + val store = FakeStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(subTask))) + val useCase = + CheckUseCase( + FakeWorkspace(workspace), + FakeGit(GitStatus("commit-1", "fingerprint-1", dirty = true)), + store, + RecordingValidationPort(summary = failedSummary), + ) + + val response = + useCase + .execute(CheckRequest(workspaceId = workspace.id)) + .shouldBeInstanceOf>() + .data + + response.publishable shouldBe false + store.written?.checks?.get("sk-101") shouldBe failedSummary + } + + test("다른 revision의 검증 결과가 반환되면, 오래된 검증 결과로 거절합니다") { + val workspace = Workspace("ws-1", "sk-101", WorkspacePath("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/managed/sk-101"), "sk-101") + val subTask = SubTask("sk-101", "task-1", "change") + val validations = + RecordingValidationPort( + summary = + CheckSummary( + fingerprint = "old-fingerprint", + revision = "old-revision", + checks = emptyList(), + ), + ) + val useCase = + CheckUseCase( + FakeWorkspace(workspace), + FakeGit(GitStatus("commit-1", "fingerprint-1", dirty = false)), + FakeStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(subTask))), + validations, + ) + + val result = useCase.execute(CheckRequest(workspaceId = workspace.id)) + val failure = result.shouldBeInstanceOf() + + failure.data.code shouldBe io.springkit.workflow.domain.FailureCode.STALE_REVISION + failure.data.next.map { it.action } shouldNotContain "open_review" + } + } + + context("원격 게시 가능성을 확인할 때") { + test("필수 검증이 실패하면 원격 dry-run을 호출하지 않습니다") { + val workspace = Workspace("ws-1", "sk-101", WorkspacePath("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/managed/sk-101"), "sk-101") + val subTask = SubTask("sk-101", "task-1", "change") + val failedSummary = + CheckSummary( + fingerprint = "fingerprint-1", + revision = "commit-1", + checks = + listOf("test", "build", "static").map { id -> + CheckResult( + id = id, + name = id, + status = ValidationStatus.FAILED, + fingerprint = "fingerprint-1", + revision = "commit-1", + message = "검증 실패", + ) + }, + ) + val git = FakeGit(GitStatus("commit-1", "fingerprint-1", dirty = false)) + val useCase = + CheckUseCase( + FakeWorkspace(workspace), + git, + FakeStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(subTask))), + RecordingValidationPort(summary = failedSummary), + ) + + val response = + useCase + .execute(CheckRequest(workspaceId = workspace.id)) + .shouldBeInstanceOf>() + .data + + response.publishable shouldBe false + git.publishCheckRequest shouldBe null + } + + test("검증이 통과해도 push dry-run이 거부하면 게시 불가를 반환합니다") { + val workspace = Workspace("ws-1", "sk-101", WorkspacePath("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/managed/sk-101"), "sk-101") + val subTask = SubTask("sk-101", "task-1", "change") + val git = + FakeGit( + GitStatus("commit-1", "fingerprint-1", dirty = false), + PortResult.Success( + CheckRemotePushResponse( + publishable = false, + message = "remote rejected update", + ) + ), + ) + val useCase = + CheckUseCase( + FakeWorkspace(workspace), + git, + FakeStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(subTask))), + RecordingValidationPort(), + ) + + val response = + useCase + .execute(CheckRequest(workspaceId = workspace.id)) + .shouldBeInstanceOf>() + .data + + response.publishable shouldBe false + response.publishabilityMessage shouldBe "remote rejected update" + git.publishCheckRequest shouldBe CheckRemotePushRequest(workspace.id, subTask.branch) + } + + test("dry-run을 실행할 수 없으면 게시 불가와 실패 원인을 반환합니다") { + val workspace = Workspace("ws-1", "sk-101", WorkspacePath("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/managed/sk-101"), "sk-101") + val subTask = SubTask("sk-101", "task-1", "change") + val git = + FakeGit( + GitStatus("commit-1", "fingerprint-1", dirty = false), + PortResult.Failure(PortError("GIT_COMMAND_FAILED", "network unavailable")), + ) + val useCase = + CheckUseCase( + FakeWorkspace(workspace), + git, + FakeStore(WorkflowStoreSnapshot("store-1", subTasks = listOf(subTask))), + RecordingValidationPort(), + ) + + val response = + useCase + .execute(CheckRequest(workspaceId = workspace.id)) + .shouldBeInstanceOf>() + .data + + response.publishable shouldBe false + response.publishabilityMessage shouldBe "network unavailable" + } + } + + context("SubTask 작업을 시작할 때") { + test("같은 요청을 다시 실행하면, 기존 Workspace를 반환합니다") { + val workspace = Workspace("ws-1", "sk-101", WorkspacePath("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/managed/sk-101"), "sk-101") + val existing = SubTask("sk-101", "task-1", "change", workspace = workspace) + val task = + Task("task-1", ExternalTaskId("TASK-1"), "task", subTaskIds = listOf(existing.id)) + val key = StartRequestKey("TASK-1", "request-1") + val taskPort = ExistingTaskPort(task, existing) + val store = + RecordingStartStore( + WorkflowStoreSnapshot( + "store-1", + tasks = listOf(task), + subTasks = listOf(existing), + workspaces = listOf(workspace), + startRequests = + mapOf( + key to + StartRequestRecord( + key = key, + subTaskId = existing.id, + title = "change", + ), + ), + ), + ) + val useCase = + StartUseCase( + taskPort, + StartGit(), + FakeWorkspace(workspace), + store, + projectPrefix = "sk", + ) + + val result = + useCase.execute( + StartRequest(ExternalTaskId("TASK-1"), "request-1", "change"), + ) + val success = result.shouldBeInstanceOf>() + + success.data.idempotent shouldBe true + success.data.subTask.id shouldBe existing.id + taskPort.lastCreateRequest shouldBe null + store.rollbackCount shouldBe 0 + } + + test("완료된 시작 요청을 재시도하면, SubTask sequence를 증가시키지 않습니다") { + val workspace = Workspace("ws-1", "sk-101", WorkspacePath("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/managed/sk-101"), "sk-101") + val existing = SubTask("sk-101", "task-1", "change", workspace = workspace) + val task = + Task("task-1", ExternalTaskId("TASK-1"), "task", subTaskIds = listOf(existing.id)) + val key = StartRequestKey("TASK-1", "request-1") + val taskPort = ExistingTaskPort(task, existing) + val store = + RecordingStartStore( + WorkflowStoreSnapshot( + revision = "store-1", + sequence = IdSequence(subTask = 101), + tasks = listOf(task), + subTasks = listOf(existing), + workspaces = listOf(workspace), + startRequests = + mapOf( + key to + StartRequestRecord( + key = key, + subTaskId = existing.id, + title = "change", + ), + ), + ), + ) + val useCase = + StartUseCase( + taskPort, + StartGit(), + FakeWorkspace(workspace), + store, + projectPrefix = "sk", + ) + + val result = + useCase.execute( + StartRequest(ExternalTaskId("TASK-1"), "request-1", "change"), + ) + val success = result.shouldBeInstanceOf>() + + success.data.idempotent shouldBe true + taskPort.lastCreateRequest shouldBe null + store.beginCount shouldBe 0 + store.written shouldBe null + } + + test("같은 요청에 다른 메타데이터를 입력하면, 멱등성 충돌로 거절합니다") { + val workspace = Workspace("ws-1", "sk-101", WorkspacePath("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/managed/sk-101"), "sk-101") + val existing = SubTask("sk-101", "task-1", "original", workspace = workspace) + val task = + Task("task-1", ExternalTaskId("TASK-1"), "task", subTaskIds = listOf(existing.id)) + val key = StartRequestKey("TASK-1", "request-1") + val taskPort = ExistingTaskPort(task, existing) + val store = + RecordingStartStore( + WorkflowStoreSnapshot( + "store-1", + tasks = listOf(task), + subTasks = listOf(existing), + workspaces = listOf(workspace), + startRequests = + mapOf( + key to + StartRequestRecord( + key = key, + subTaskId = existing.id, + title = "original", + ), + ), + ), + ) + val result = + StartUseCase( + taskPort, + StartGit(), + FakeWorkspace(workspace), + store, + compensationPort = RecordingCompensationPort(), + projectPrefix = "sk", + ) + .execute(StartRequest(ExternalTaskId("TASK-1"), "request-1", "different")) + val failure = result.shouldBeInstanceOf() + + failure.data.blockedBy.single().code shouldBe "IDEMPOTENCY_CONFLICT" + taskPort.lastCreateRequest shouldBe null + store.rollbackCount shouldBe 0 + } + + test("새 SubTask를 시작하면, 발급한 ID와 증가한 sequence를 같은 저장 트랜잭션에 기록합니다") { + val task = Task("task-1", ExternalTaskId("TASK-1"), "task") + val subTask = SubTask("sk-101", task.id, "change") + val store = RecordingStartStore(WorkflowStoreSnapshot("store-1")) + val taskPort = ExistingTaskPort(task, subTask) + val useCase = + StartUseCase( + taskPort, + StartGit(), + FakeWorkspace( + Workspace( + "ws-1", + subTask.id, + WorkspacePath("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/managed/sk-101"), + subTask.branch, + ), + ), + store, + projectPrefix = "sk", + ) + + val result = + useCase.execute( + StartRequest(ExternalTaskId("TASK-1"), "request-1", "change"), + ) + val success = result.shouldBeInstanceOf>() + + success.data.subTask.id shouldBe "sk-101" + success.data.subTask.workspace shouldBe success.data.workspace + taskPort.lastCreateRequest?.subTaskId shouldBe "sk-101" + store.written?.sequence?.subTask shouldBe 101 + store.written?.subTasks?.single()?.workspace shouldBe store.written?.workspaces?.single() + } + + test("Worktree 생성이 실패하면, 외부 변경을 역순으로 보상하고 상태를 되돌립니다") { + val task = Task("task-1", ExternalTaskId("TASK-1"), "task") + val subTask = SubTask("sk-101", "task-1", "change") + val store = RecordingStartStore(WorkflowStoreSnapshot("store-1")) + val compensation = RecordingCompensationPort() + val result = + StartUseCase( + ExistingTaskPort(task, subTask), + FailingStartGit(), + FakeWorkspace( + Workspace( + "ws-1", + subTask.id, + WorkspacePath("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/managed/sk-101"), + subTask.branch, + ), + ), + store, + compensationPort = compensation, + projectPrefix = "sk", + ) + .execute(StartRequest(ExternalTaskId("TASK-1"), "request-1", "change")) + + result.shouldBeInstanceOf() + store.rollbackCount shouldBe 1 + compensation.ids shouldBe listOf("worktree-change", "branch-change", "task-change") + } + } + }) + +private class FakeWorkspace(private val workspace: Workspace) : WorkspacePort { + override fun get(request: WorkspaceLookupRequest) = + PortResult.Success(WorkspaceLookupResponse(workspace)) + + override fun create(request: CreateWorkspaceRequest): PortResult = + PortResult.Success( + CreateWorkspaceResponse( + Workspace( + request.workspaceId, + request.subTaskId, + request.path, + request.branch, + ), + ChangeReceipt("workspace-change", "create-workspace"), + ), + ) + + override fun delete(request: DeleteWorkspaceRequest): PortResult = + error("not used") +} + +private class ExistingTaskPort( + private val task: Task, + private val subTask: SubTask, +) : TaskPort { + var lastCreateRequest: CreateSubTaskRequest? = null + + override fun get(request: TaskLookupRequest) = PortResult.Success(TaskLookupResponse(task)) + + override fun getSubTask(request: SubTaskLookupRequest) = + PortResult.Success(SubTaskLookupResponse(subTask)) + + override fun createSubTask(request: CreateSubTaskRequest) = run { + lastCreateRequest = request + PortResult.Success( + CreateSubTaskResponse( + task, + subTask, + ChangeReceipt( + "task-change", + "create-subtask", + compensation = Compensation("undo-task", "delete-subtask", "request-1"), + ), + ), + ) + } + + override fun updateSubTask( + request: UpdateExternalSubTaskRequest, + ): PortResult = error("not used") +} + +private open class StartGit : GitPort { + override fun refreshMain(request: MainRevisionRequest) = + PortResult.Success(MainRevisionResponse("main-1")) + + override fun inspect(request: GitInspectRequest) = + PortResult.Success(GitInspectResponse(GitStatus("main-1", "main-fingerprint", false))) + + override fun createBranch(request: CreateBranchRequest) = + PortResult.Success( + CreateBranchResponse( + request.branch, + request.baseRevision, + ChangeReceipt("branch-change", "create-branch"), + ), + ) + + override fun createWorktree( + request: CreateWorktreeRequest, + ): PortResult = + PortResult.Success( + CreateWorktreeResponse( + request.workspaceId, + request.branch, + request.path, + ChangeReceipt("worktree-change", "create-worktree"), + ), + ) + + override fun restack(request: RestackRequest): PortResult = error("not used") + + override fun removeBranch(request: RemoveBranchRequest): PortResult = + error("not used") +} + +private class FailingStartGit : StartGit() { + override fun createWorktree(request: CreateWorktreeRequest) = + PortResult.Failure( + PortError("WORKTREE_CREATE_FAILED", "worktree provider failed"), + ChangeReceipt( + "worktree-change", + "create-worktree", + compensation = Compensation("undo-worktree", "remove-worktree", "request-1"), + ), + ) + + override fun createBranch(request: CreateBranchRequest) = + PortResult.Success( + CreateBranchResponse( + request.branch, + request.baseRevision, + ChangeReceipt( + "branch-change", + "create-branch", + compensation = Compensation("undo-branch", "remove-branch", "request-1"), + ), + ), + ) +} + +private class RecordingStartStore(private val snapshotValue: WorkflowStoreSnapshot) : + WorkflowStorePort { + var rollbackCount = 0 + var beginCount = 0 + var written: WorkflowStoreSnapshot? = null + + override fun snapshot(request: StoreSnapshotRequest) = + PortResult.Success(StoreSnapshotResponse(snapshotValue)) + + override fun begin(request: StoreTransactionRequest) = run { + beginCount += 1 + PortResult.Success(StoreTransactionResponse(request.transactionId, StoreTransactionState.OPEN)) + } + + override fun commit(request: StoreTransactionRequest): PortResult = + PortResult.Success( + StoreTransactionResponse( + request.transactionId, + StoreTransactionState.COMMITTED, + "store-2", + ), + ) + + override fun rollback(request: StoreTransactionRequest): PortResult { + rollbackCount += 1 + return PortResult.Success( + StoreTransactionResponse(request.transactionId, StoreTransactionState.ROLLED_BACK), + ) + } + + override fun write(request: StoreWriteRequest): PortResult { + written = request.snapshot + return PortResult.Success(StoreWriteResponse("store-2")) + } + + override fun append(request: StoreEventRequest): PortResult = + PortResult.Success(StoreEventResponse(request.event, "store-2")) +} + +private class RecordingCompensationPort : CompensationPort { + val ids = mutableListOf() + + override fun compensate(request: CompensateRequest): PortResult { + ids += request.change.id + return PortResult.Success(CompensateResponse(request.change)) + } +} + +private class FakeGit( + private val status: GitStatus, + private val remotePushResult: PortResult = + PortResult.Success(CheckRemotePushResponse(publishable = true)), +) : GitPort { + var publishCheckRequest: CheckRemotePushRequest? = null + + override fun inspect(request: GitInspectRequest) = PortResult.Success(GitInspectResponse(status)) + + override fun checkRemotePush( + request: CheckRemotePushRequest + ): PortResult { + publishCheckRequest = request + return remotePushResult + } + + override fun refreshMain(request: MainRevisionRequest): PortResult = + error("not used") + + override fun createBranch(request: CreateBranchRequest): PortResult = + error("not used") + + override fun createWorktree(request: CreateWorktreeRequest): PortResult = + error("not used") + + override fun restack(request: RestackRequest): PortResult = error("not used") + + override fun removeBranch(request: RemoveBranchRequest): PortResult = + error("not used") +} + +private class FakeStore(private val value: WorkflowStoreSnapshot) : WorkflowStorePort { + var written: WorkflowStoreSnapshot? = null + + override fun snapshot(request: StoreSnapshotRequest) = + PortResult.Success(StoreSnapshotResponse(value)) + + override fun begin(request: StoreTransactionRequest): PortResult = + PortResult.Success( + StoreTransactionResponse( + request.transactionId, + StoreTransactionState.OPEN, + value.revision, + ) + ) + + override fun commit(request: StoreTransactionRequest): PortResult = + PortResult.Success( + StoreTransactionResponse( + request.transactionId, + StoreTransactionState.COMMITTED, + "store-2", + ) + ) + + override fun rollback(request: StoreTransactionRequest): PortResult = + error("not used") + + override fun write(request: StoreWriteRequest): PortResult { + written = request.snapshot + return PortResult.Success(StoreWriteResponse("store-2")) + } + + override fun append(request: StoreEventRequest): PortResult = + error("not used") +} + +private class RecordingValidationPort( + private val summary: CheckSummary? = null, +) : ValidationPort { + var request: RunValidationRequest? = null + var requiredNames: List = emptyList() + + override fun run(request: RunValidationRequest): PortResult { + this.request = request + requiredNames = request.required.map { it.name } + val validations = request.required.map { it.copy(status = ValidationStatus.PASSED) } + val resultSummary = + summary + ?: CheckSummary( + request.fingerprint, + request.revision, + validations.map { + io.springkit.workflow.domain.CheckResult( + it.id, + it.name, + it.status, + request.fingerprint, + request.revision, + ) + }, + ) + return PortResult.Success( + RunValidationResponse( + validations = validations, + summary = resultSummary, + change = ChangeReceipt("run-1", "run-validation"), + ), + ) + } + + override fun get(request: GetValidationRequest): PortResult = + error("not used") +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/application/StatusUseCaseTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/StatusUseCaseTest.kt new file mode 100644 index 00000000..c683c15d --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/StatusUseCaseTest.kt @@ -0,0 +1,389 @@ +package io.springkit.workflow.application + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldNotContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.CiStatus +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskCleanupState +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspacePath + +class StatusUseCaseTest : + FunSpec({ + context("CLI 선택자를 검증하는 상황에서") { + test("두 개 이상의 선택자를 지정하면, 잘못된 대상 선택으로 거부합니다") { + val result = + StatusUseCase(FakeStatusStore()) + .execute( + StatusRequest(subTaskId = "sk-101", taskId = "TASK-1"), + ) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.INVALID_TARGET_SELECTION + } + + test("잘못된 선택자를 지정하면, 저장소를 읽기 전에 잘못된 대상 선택으로 거부합니다") { + val result = + StatusUseCase(FailingStatusStore()) + .execute(StatusRequest(subTaskId = "sk-101", taskId = "TASK-1")) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.INVALID_TARGET_SELECTION + } + } + + context("상태 저장소 조회가 실패하는 상황에서") { + test("저장소가 실패를 반환하면, 저장소 실패 코드와 메시지를 반환합니다") { + val result = + StatusUseCase(FailingStatusStore()).execute(StatusRequest(subTaskId = "sk-101")) + + val failure = result.shouldBeInstanceOf().data + failure.code shouldBe FailureCode.STORE_FAILURE + failure.message shouldBe "store is unavailable" + } + } + + context("상태 대상이 존재하지 않는 상황에서") { + test("없는 subtask를 조회하면, 대상이 지정된 subtask 없음 오류를 반환합니다") { + val result = StatusUseCase(FakeStatusStore()).execute(StatusRequest(subTaskId = "sk-404")) + + val failure = result.shouldBeInstanceOf().data + failure.code shouldBe FailureCode.SUBTASK_NOT_FOUND + failure.blockedBy.single().target shouldBe "sk-404" + } + + test("없는 deployment candidate와 release를 조회하면, 서로 다른 없음 오류를 반환합니다") { + val candidateResult = + StatusUseCase(FakeStatusStore()).execute(StatusRequest(candidateId = "dc-404")) + val releaseResult = + StatusUseCase(FakeStatusStore()).execute(StatusRequest(releaseId = "rel-404")) + + candidateResult.shouldBeInstanceOf().data.code shouldBe + FailureCode.DEPLOYMENT_NOT_FOUND + releaseResult.shouldBeInstanceOf().data.code shouldBe + FailureCode.RELEASE_NOT_FOUND + } + + test("현재 workspace를 조회할 때 workspace가 없으면, workspace 없음 오류를 반환합니다") { + val result = StatusUseCase(FakeStatusStore()).execute() + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.WORKSPACE_NOT_FOUND + } + } + + context("정리가 완료된 workspace의 상태를 조회하는 상황에서") { + test("완료된 workspace이면, 삭제된 경로를 다시 검사하지 않고 상태를 반환합니다") { + val workspace = + Workspace( + id = "ws-sk-parent", + subTaskId = "sk-parent", + path = WorkspacePath("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/managed/sk-parent"), + branch = "sk-parent", + ) + val snapshot = + WorkflowStoreSnapshot( + revision = "store-1", + subTasks = + listOf( + SubTask( + id = "sk-parent", + taskId = "task-1", + title = "정리 완료", + state = SubTaskState.MERGED, + workspace = workspace, + cleanupState = SubTaskCleanupState.COMPLETED, + ) + ), + ) + + val result = + StatusUseCase( + FakeStatusStore(snapshot), + workspacePort = + FixedStatusWorkspacePort( + PortResult.Success(WorkspaceLookupResponse(workspace)) + ), + ) + .execute() + + val status = result.shouldBeInstanceOf>().data + status.subTask?.id shouldBe "sk-parent" + status.workspace shouldBe null + } + + test("관리 worktree가 아닌 현재 경로이면, 완료 SubTask를 임의로 선택하지 않습니다") { + val snapshot = + WorkflowStoreSnapshot( + revision = "store-1", + subTasks = + listOf( + SubTask( + id = "sk-parent", + taskId = "task-1", + title = "정리 완료", + state = SubTaskState.MERGED, + cleanupState = SubTaskCleanupState.COMPLETED, + ) + ), + ) + + val result = + StatusUseCase( + FakeStatusStore(snapshot), + workspacePort = + FixedStatusWorkspacePort( + PortResult.Failure( + PortError("NOT_MANAGED_WORKTREE", "현재 경로는 관리 대상이 아닙니다") + ) + ), + ) + .execute() + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.WORKSPACE_NOT_FOUND + } + + test("원격 branch만 제거되었으면, 남아 있는 workspace를 계속 표시합니다") { + val workspace = + Workspace( + id = "ws-sk-parent", + subTaskId = "sk-parent", + path = WorkspacePath("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/managed/sk-parent"), + branch = "sk-parent", + ) + val snapshot = + WorkflowStoreSnapshot( + revision = "store-1", + subTasks = + listOf( + SubTask( + id = "sk-parent", + taskId = "task-1", + title = "원격 정리 진행 중", + state = SubTaskState.MERGED, + workspace = workspace, + cleanupState = SubTaskCleanupState.REMOTE_BRANCH_REMOVED, + ) + ), + ) + + val result = + StatusUseCase(FakeStatusStore(snapshot)) + .execute(StatusRequest(subTaskId = "sk-parent")) + + result + .shouldBeInstanceOf>() + .data + .workspace + ?.workspace + ?.id shouldBe workspace.id + } + } + + context("애플리케이션 호출자의 선택자를 확인하는 상황에서") { + test("typed selector를 지정하면, 동일한 선택자를 보존합니다") { + val request = StatusRequest(selector = StatusSelector.SubTask("sk-101")) + + request.selected().shouldBeInstanceOf().selector shouldBe + StatusSelector.SubTask("sk-101") + } + } + + context("Review가 Merge Queue 차단 조건을 가진 상황에서") { + test("Review가 Merge Queue를 차단하면, 차단 사유와 해결 행동을 반환하고 승인을 다음 행동으로 제안하지 않습니다") { + val snapshot = + reviewSnapshot(SubTaskState.REVIEW, PullRequestState.REVIEW, CiStatus.PENDING) + + val result = + StatusUseCase(FakeStatusStore(snapshot)).execute(StatusRequest(subTaskId = "sk-101")) + + val status = result.shouldBeInstanceOf>().data + status.blockedBy.map { it.code } shouldBe listOf("CI_NOT_PASSED") + status.next.map { it.action } shouldBe listOf("await_ci") + status.next.map { it.action } shouldNotContain "approve_change" + } + + test("Draft Review이면, 정규화된 ready 명령을 반환합니다") { + val snapshot = + reviewSnapshot(SubTaskState.DRAFT, PullRequestState.DRAFT, CiStatus.PENDING) + + val result = + StatusUseCase(FakeStatusStore(snapshot)).execute(StatusRequest(subTaskId = "sk-101")) + + val next = result.shouldBeInstanceOf>().data.next + next.single().action shouldBe "ready_review" + next.single().command shouldBe + "./tools/workflow/bin/workflow gate ready sk-101 --review-revision rv-1" + } + } + + context("CI 상태를 조회하는 상황에서") { + test("Review의 provider revision이 없으면, diff identity를 쓰지 않고 CI를 미완료로 둡니다") { + val ciPort = RecordingCiPort() + val result = + StatusUseCase( + storePort = + FakeStatusStore( + reviewSnapshot( + SubTaskState.REVIEW, + PullRequestState.REVIEW, + CiStatus.PASSED, + ) + ), + ciPort = ciPort, + ) + .execute(StatusRequest(subTaskId = "sk-101")) + + result + .shouldBeInstanceOf>() + .data + .review + ?.ci shouldBe CiStatus.PENDING + ciPort.revision shouldBe null + } + + test("provider revision이 있으면, CI 공급자에 provider revision을 전달합니다") { + val snapshot = + reviewSnapshot(SubTaskState.REVIEW, PullRequestState.REVIEW, CiStatus.PENDING) + .copy( + pullRequests = + listOf( + reviewSnapshot( + SubTaskState.REVIEW, + PullRequestState.REVIEW, + CiStatus.PENDING, + ) + .pullRequests + .single() + .copy( + changeRevision = + ChangeRevision( + "cr-1", + 1, + Diff("diff-1"), + providerRevision = "head-1", + ) + ) + ) + ) + val ciPort = RecordingCiPort() + + val result = + StatusUseCase(FakeStatusStore(snapshot), ciPort = ciPort) + .execute(StatusRequest(subTaskId = "sk-101")) + + result.shouldBeInstanceOf>() + result.data.review?.ci shouldBe CiStatus.PASSED + ciPort.revision shouldBe "head-1" + } + } + }) + +private fun reviewSnapshot( + subTaskState: SubTaskState, + reviewState: PullRequestState, + ciStatus: CiStatus, +): WorkflowStoreSnapshot = + WorkflowStoreSnapshot( + revision = "store-1", + subTasks = + listOf( + SubTask( + id = "sk-101", + taskId = "task-1", + title = "상태 계약", + state = subTaskState, + pullRequestId = "pr-1", + ) + ), + pullRequests = + listOf( + PullRequest( + id = "pr-1", + subTaskId = "sk-101", + title = "상태 계약", + body = "상태 계약을 검증합니다.", + base = "main", + state = reviewState, + reviewRevision = ReviewRevision("rv-1", 1, "상태 계약을 검증합니다."), + changeRevision = ChangeRevision("cr-1", 1, Diff("diff-1")), + ci = ciStatus, + ) + ), + ) + +private class FakeStatusStore( + private val current: WorkflowStoreSnapshot = WorkflowStoreSnapshot("store-1") +) : WorkflowStorePort { + override fun snapshot(request: StoreSnapshotRequest) = + PortResult.Success(StoreSnapshotResponse(current)) + + override fun begin(request: StoreTransactionRequest): PortResult = + unused() + + override fun commit(request: StoreTransactionRequest): PortResult = + unused() + + override fun rollback(request: StoreTransactionRequest): PortResult = + unused() + + override fun write(request: StoreWriteRequest): PortResult = unused() + + override fun append(request: StoreEventRequest): PortResult = unused() + + private fun unused(): PortResult = + PortResult.Failure(PortError("UNUSED", "not used by this test")) +} + +private class FixedStatusWorkspacePort(private val result: PortResult) : + WorkspacePort { + override fun get(request: WorkspaceLookupRequest) = result + + override fun create(request: CreateWorkspaceRequest) = error("not used") + + override fun delete(request: DeleteWorkspaceRequest) = error("not used") +} + +private class FailingStatusStore : WorkflowStorePort { + override fun snapshot(request: StoreSnapshotRequest): PortResult = + PortResult.Failure(PortError("STORE_DOWN", "store is unavailable")) + + override fun begin(request: StoreTransactionRequest): PortResult = + unused() + + override fun commit(request: StoreTransactionRequest): PortResult = + unused() + + override fun rollback(request: StoreTransactionRequest): PortResult = + unused() + + override fun write(request: StoreWriteRequest): PortResult = unused() + + override fun append(request: StoreEventRequest): PortResult = unused() + + private fun unused(): PortResult = + PortResult.Failure(PortError("UNUSED", "not used by this test")) +} + +private class RecordingCiPort : CiPort { + var revision: String? = null + + override fun start(request: StartCiRequest): PortResult = + PortResult.Failure(PortError("UNUSED", "not used by this test")) + + override fun get(request: GetCiRequest): PortResult { + revision = request.revision + return PortResult.Success(GetCiResponse(emptyList(), CiStatus.PASSED)) + } +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/application/WorkflowEventUseCasesTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/WorkflowEventUseCasesTest.kt new file mode 100644 index 00000000..c0225ae4 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/WorkflowEventUseCasesTest.kt @@ -0,0 +1,171 @@ +package io.springkit.workflow.application + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.WorkflowResult + +class WorkflowEventUseCasesTest : + FunSpec({ + context("수신한 이벤트가 중복 또는 수락 상태일 때") { + test("중복 이벤트를 수신하면, 중복 성공 결과를 반환합니다") { + val port = + RecordingWorkflowEventPort(received = ReceiveEventResponse(event(), accepted = false)) + var calls = 0 + val useCase = + WorkflowEventUseCases( + eventPort = port, + handlers = + mapOf( + ExternalEventKind.TASK_CHANGED to + WorkflowEventHandler { + calls += 1 + WorkflowResult.Success(Unit) + } + ), + ) + + val result = useCase.handle(ReceiveEventRequest(event())) + + val success = result.shouldBeInstanceOf>() + success.data.duplicate shouldBe true + calls shouldBe 0 + port.acknowledgements.single().accepted shouldBe true + } + + test("수락된 이벤트를 수신하면, 등록된 handler를 한 번 호출합니다") { + val port = + RecordingWorkflowEventPort(received = ReceiveEventResponse(event(), accepted = true)) + val calls = mutableListOf() + val useCase = + WorkflowEventUseCases( + eventPort = port, + handlers = + mapOf( + ExternalEventKind.TASK_CHANGED to + WorkflowEventHandler { + calls += it + WorkflowResult.Success(Unit) + } + ), + ) + + val result = useCase.receive(ReceiveEventRequest(event())) + + result.shouldBeInstanceOf>() + calls shouldBe listOf(event()) + port.acknowledgements.single().accepted shouldBe true + } + } + + context("등록된 handler의 처리 결과를 acknowledge할 때") { + test("handler가 실패하면, 같은 실패 내용을 acknowledge합니다") { + val port = + RecordingWorkflowEventPort(received = ReceiveEventResponse(event(), accepted = true)) + val failure = FailureData(FailureCode.STATE_CONFLICT, "처리할 수 없는 상태입니다.") + val useCase = + WorkflowEventUseCases( + port, + mapOf( + ExternalEventKind.TASK_CHANGED to + WorkflowEventHandler { WorkflowResult.Failure(failure) } + ), + ) + + val result = useCase.handle(ReceiveEventRequest(event())) + + result shouldBe WorkflowResult.Failure(failure) + port.acknowledgements.single().accepted shouldBe false + port.acknowledgements.single().message shouldBe failure.message + } + + test("acknowledge가 실패하면, EXTERNAL_FAILURE를 반환합니다") { + val port = + RecordingWorkflowEventPort( + received = ReceiveEventResponse(event(), accepted = true), + acknowledgeResult = PortResult.Failure(PortError("ACK_FAILED", "acknowledge 실패")), + ) + val useCase = + WorkflowEventUseCases( + port, + mapOf( + ExternalEventKind.TASK_CHANGED to + WorkflowEventHandler { WorkflowResult.Success(Unit) } + ), + ) + + val result = useCase.handle(ReceiveEventRequest(event())) + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.EXTERNAL_FAILURE + result.shouldBeInstanceOf().data.message shouldContain + "acknowledge 실패" + } + } + + context("지원하지 않는 이벤트를 수신할 때") { + test("AI_REVIEW_CHANGED를 수신하면, INVALID_ARGUMENT를 반환합니다") { + val aiEvent = event(kind = ExternalEventKind.AI_REVIEW_CHANGED) + val port = + RecordingWorkflowEventPort(received = ReceiveEventResponse(aiEvent, accepted = true)) + var calls = 0 + val useCase = + WorkflowEventUseCases( + port, + mapOf( + ExternalEventKind.AI_REVIEW_CHANGED to + WorkflowEventHandler { + calls += 1 + WorkflowResult.Success(Unit) + } + ), + ) + + val result = useCase.handle(ReceiveEventRequest(aiEvent)) + + val failure = result.shouldBeInstanceOf().data + failure.code shouldBe FailureCode.INVALID_ARGUMENT + failure.message shouldContain ExternalEventKind.AI_REVIEW_CHANGED.name + calls shouldBe 0 + port.acknowledgements.single().accepted shouldBe false + } + + test("등록되지 않은 kind를 수신하면, INVALID_ARGUMENT를 반환합니다") { + val received = event(kind = ExternalEventKind.REVIEW_CHANGED) + val port = + RecordingWorkflowEventPort(received = ReceiveEventResponse(received, accepted = true)) + val useCase = WorkflowEventUseCases(port) + + val result = useCase.handle(ReceiveEventRequest(received)) + + val failure = result.shouldBeInstanceOf().data + failure.code shouldBe FailureCode.INVALID_ARGUMENT + failure.message shouldContain ExternalEventKind.REVIEW_CHANGED.name + port.acknowledgements.single().accepted shouldBe false + } + } + }) + +private fun event( + id: String = "event-1", + kind: ExternalEventKind = ExternalEventKind.TASK_CHANGED, +) = ExternalEvent(id = id, kind = kind, targetId = "target-1", occurredAtEpochMillis = 1) + +private class RecordingWorkflowEventPort( + private val received: ReceiveEventResponse, + private val acknowledgeResult: PortResult = + PortResult.Success(AcknowledgeEventResponse(received.event.id)), +) : WorkflowEventPort { + val acknowledgements = mutableListOf() + + override fun receive(request: ReceiveEventRequest): PortResult = + PortResult.Success(received) + + override fun acknowledge(request: AcknowledgeEventRequest): PortResult { + acknowledgements += request + return acknowledgeResult + } +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/application/WorkflowTransactionTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/WorkflowTransactionTest.kt new file mode 100644 index 00000000..cdd6ab9b --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/application/WorkflowTransactionTest.kt @@ -0,0 +1,111 @@ +package io.springkit.workflow.application + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.WorkflowResult + +class WorkflowTransactionTest : + FunSpec({ + context("트랜잭션 작업이 성공하는 상황에서") { + test("작업을 실행하면, 한 번 쓰고 커밋합니다") { + val store = TransactionRecordingStore() + val transaction = WorkflowTransaction(store, RecordingCompensation()) + + val result = + transaction.execute(StoreTransactionRequest("tx-1")) { snapshot -> + PortResult.Success(TransactionMutation("done", snapshot)) + } + + result.shouldBeInstanceOf>().data shouldBe "done" + store.calls shouldBe listOf("begin", "snapshot", "write", "commit") + } + } + + context("트랜잭션 쓰기가 실패하는 상황에서") { + test("여러 변경을 기록하면, 역순으로 보상하고 롤백합니다") { + val store = TransactionRecordingStore(failWrite = true) + val compensations = RecordingCompensation() + val transaction = WorkflowTransaction(store, compensations) + val first = change("first") + val second = change("second") + + val result = + transaction.execute(StoreTransactionRequest("tx-1")) { snapshot -> + PortResult.Success(TransactionMutation("unused", snapshot, listOf(first, second))) + } + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.STATE_CONFLICT + compensations.operations shouldBe listOf("second", "first") + store.calls shouldBe listOf("begin", "snapshot", "write", "rollback") + } + } + + context("포트 작업이 실패하는 상황에서") { + test("안정적인 실패 코드를 반환하면, 같은 코드로 워크플로 실패를 반환합니다") { + val store = TransactionRecordingStore() + val transaction = WorkflowTransaction(store, RecordingCompensation()) + + val result = + transaction.execute(StoreTransactionRequest("tx-1")) { + PortResult.Failure(PortError("IDEMPOTENCY_CONFLICT", "request differs")) + } + + result.shouldBeInstanceOf().data.code shouldBe + FailureCode.IDEMPOTENCY_CONFLICT + store.calls shouldBe listOf("begin", "snapshot", "rollback") + } + } + }) + +private fun change(operation: String) = + ChangeReceipt( + id = "change-$operation", + operation = operation, + compensation = Compensation("comp-$operation", operation, operation), + ) + +private class TransactionRecordingStore(private val failWrite: Boolean = false) : + WorkflowStorePort { + val calls = mutableListOf() + private val snapshot = WorkflowStoreSnapshot("store-1") + + override fun begin(request: StoreTransactionRequest) = success("begin") + + override fun snapshot(request: StoreSnapshotRequest): PortResult { + calls += "snapshot" + return PortResult.Success(StoreSnapshotResponse(snapshot)) + } + + override fun write(request: StoreWriteRequest): PortResult { + calls += "write" + return if (failWrite) { + PortResult.Failure(PortError("STATE_CONFLICT", "stale store revision")) + } else { + PortResult.Success(StoreWriteResponse("store-2")) + } + } + + override fun append(request: StoreEventRequest): PortResult = + error("not used by this test") + + override fun commit(request: StoreTransactionRequest) = success("commit") + + override fun rollback(request: StoreTransactionRequest) = success("rollback") + + private fun success(call: String): PortResult { + calls += call + return PortResult.Success(StoreTransactionResponse("tx-1", StoreTransactionState.OPEN)) + } +} + +private class RecordingCompensation : CompensationPort { + val operations = mutableListOf() + + override fun compensate(request: CompensateRequest): PortResult { + operations += request.change.operation + return PortResult.Success(CompensateResponse(request.change)) + } +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/domain/ReviewBodyTemplateTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/domain/ReviewBodyTemplateTest.kt new file mode 100644 index 00000000..fd1995de --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/domain/ReviewBodyTemplateTest.kt @@ -0,0 +1,49 @@ +package io.springkit.workflow.domain + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +class ReviewBodyTemplateTest : + FunSpec({ + context("PR 본문 Markdown 제목을 검증하는 상황에서") { + test("필수 제목이 명세 순서로 있으면, 템플릿 검증을 통과합니다") { + ReviewBodyTemplate.validate(validReviewBody()) shouldBe null + } + + test("필수 제목이 없으면, 누락된 제목을 반환합니다") { + val body = validReviewBody().replace("## 왜 지금 해결해야 하는가", "## 다른 제목") + + ReviewBodyTemplate.validate(body) shouldBe "PR 본문에 '왜 지금 해결해야 하는가' 제목이 없습니다." + } + + test("필수 제목 순서가 바뀌면, 기대한 순서를 반환합니다") { + val body = + validReviewBody() + .replace("## 해결하려는 문제", "## 임시 제목") + .replace("## 왜 지금 해결해야 하는가", "## 해결하려는 문제") + .replace("## 임시 제목", "## 왜 지금 해결해야 하는가") + + ReviewBodyTemplate.validate(body) shouldBe + "PR 본문 제목 순서가 잘못되었습니다. '해결하려는 문제' 다음에 '왜 지금 해결해야 하는가' 제목이 있어야 합니다." + } + + test("공백 없이 정보 문자열이 붙은 코드 펜스 안의 제목은, 필수 제목으로 인정하지 않습니다") { + val body = "```kotlin\n## 해결하려는 문제\n```" + + ReviewBodyTemplate.validate(body) shouldBe "PR 본문에 '해결하려는 문제' 제목이 없습니다." + } + } + }) + +private fun validReviewBody(): String = + listOf( + "해결하려는 문제", + "왜 지금 해결해야 하는가", + "어떻게 해결했는가", + "한계와 트레이드오프", + "기존 기능에 미치는 영향", + "Edge Case와 실패 시나리오", + "검토한 대안과 선택 이유", + "리뷰 포인트", + ) + .joinToString("\n\n") { heading -> "## $heading\n설명" } diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/domain/WorkflowDomainTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/domain/WorkflowDomainTest.kt new file mode 100644 index 00000000..62afc410 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/domain/WorkflowDomainTest.kt @@ -0,0 +1,149 @@ +package io.springkit.workflow.domain + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldNotBeNull +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe + +class WorkflowDomainTest : + FunSpec({ + val human = Actor("alice", ActorKind.HUMAN) + val agent = Actor("agent", ActorKind.AGENT) + + context("열린 R thread를 확인할 때") { + test("열린 R thread가 있으면, 차단 thread로 반환합니다") { + val comment = ReviewComment("comment-1", agent, "fix this") + val required = ReviewThread("thread-r", ReviewLevel.R, listOf(comment)) + val resolved = + ReviewThread("thread-c", ReviewLevel.C, listOf(comment), ThreadState.RESOLVED) + + hasOpenRequiredThread(listOf(required, resolved)) shouldBe true + WorkflowRules.openRequiredThreads(listOf(required, resolved)) shouldBe listOf(required) + } + + test("열린 R thread가 없으면, 차단 thread가 없다고 판단합니다") { + val comment = ReviewComment("comment-1", agent, "fix this") + val resolved = + ReviewThread("thread-c", ReviewLevel.C, listOf(comment), ThreadState.RESOLVED) + + hasOpenRequiredThread(listOf(resolved)) shouldBe false + } + } + + context("Approval이 현재 변경을 가리키는지 확인할 때") { + test("활성 변경과 diff가 모두 일치하면, Approval을 적용합니다") { + val change = ChangeRevision("cr-1", 1, Diff("diff-1")) + val approval = Approval("approval-1", human, "cr-1", "diff-1") + + approvalApplies(approval, change) shouldBe true + } + + test("diff가 다르면, Approval을 적용하지 않습니다") { + val change = ChangeRevision("cr-1", 1, Diff("diff-1")) + val approval = Approval("approval-1", human, "cr-1", "diff-1") + + approvalApplies(approval.copy(diffIdentity = "diff-2"), change) shouldBe false + } + + test("변경이 비활성이면, Approval을 적용하지 않습니다") { + val change = ChangeRevision("cr-1", 1, Diff("diff-1")) + val approval = Approval("approval-1", human, "cr-1", "diff-1") + + approvalApplies(approval.copy(active = false), change) shouldBe false + } + + test("actor가 agent이면, Approval 생성을 거부합니다") { + shouldThrow { + Approval("approval-1", agent, "cr-1", "diff-1") + } + } + } + + context("dependency cycle을 확인할 때") { + test("순환 의존성이 있으면, 시작점으로 닫힌 경로를 반환합니다") { + val cycle = + dependencyCycle( + listOf( + Dependency("b", "a"), + Dependency("c", "b"), + Dependency("a", "c"), + ), + ) + + cycle shouldNotBe emptyList() + cycle.first() shouldBe cycle.last() + } + + test("순환 의존성이 없으면, 순환 경로를 반환하지 않습니다") { + hasDependencyCycle(listOf(Dependency("b", "a"))) shouldBe false + } + } + + context("human gate 조건을 확인할 때") { + test("agent가 결정을 시도하면, HUMAN_REQUIRED 실패를 반환합니다") { + val failure = humanGateCondition(GateType.READY, agent) + + failure.shouldNotBeNull().code shouldBe FailureCode.HUMAN_REQUIRED + } + } + + context("candidate risk를 계산할 때") { + test("모든 subtask의 위험도가 NORMAL이면, NORMAL을 반환합니다") { + candidateRisk(listOf(Risk.NORMAL, Risk.NORMAL)) shouldBe Risk.NORMAL + } + + test("HIGH 위험도의 subtask가 하나라도 있으면, HIGH를 반환합니다") { + candidateRisk(listOf(Risk.NORMAL, Risk.HIGH)) shouldBe Risk.HIGH + } + } + + context("release 후속 작업을 결정할 때") { + test("공개 승인 대기 상태이면, 실행 가능한 Workflow CLI 명령을 반환합니다") { + val release = + Release( + id = "rel-1", + candidateId = "dc-1", + state = ReleaseState.AWAITING_RELEASE_APPROVAL, + productionReady = true, + internalValidationPassed = true, + ) + + val action = nextReleaseAction(release).shouldNotBeNull() + + action.action shouldBe "approve_release" + action.command shouldBe "./tools/workflow/bin/workflow gate release rel-1" + } + + test("rollout이 해제된 release이면, cleanup subtask 생성 작업을 반환합니다") { + val release = + Release( + id = "rel-1", + candidateId = "dc-1", + state = ReleaseState.CLEANUP_REQUIRED, + productionReady = true, + internalValidationPassed = true, + ) + + val action = nextReleaseAction(release) + + val actual = action.shouldNotBeNull() + + actual.action shouldBe "create_cleanup_subtask" + actual.command shouldBe null + } + + test("release 상태가 RELEASED이면, 후속 작업을 반환하지 않습니다") { + val release = + Release( + id = "rel-1", + candidateId = "dc-1", + state = ReleaseState.RELEASED, + productionReady = true, + internalValidationPassed = true, + ) + + nextReleaseAction(release) shouldBe null + } + } + }) diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/domain/WorkflowResultTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/domain/WorkflowResultTest.kt new file mode 100644 index 00000000..1014233f --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/domain/WorkflowResultTest.kt @@ -0,0 +1,33 @@ +package io.springkit.workflow.domain + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import io.springkit.workflow.adapter.json.encodeToString + +class WorkflowResultTest : + FunSpec({ + context("WorkflowResult의 실패 코드 JSON 이름을 확인할 때") { + test("기록된 실패 코드이면, 안정적인 JSON 이름을 유지합니다") { + val result = + WorkflowResult.Failure( + FailureData( + code = FailureCode.IDEMPOTENCY_CONFLICT, + message = "같은 요청 키의 입력이 다릅니다.", + ) + ) + + result.encodeToString() shouldContain "\"code\":\"IDEMPOTENCY_CONFLICT\"" + } + } + + context("FailureCode의 명시적 도메인 값을 확인할 때") { + test("사람 승인과 동기화 실패 코드이면, enum에 포함됩니다") { + val codes = FailureCode.entries.toSet() + + codes.contains(FailureCode.HUMAN_REQUIRED) shouldBe true + codes.contains(FailureCode.SYNC_CONFLICT) shouldBe true + codes.contains(FailureCode.STALE_DIFF_IDENTITY) shouldBe true + } + } + }) diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/domain/WorkflowStateTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/domain/WorkflowStateTest.kt new file mode 100644 index 00000000..eedd178f --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/domain/WorkflowStateTest.kt @@ -0,0 +1,47 @@ +package io.springkit.workflow.domain + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +class WorkflowStateTest : + FunSpec({ + context("WorkflowState에서 식별자를 생성할 때") { + test("subtask와 review를 차례로 생성하면, 식별자가 결정적으로 증가합니다") { + val initial = WorkflowState() + val (withSubTask, subTaskId) = initial.nextSubTaskId("sk") + val (withReview, reviewId) = withSubTask.nextReviewRevision() + val (_, changeId) = withReview.nextChangeRevision() + + subTaskId shouldBe "sk-101" + reviewId shouldBe "rv-1" + changeId shouldBe "cr-1" + } + } + + context("start request record의 subtask 참조를 검증할 때") { + test("존재하지 않는 subtask를 참조하면, WorkflowState 생성을 거부합니다") { + val key = StartRequestKey("TASK-1", "request-1") + val record = StartRequestRecord(key, "sk-101", "title") + + shouldThrow { + WorkflowState(startRequests = mapOf(key to record)) + } + } + } + + context("WorkflowState의 dependency cycle을 검증할 때") { + test("순환 의존성이 있으면, WorkflowState 생성을 거부합니다") { + shouldThrow { + WorkflowState( + subTasks = + listOf( + SubTask("sk-101", "TASK-1", "first", requires = "sk-102"), + SubTask("sk-102", "TASK-1", "second", requires = "sk-101"), + ) + .associateBy { it.id }, + ) + } + } + } + }) diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/ManagedWorkspaceBodyPathTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/ManagedWorkspaceBodyPathTest.kt new file mode 100644 index 00000000..49c6fd4a --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/ManagedWorkspaceBodyPathTest.kt @@ -0,0 +1,67 @@ +package io.springkit.workflow.runtime + +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.springkit.workflow.domain.WorkspacePath +import java.nio.file.Files + +class ManagedWorkspaceBodyPathTest : + FunSpec({ + context("관리 Worktree 내부의 본문 경로를 해석할 때") { + test("정상 상대 경로를 입력하면, 실제 파일 경로를 반환합니다") { + val workspace = Files.createTempDirectory("workflow-body-workspace-") + val body = Files.writeString(workspace.resolve("pr.md"), "본문") + + ManagedWorkspaceBodyPath.resolve(WorkspacePath(workspace.toString()), "pr.md") shouldBe + body.toRealPath() + } + + test("절대 경로가 Worktree 내부를 가리키면, 실제 파일 경로를 반환합니다") { + val workspace = Files.createTempDirectory("workflow-body-workspace-") + val body = Files.writeString(workspace.resolve("pr.md"), "본문") + + ManagedWorkspaceBodyPath.resolve( + WorkspacePath(workspace.toString()), + body.toString(), + ) shouldBe body.toRealPath() + } + } + + context("본문 경로가 관리 Worktree 밖을 가리킬 때") { + test("절대 경로를 입력하면, 잘못된 인자로 거부합니다") { + val workspace = Files.createTempDirectory("workflow-body-workspace-") + val outside = Files.createTempFile("workflow-body-outside-", ".md") + + shouldThrow { + ManagedWorkspaceBodyPath.resolve( + WorkspacePath(workspace.toString()), + outside.toString(), + ) + } + } + + test("상위 경로가 Worktree 밖을 가리키면, 잘못된 인자로 거부합니다") { + val parent = Files.createTempDirectory("workflow-body-parent-") + val workspace = Files.createDirectory(parent.resolve("workspace")) + Files.writeString(parent.resolve("outside.md"), "본문") + + shouldThrow { + ManagedWorkspaceBodyPath.resolve( + WorkspacePath(workspace.toString()), + "../outside.md", + ) + } + } + + test("심볼릭 링크가 Worktree 밖을 가리키면, 잘못된 인자로 거부합니다") { + val workspace = Files.createTempDirectory("workflow-body-workspace-") + val outside = Files.createTempFile("workflow-body-outside-", ".md") + Files.createSymbolicLink(workspace.resolve("link.md"), outside) + + shouldThrow { + ManagedWorkspaceBodyPath.resolve(WorkspacePath(workspace.toString()), "link.md") + } + } + } + }) diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/RuntimeFactoryTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/RuntimeFactoryTest.kt new file mode 100644 index 00000000..9f5d4f8d --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/RuntimeFactoryTest.kt @@ -0,0 +1,610 @@ +package io.springkit.workflow.runtime + +import com.github.ajalt.clikt.testing.test +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.kotest.matchers.string.shouldContain +import io.kotest.matchers.string.shouldNotContain +import io.kotest.matchers.string.shouldStartWith +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.adapter.cli.WorkflowCli +import io.springkit.workflow.adapter.cli.WorkflowCommandRequest +import io.springkit.workflow.adapter.github.GithubIssueTaskAdapter +import io.springkit.workflow.adapter.json.encodeToString +import io.springkit.workflow.adapter.local.SnapshotTaskAdapter +import io.springkit.workflow.adapter.provider.ProviderDeploymentAdapter +import io.springkit.workflow.adapter.store.OkioWorkflowStoreAdapter +import io.springkit.workflow.adapter.store.WorkflowStateJsonCodec +import io.springkit.workflow.application.DeploymentLifecycleUseCase +import io.springkit.workflow.application.PostMergeCleanupBlock +import io.springkit.workflow.application.PostMergeCleanupResponse +import io.springkit.workflow.application.PostMergeCleanupState +import io.springkit.workflow.application.WorkflowStoreSnapshot +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.BlockedBy +import io.springkit.workflow.domain.DeploymentCandidate +import io.springkit.workflow.domain.DeploymentCandidateState +import io.springkit.workflow.domain.ExternalTaskId +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.FailureData +import io.springkit.workflow.domain.NextAction +import io.springkit.workflow.domain.ReviewComment +import io.springkit.workflow.domain.ReviewLevel +import io.springkit.workflow.domain.ReviewThread +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.Task +import io.springkit.workflow.domain.TaskState +import io.springkit.workflow.domain.ThreadState +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.ValidationStatus +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.WorkflowState +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspacePath +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import java.nio.file.Files +import java.nio.file.Path +import kotlinx.serialization.json.JsonObject +import okio.Path.Companion.toPath + +class RuntimeFactoryTest : + FunSpec({ + context("Task provider를 런타임에 연결할 때") { + test("설정이 없으면, 기존 snapshot adapter를 선택합니다") { + val adapter = + createTaskPort( + repositoryRoot = Path.of("/repo"), + environment = emptyMap(), + commandRunner = RuntimeCommandRunner(Path.of("/repo")), + snapshotProvider = { WorkflowStoreSnapshot("1") }, + ) + + adapter.shouldBeInstanceOf() + } + + test("snapshot을 지정하면, 기존 snapshot adapter를 선택합니다") { + val adapter = + createTaskPort( + repositoryRoot = Path.of("/repo"), + environment = mapOf("WORKFLOW_TASK_PROVIDER" to "snapshot"), + commandRunner = RuntimeCommandRunner(Path.of("/repo")), + snapshotProvider = { WorkflowStoreSnapshot("1") }, + ) + + adapter.shouldBeInstanceOf() + } + + test("github-issue를 지정하면, GitHub Issue adapter를 선택합니다") { + val adapter = + createTaskPort( + repositoryRoot = Path.of("/repo"), + environment = mapOf("WORKFLOW_TASK_PROVIDER" to "github-issue"), + commandRunner = RuntimeCommandRunner(Path.of("/repo")), + snapshotProvider = { WorkflowStoreSnapshot("1") }, + ) + + adapter.shouldBeInstanceOf() + } + + test("지원하지 않는 provider이면, 두 기본 런타임 모두 명확한 예외를 반환합니다") { + val root = Files.createTempDirectory("workflow-runtime-provider-") + val environment = + mapOf( + "WORKFLOW_REPO_ROOT" to root.toString(), + "WORKFLOW_TASK_PROVIDER" to "clickup", + ) + + val applicationFailure = + shouldThrow { + createDefaultApplicationRuntime( + currentDirectory = root, + environment = environment, + commandRunner = RuntimeCommandRunner(root), + ) + } + applicationFailure.message shouldContain "WORKFLOW_TASK_PROVIDER" + + shouldThrow { + createDefaultRuntime( + currentDirectory = root, + environment = environment, + commandRunner = RuntimeCommandRunner(root), + ) + } + } + } + + context("외부 provider CLI를 런타임에 연결할 때") { + test("배포와 Release provider 명령을 JSON 배열로 설정하면, Application lifecycle을 구성합니다") { + val root = Files.createTempDirectory("workflow-runtime-providers-") + val environment = + mapOf( + "WORKFLOW_REPO_ROOT" to root.toString(), + "WORKFLOW_DEPLOYMENT_COMMAND" to "[\"deploy\"]", + "WORKFLOW_RELEASE_COMMAND" to "[\"release\"]", + ) + + val runtime = + createDefaultApplicationRuntime( + currentDirectory = root, + environment = environment, + commandRunner = RuntimeCommandRunner(root), + ) + + runtime.deploymentLifecycle shouldNotBe null + runtime.releaseLifecycle shouldNotBe null + } + + test("Release provider 명령이 빠지면, 명확한 IllegalArgumentException을 반환합니다") { + val root = Files.createTempDirectory("workflow-runtime-provider-missing-") + val environment = + mapOf( + "WORKFLOW_REPO_ROOT" to root.toString(), + "WORKFLOW_DEPLOYMENT_COMMAND" to "[\"deploy\"]", + ) + + val failure = + shouldThrow { + createDefaultApplicationRuntime( + currentDirectory = root, + environment = environment, + commandRunner = RuntimeCommandRunner(root), + ) + } + + failure.message shouldContain "WORKFLOW_RELEASE_COMMAND" + } + + test("provider 명령이 JSON 배열이 아니면, 명확한 IllegalArgumentException을 반환합니다") { + val root = Files.createTempDirectory("workflow-runtime-provider-json-") + val environment = + mapOf( + "WORKFLOW_REPO_ROOT" to root.toString(), + "WORKFLOW_DEPLOYMENT_COMMAND" to "deploy", + "WORKFLOW_RELEASE_COMMAND" to "[\"release\"]", + ) + + val failure = + shouldThrow { + createDefaultApplicationRuntime( + currentDirectory = root, + environment = environment, + commandRunner = RuntimeCommandRunner(root), + ) + } + + failure.message shouldContain "WORKFLOW_DEPLOYMENT_COMMAND" + failure.message shouldContain "JSON" + } + } + + context("병합 이후 cleanup과 배포가 모두 실패하면") { + test("병합 이후 cleanup과 배포가 모두 실패하면, 배포 실패 결과에 cleanup 차단 원인과 재시도 행동을 보존합니다") { + val deploymentFailure = + WorkflowResult.Failure( + FailureData( + code = FailureCode.EXTERNAL_FAILURE, + message = "배포 provider 실패", + blockedBy = listOf(BlockedBy("DEPLOYMENT_FAILED", "배포 실패", "candidate-1")), + ) + ) + val cleanup = + WorkflowResult.Success( + PostMergeCleanupResponse( + mergedSubTaskId = "sk-101", + state = PostMergeCleanupState.BLOCKED, + blocks = + listOf( + PostMergeCleanupBlock( + phase = "worktree", + target = "workspace-1", + code = "WORKTREE_DIRTY", + message = "Worktree에 게시되지 않은 변경이 있습니다.", + ) + ), + ), + next = listOf(NextAction(ActorKind.WORKFLOW, "retry_cleanup")), + ) + + val combined = deploymentFailure.withCleanupRetry(cleanup, "sk-101") + + combined.data.blockedBy.map(BlockedBy::code) shouldContain "DEPLOYMENT_FAILED" + combined.data.blockedBy.map(BlockedBy::code) shouldContain "WORKTREE_DIRTY" + combined.data.next shouldContain NextAction(ActorKind.WORKFLOW, "retry_cleanup") + } + } + + context("Production 이후 다음 배포 후보를 읽을 때") { + test("Store snapshot이 실패하면, STORE_FAILURE을 구조화해 반환합니다") { + val root = Files.createTempDirectory("workflow-runtime-next-candidate-store-failure-") + val statePath = root.resolve("state.json") + Files.writeString(statePath, "잘못된 상태") + val store = OkioWorkflowStoreAdapter(statePath.toString().toPath()) + val deploymentLifecycle = + DeploymentLifecycleUseCase( + storePort = store, + deploymentPort = + ProviderDeploymentAdapter( + commandPrefix = listOf("deploy"), + workingDirectory = root, + commandRunner = RuntimeCommandRunner(root), + ), + ) + val production = + DeploymentCandidate( + id = "candidate-1", + mainRevision = "main-1", + includedSubTasks = emptyList(), + validations = listOf(Validation("validation-1", "검증", ValidationStatus.PASSED)), + state = DeploymentCandidateState.PRODUCTION, + ) + + val result = + createNextCandidateAfterProduction( + store = store, + deploymentLifecycle = deploymentLifecycle, + production = production, + requestId = "event-1", + ) + + val failure = result.shouldBeInstanceOf() + failure.data.code shouldBe FailureCode.STORE_FAILURE + failure.data.blockedBy.single().target shouldBe "store" + } + } + + context("기본 런타임을 CLI에 연결할 때") { + test("help를 요청하면, 공개 Workflow 명령 목록을 출력합니다") { + val root = Files.createTempDirectory("workflow-runtime-help-") + val output = ByteArrayOutputStream() + val command = + WorkflowCli( + createDefaultRuntime( + currentDirectory = root, + environment = mapOf("WORKFLOW_REPO_ROOT" to root.toString()), + commandRunner = RuntimeCommandRunner(root), + ), + stdout = PrintStream(output), + ) + + val result = command.test("--help") + + result.statusCode shouldBe 0 + output.toString() shouldContain "start" + output.toString() shouldContain "review" + output.toString() shouldContain "gate" + } + + test("빈 Store에서 status JSON을 요청하면, 관리 Workspace 없음 실패를 반환합니다") { + val root = Files.createTempDirectory("workflow-runtime-empty-") + val state = root.resolve("state.json") + val gateway = + createDefaultRuntime( + currentDirectory = root, + environment = + mapOf( + "WORKFLOW_REPO_ROOT" to root.toString(), + "WORKFLOW_STATE_FILE" to state.toString(), + ), + commandRunner = RuntimeCommandRunner(root), + ) + + val result = gateway.execute(WorkflowCommandRequest.Status()) + + val failure = + result.shouldBeInstanceOf() + failure.data.code.name shouldBe "WORKSPACE_NOT_FOUND" + result.encodeToString() shouldStartWith "{\"data\":{" + } + } + + context("Store에 현재 Workspace가 저장된 상태에서") { + test("status 요청을 실행하면, 성공 JSON에 현재 SubTask를 포함합니다") { + val root = Files.createTempDirectory("workflow-runtime-status-") + val statePath = root.resolve("state.json") + val workspace = Workspace("ws-1", "sk-101", WorkspacePath(root.toString()), "sk-101") + val subTask = SubTask("sk-101", "task-1", "변경", workspace = workspace) + val task = + Task( + id = "task-1", + externalId = ExternalTaskId("TASK-1"), + title = "작업", + state = TaskState.IN_PROGRESS, + subTaskIds = listOf(subTask.id), + ) + Files.writeString( + statePath, + WorkflowStateJsonCodec.encodeToString( + WorkflowState( + tasks = mapOf(task.id to task), + subTasks = mapOf(subTask.id to subTask), + ) + ), + ) + val gateway = + createDefaultRuntime( + currentDirectory = root, + environment = + mapOf( + "WORKFLOW_REPO_ROOT" to root.toString(), + "WORKFLOW_STATE_FILE" to statePath.toString(), + ), + commandRunner = RuntimeCommandRunner(root), + ) + + val result = gateway.execute(WorkflowCommandRequest.Status()) + + val success = + result.shouldBeInstanceOf< + io.springkit.workflow.domain.WorkflowResult.Success + >() + success.data["task"].toString() shouldBe "\"TASK-1\"" + success.data["subtask"].toString() shouldBe "\"sk-101\"" + success.data["branch"].toString() shouldBe "\"sk-101\"" + success.data["state"].toString() shouldBe "\"DEVELOPMENT\"" + success.data["workspace"].toString() shouldContain "\"path\":\"${root}\"" + success.data["next"].toString() shouldBe "[]" + } + + test("Review가 없는 SubTask에서 review open을 요청하면, Draft PR 결과를 반환합니다") { + val root = Files.createTempDirectory("workflow-runtime-review-") + val statePath = root.resolve("state.json") + val bodyPath = root.resolve("pr.md") + val workspace = Workspace("ws-1", "sk-101", WorkspacePath(root.toString()), "sk-101") + val subTask = SubTask("sk-101", "task-1", "변경", workspace = workspace) + val task = + Task( + id = "task-1", + externalId = ExternalTaskId("TASK-1"), + title = "작업", + subTaskIds = listOf(subTask.id), + ) + Files.writeString( + statePath, + WorkflowStateJsonCodec.encodeToString( + WorkflowState( + tasks = mapOf(task.id to task), + subTasks = mapOf(subTask.id to subTask), + ) + ), + ) + Files.writeString( + bodyPath, + listOf( + "해결하려는 문제", + "왜 지금 해결해야 하는가", + "어떻게 해결했는가", + "한계와 트레이드오프", + "기존 기능에 미치는 영향", + "Edge Case와 실패 시나리오", + "검토한 대안과 선택 이유", + "리뷰 포인트", + ) + .joinToString("\n\n") { heading -> "## $heading\n설명" }, + ) + val commandRunner = RuntimeCommandRunner(root) + val gateway = + createDefaultRuntime( + currentDirectory = root, + environment = + mapOf( + "WORKFLOW_REPO_ROOT" to root.toString(), + "WORKFLOW_STATE_FILE" to statePath.toString(), + "GH_USER" to "agent-1", + "WORKFLOW_ACTOR_KIND" to "AGENT", + ), + commandRunner = commandRunner, + ) + + gateway + .execute(WorkflowCommandRequest.Check) + .shouldBeInstanceOf>() + + val result = + gateway.execute( + WorkflowCommandRequest.ReviewOpen( + bodyFile = bodyPath.toString(), + risk = "normal", + ) + ) + + val success = + result.shouldBeInstanceOf< + io.springkit.workflow.domain.WorkflowResult.Success + >() + success.data["pull_request"].toString() shouldContain "\"id\":\"1\"" + + val stored = WorkflowStateJsonCodec.decode(Files.readString(statePath)) + val pullRequest = requireNotNull(stored.pullRequests["1"]) + val agent = Actor("agent-1", ActorKind.AGENT) + val openThread = + ReviewThread( + id = "thread-open", + level = ReviewLevel.R, + comments = listOf(ReviewComment("comment-open", agent, "[Agent] [R] 열린 의견")), + ) + val resolvedThread = + ReviewThread( + id = "thread-resolved", + level = ReviewLevel.C, + comments = listOf(ReviewComment("comment-resolved", agent, "[Agent] [C] 해결된 의견")), + state = ThreadState.RESOLVED, + ) + Files.writeString( + statePath, + WorkflowStateJsonCodec.encodeToString( + stored.copy( + pullRequests = + stored.pullRequests + + (pullRequest.id to + pullRequest.copy( + reviewRevision = + pullRequest.reviewRevision.copy( + threads = listOf(openThread, resolvedThread) + ) + )) + ) + ), + ) + + val shown = + gateway.execute(WorkflowCommandRequest.ReviewShow(diff = false, threads = "open")) + val shownSuccess = + shown.shouldBeInstanceOf< + io.springkit.workflow.domain.WorkflowResult.Success + >() + shownSuccess.data["subtask"].toString() shouldBe "\"sk-101\"" + shownSuccess.data["diff"] shouldBe null + shownSuccess.data["threads"].toString() shouldContain "thread-open" + shownSuccess.data["threads"].toString() shouldNotContain "thread-resolved" + + val allThreads = + gateway.execute(WorkflowCommandRequest.ReviewShow(diff = true, threads = "all")) + val allThreadsSuccess = + allThreads.shouldBeInstanceOf< + io.springkit.workflow.domain.WorkflowResult.Success + >() + allThreadsSuccess.data["diff"].toString() shouldNotContain "\"identity\":\"head-1\"" + allThreadsSuccess.data["threads"].toString() shouldContain "thread-resolved" + + val reviewBeforeComment = + WorkflowStateJsonCodec.decode(Files.readString(statePath)).pullRequests.getValue("1") + reviewBeforeComment.changeRevision.providerRevision shouldBe "head-1" + val commentResult = + gateway.execute( + WorkflowCommandRequest.ReviewComment( + revision = reviewBeforeComment.reviewRevision.id, + level = "R", + body = "런타임에서 확인했습니다.", + ) + ) + commentResult.shouldBeInstanceOf>() + val reviewAfterComment = + WorkflowStateJsonCodec.decode(Files.readString(statePath)).pullRequests.getValue("1") + reviewAfterComment.reviewRevision.id shouldNotBe reviewBeforeComment.reviewRevision.id + reviewAfterComment.reviewRevision.threads.size shouldBe 3 + + val outsideBody = Files.createTempFile("workflow-body-outside-", ".md") + val invalidResults = + listOf( + gateway.execute( + WorkflowCommandRequest.ReviewOpen( + bodyFile = outsideBody.toString(), + risk = "normal", + ) + ), + gateway.execute( + WorkflowCommandRequest.ReviewUpdate( + revision = pullRequest.reviewRevision.id, + bodyFile = outsideBody.toString(), + ) + ), + gateway.execute( + WorkflowCommandRequest.ReviewComment( + revision = pullRequest.reviewRevision.id, + level = "R", + bodyFile = outsideBody.toString(), + ) + ), + gateway.execute( + WorkflowCommandRequest.ReviewReply( + revision = pullRequest.reviewRevision.id, + thread = openThread.id, + bodyFile = outsideBody.toString(), + ) + ), + ) + + invalidResults.forEach { result -> + result + .shouldBeInstanceOf() + .data + .code shouldBe io.springkit.workflow.domain.FailureCode.INVALID_ARGUMENT + } + } + } + }) + +private class RuntimeCommandRunner(private val repositoryRoot: Path) : CommandRunner { + private var reviewCommentCreated = false + private var pullRequestBody = "PR 본문" + + override fun run(command: List, workingDirectory: Path): CommandResult = + when { + command == listOf("git", "rev-parse", "--show-toplevel") -> + CommandResult(0, repositoryRoot.toString() + "\n", "") + command == listOf("git", "rev-parse", "HEAD") -> CommandResult(0, "head-1\n", "") + command.firstOrNull() == "git" && command.getOrNull(1) == "status" -> + CommandResult(0, "", "") + command.firstOrNull() == "git" && command.getOrNull(1) == "diff" -> CommandResult(0, "", "") + command.firstOrNull() == "git" && command.getOrNull(1) == "ls-files" -> + CommandResult(0, "", "") + command.firstOrNull() == "git" && command.getOrNull(1) == "hash-object" -> + CommandResult(0, "hash-1\n", "") + command.firstOrNull() == "gh" && + command.getOrNull(1) == "pr" && + command.getOrNull(2) == "create" -> { + val bodyIndex = command.indexOf("--body") + if (bodyIndex >= 0) { + pullRequestBody = command.getOrNull(bodyIndex + 1) ?: pullRequestBody + } + CommandResult(0, "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/example/repository/pull/1\n", "") + } + command == listOf("gh", "api", "user", "--jq", ".login") -> + CommandResult(0, "agent-1\n", "") + command.firstOrNull() == "gh" && + command.getOrNull(1) == "pr" && + command.getOrNull(2) == "view" && + command.contains("--json") && + command.getOrNull(command.indexOf("--json") + 1) == "author" -> + CommandResult(0, """{"author":{"login":"agent-1"}}""", "") + command.firstOrNull() == "gh" && + command.getOrNull(1) == "pr" && + command.getOrNull(2) == "view" -> + CommandResult( + 0, + pullRequestJson(), + "", + ) + command.firstOrNull() == "gh" && + command.getOrNull(1) == "api" && + command.getOrNull(2) == "graphql" -> CommandResult(0, reviewThreadsJson(), "") + command.firstOrNull() == "gh" && + command.getOrNull(1) == "api" && + command.getOrNull(2) == "--method" && + command.getOrNull(3) == "POST" -> { + reviewCommentCreated = true + CommandResult(0, "{\"id\":123,\"node_id\":\"node-comment-1\"}", "") + } + command.firstOrNull() == "gh" && command.getOrNull(1) == "pr" -> CommandResult(0, "[]", "") + else -> CommandResult(0, "", "") + } + + private fun pullRequestJson(): String { + val bodyJson = kotlinx.serialization.json.JsonPrimitive(pullRequestBody).toString() + val comments = + if (reviewCommentCreated) { + """[{"id":"comment-1","databaseId":123,"node_id":"node-comment-1","body":"[Agent] [R] 런타임에서 확인했습니다.","author":{"login":"agent-1"}}]""" + } else { + "[]" + } + return """ + {"number":1,"title":"[sk-101] 변경","body":$bodyJson,"state":"OPEN","isDraft":true,"baseRefName":"main","headRefName":"sk-101","headRefOid":"head-1","comments":$comments,"reviews":[],"statusCheckRollup":[]} + """ + .trimIndent() + } + + private fun reviewThreadsJson(): String { + return """ + [{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[{"id":"thread-open","isResolved":false,"comments":{"nodes":[{"id":"comment-open","body":"[Agent] [R] 열린 의견","author":{"login":"agent-1"}}]}},{"id":"thread-resolved","isResolved":true,"comments":{"nodes":[{"id":"comment-resolved","body":"[Agent] [C] 해결된 의견","author":{"login":"agent-1"}}]}}],"pageInfo":{"hasNextPage":false,"endCursor":null}}}}},"errors":[]}] + """ + .trimIndent() + } +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/WorkflowApplicationRuntimeTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/WorkflowApplicationRuntimeTest.kt new file mode 100644 index 00000000..1f3d70ec --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/WorkflowApplicationRuntimeTest.kt @@ -0,0 +1,623 @@ +package io.springkit.workflow.runtime + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.adapter.store.WorkflowStateJsonCodec +import io.springkit.workflow.application.AcknowledgeEventRequest +import io.springkit.workflow.application.AcknowledgeEventResponse +import io.springkit.workflow.application.ExternalEvent +import io.springkit.workflow.application.ExternalEventKind +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.ReceiveEventRequest +import io.springkit.workflow.application.ReceiveEventResponse +import io.springkit.workflow.application.WorkflowEventPort +import io.springkit.workflow.common.CommandResult +import io.springkit.workflow.common.CommandRunner +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.DeploymentCandidate +import io.springkit.workflow.domain.DeploymentCandidateState +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.EventLog +import io.springkit.workflow.domain.ExternalTaskId +import io.springkit.workflow.domain.Integration +import io.springkit.workflow.domain.IntegrationState +import io.springkit.workflow.domain.MergeQueueEntry +import io.springkit.workflow.domain.MergeQueueState +import io.springkit.workflow.domain.MergeRecorded +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.PullRequestState +import io.springkit.workflow.domain.Release +import io.springkit.workflow.domain.ReleaseState +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.SubTaskState +import io.springkit.workflow.domain.Task +import io.springkit.workflow.domain.Validation +import io.springkit.workflow.domain.ValidationStatus +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.WorkflowState +import java.nio.file.Files +import java.nio.file.Path +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +class WorkflowApplicationRuntimeTest : + FunSpec({ + context("기본 런타임으로 이벤트를 처리할 때") { + test("MERGE_QUEUE_CHANGED 이벤트를 수신하면, 통합과 후속 cleanup에 연결합니다") { + val root = Files.createTempDirectory("workflow-runtime-merge-event-") + val statePath = root.resolve("state.json") + val subTaskId = "sk-101" + val pullRequestId = "17" + val task = + Task( + id = "task-1", + externalId = ExternalTaskId("TASK-1"), + title = "작업", + subTaskIds = listOf(subTaskId), + ) + val subTask = + SubTask( + id = subTaskId, + taskId = task.id, + title = "변경", + state = SubTaskState.QUEUED, + pullRequestId = pullRequestId, + ) + val changeRevision = + ChangeRevision( + id = "change-1", + number = 1, + diff = Diff(identity = "diff-1"), + providerRevision = MERGE_QUEUE_HEAD_SHA, + ) + val pullRequest = + PullRequest( + id = pullRequestId, + subTaskId = subTaskId, + title = "[SK-101] 변경", + body = "변경 내용", + base = "main", + state = PullRequestState.QUEUED, + reviewRevision = ReviewRevision("review-1", 1, "리뷰 본문"), + changeRevision = changeRevision, + ) + val mergeQueue = + MergeQueueEntry( + id = "queue-1", + subTaskId = subTaskId, + pullRequestId = pullRequest.id, + changeRevisionId = changeRevision.id, + providerRevision = MERGE_QUEUE_HEAD_SHA, + state = MergeQueueState.PASSED, + ) + Files.writeString( + statePath, + WorkflowStateJsonCodec.encodeToString( + WorkflowState( + tasks = mapOf(task.id to task), + subTasks = mapOf(subTask.id to subTask), + pullRequests = mapOf(pullRequest.id to pullRequest), + mergeQueue = mapOf(mergeQueue.id to mergeQueue), + ) + ), + ) + val runner = MergeQueueEventCommandRunner(root) + val eventPort = RecordingWorkflowEventPort() + val runtime = + createDefaultApplicationRuntime( + currentDirectory = root, + environment = + mapOf( + "WORKFLOW_REPO_ROOT" to root.toString(), + "WORKFLOW_STATE_FILE" to statePath.toString(), + ), + commandRunner = runner, + eventPort = eventPort, + ) + val event = + ExternalEvent( + id = "merge-event-1", + kind = ExternalEventKind.MERGE_QUEUE_CHANGED, + targetId = subTaskId, + occurredAtEpochMillis = 1, + attributes = + mapOf( + "merge_queue_entry_id" to mergeQueue.id, + "change_revision_id" to changeRevision.id, + ), + ) + + val result = runtime.eventUseCases.handle(ReceiveEventRequest(event)) + + result.shouldBeInstanceOf>() + eventPort.acknowledged shouldBe AcknowledgeEventRequest(event.id, true, null) + runner.mergeCalls shouldBe 1 + val stored = WorkflowStateJsonCodec.decode(Files.readString(statePath)) + stored.subTasks[subTaskId]?.state shouldBe SubTaskState.MERGED + stored.pullRequests[pullRequest.id]?.state shouldBe PullRequestState.MERGED + stored.integrations[subTaskId]?.mainRevision shouldBe "main-1" + stored.deploymentCandidates shouldBe emptyMap() + } + + test("MAIN_MERGED 이벤트를 수신하면, 병합 후 정리 유스케이스로 연결합니다") { + val eventPort = RecordingWorkflowEventPort() + val runtime = + createDefaultApplicationRuntime( + currentDirectory = Files.createTempDirectory("workflow-runtime-event-"), + environment = emptyMap(), + commandRunner = RuntimeCommandRunnerForComposition, + eventPort = eventPort, + ) + + val result = + runtime.eventUseCases.handle( + ReceiveEventRequest( + ExternalEvent( + id = "merge-event-1", + kind = ExternalEventKind.MAIN_MERGED, + targetId = "sk-101", + occurredAtEpochMillis = 1, + ) + ) + ) + + val failure = result.shouldBeInstanceOf() + failure.data.code.name shouldBe "SUBTASK_NOT_FOUND" + eventPort.acknowledged shouldBe + AcknowledgeEventRequest("merge-event-1", false, failure.data.message) + } + + test("AI_REVIEW_CHANGED 이벤트를 수신하면, INVALID_ARGUMENT를 반환합니다") { + val eventPort = RecordingWorkflowEventPort() + val runtime = + createDefaultApplicationRuntime( + currentDirectory = Files.createTempDirectory("workflow-runtime-ai-event-"), + environment = emptyMap(), + commandRunner = RuntimeCommandRunnerForComposition, + eventPort = eventPort, + ) + + val result = + runtime.eventUseCases.handle( + ReceiveEventRequest( + ExternalEvent( + id = "ai-event-1", + kind = ExternalEventKind.AI_REVIEW_CHANGED, + targetId = "sk-101", + occurredAtEpochMillis = 1, + ) + ) + ) + + val failure = result.shouldBeInstanceOf() + failure.data.code.name shouldBe "INVALID_ARGUMENT" + eventPort.acknowledged?.accepted shouldBe false + } + + test("provider가 구성되면, DEPLOYMENT_CHANGED 이벤트를 Deployment lifecycle로 전달합니다") { + val root = Files.createTempDirectory("workflow-runtime-deployment-event-") + val eventPort = RecordingWorkflowEventPort() + val runtime = + createDefaultApplicationRuntime( + currentDirectory = root, + environment = + mapOf( + "WORKFLOW_REPO_ROOT" to root.toString(), + "WORKFLOW_DEPLOYMENT_COMMAND" to "[\"deploy\"]", + "WORKFLOW_RELEASE_COMMAND" to "[\"release\"]", + ), + commandRunner = RuntimeCommandRunnerForComposition, + eventPort = eventPort, + ) + + val event = + ExternalEvent( + id = "deployment-event-1", + kind = ExternalEventKind.DEPLOYMENT_CHANGED, + targetId = "candidate-1", + occurredAtEpochMillis = 1, + ) + val result = runtime.eventUseCases.handle(ReceiveEventRequest(event)) + + val failure = result.shouldBeInstanceOf() + failure.data.code.name shouldBe "DEPLOYMENT_NOT_FOUND" + eventPort.acknowledged shouldBe + AcknowledgeEventRequest(event.id, false, failure.data.message) + } + + test("provider가 미구성되면, DEPLOYMENT_CHANGED 이벤트를 지원하지 않습니다") { + val eventPort = RecordingWorkflowEventPort() + val runtime = + createDefaultApplicationRuntime( + currentDirectory = + Files.createTempDirectory("workflow-runtime-deployment-disabled-"), + environment = emptyMap(), + commandRunner = RuntimeCommandRunnerForComposition, + eventPort = eventPort, + ) + val event = + ExternalEvent( + id = "deployment-event-2", + kind = ExternalEventKind.DEPLOYMENT_CHANGED, + targetId = "candidate-1", + occurredAtEpochMillis = 1, + ) + + val result = runtime.eventUseCases.handle(ReceiveEventRequest(event)) + + result.shouldBeInstanceOf().data.code.name shouldBe + "INVALID_ARGUMENT" + eventPort.acknowledged?.accepted shouldBe false + } + + test("Production candidate에 SubTask가 여러 개여도, Release를 하나만 생성합니다") { + val root = Files.createTempDirectory("workflow-runtime-release-all-") + val statePath = root.resolve("state.json") + val state = productionState(includedSubTaskCount = 2) + Files.writeString(statePath, WorkflowStateJsonCodec.encodeToString(state)) + val runner = RuntimeProviderCommandRunner(root) + val eventPort = RecordingWorkflowEventPort() + val runtime = providerRuntime(root, statePath, runner, eventPort) + + val result = + runtime.eventUseCases.handle( + ReceiveEventRequest( + ExternalEvent( + id = "deployment-production-1", + kind = ExternalEventKind.DEPLOYMENT_CHANGED, + targetId = "candidate-1", + occurredAtEpochMillis = 1, + ) + ) + ) + + result.shouldBeInstanceOf>() + eventPort.acknowledged?.accepted shouldBe true + runner.operationCalls("create-release") shouldBe 1 + runner.operationCalls("validate-release") shouldBe 1 + WorkflowStateJsonCodec.decode(Files.readString(statePath)).releases.values.map { + it.candidateId + } shouldBe listOf("candidate-1") + } + + test("RELEASE_CHANGED가 ROLLOUT이면, Release provider의 rollout을 계속 실행합니다") { + val root = Files.createTempDirectory("workflow-runtime-release-rollout-") + val statePath = root.resolve("state.json") + val state = productionState(releaseState = ReleaseState.ROLLOUT) + Files.writeString(statePath, WorkflowStateJsonCodec.encodeToString(state)) + val runner = RuntimeProviderCommandRunner(root) + val eventPort = RecordingWorkflowEventPort() + val runtime = providerRuntime(root, statePath, runner, eventPort) + + val result = + runtime.eventUseCases.handle( + ReceiveEventRequest( + ExternalEvent( + id = "release-rollout-1", + kind = ExternalEventKind.RELEASE_CHANGED, + targetId = "release-1", + occurredAtEpochMillis = 2, + attributes = mapOf("state" to "ROLLOUT"), + ) + ) + ) + + result.shouldBeInstanceOf>() + runner.operationCalls("continue-rollout") shouldBe 1 + WorkflowStateJsonCodec.decode(Files.readString(statePath)) + .releases["release-1"] + ?.state shouldBe ReleaseState.CLEANUP_REQUIRED + } + + test("Production 전환 뒤 후속 merge가 있으면, 최신 main revision으로 다음 후보를 만듭니다") { + val root = Files.createTempDirectory("workflow-runtime-next-candidate-") + val statePath = root.resolve("state.json") + val state = productionState(includeNextMerge = true) + Files.writeString(statePath, WorkflowStateJsonCodec.encodeToString(state)) + val runner = RuntimeProviderCommandRunner(root) + val eventPort = RecordingWorkflowEventPort() + val runtime = providerRuntime(root, statePath, runner, eventPort) + val event = + ExternalEvent( + id = "deployment-production-2", + kind = ExternalEventKind.CANARY_CHANGED, + targetId = "candidate-1", + occurredAtEpochMillis = 3, + attributes = mapOf("outcome" to "SUCCEEDED"), + ) + + runtime.eventUseCases.handle(ReceiveEventRequest(event)) + runtime.eventUseCases.handle( + ReceiveEventRequest(event.copy(id = "deployment-production-3")) + ) + + val stored = WorkflowStateJsonCodec.decode(Files.readString(statePath)) + stored.deploymentCandidates.values.map { it.mainRevision } shouldContain "main-2" + runner.operationCalls("create-candidate") shouldBe 1 + } + + test("Production 뒤 event log가 일부만 남으면, integrations에서 후속 merge를 찾아 후보를 만듭니다") { + val root = Files.createTempDirectory("workflow-runtime-partial-event-log-") + val statePath = root.resolve("state.json") + val state = productionState(includeNextMerge = true, partialEventLog = true) + Files.writeString(statePath, WorkflowStateJsonCodec.encodeToString(state)) + val runner = RuntimeProviderCommandRunner(root) + val runtime = providerRuntime(root, statePath, runner, RecordingWorkflowEventPort()) + + val result = + runtime.eventUseCases.handle( + ReceiveEventRequest( + ExternalEvent( + id = "deployment-production-partial-1", + kind = ExternalEventKind.DEPLOYMENT_CHANGED, + targetId = "candidate-1", + occurredAtEpochMillis = 4, + ) + ) + ) + + result.shouldBeInstanceOf>() + WorkflowStateJsonCodec.decode(Files.readString(statePath)) + .deploymentCandidates + .values + .map { it.mainRevision } shouldContain "main-2" + runner.operationCalls("create-candidate") shouldBe 1 + } + } + }) + +private fun providerRuntime( + root: Path, + statePath: Path, + runner: RuntimeProviderCommandRunner, + eventPort: WorkflowEventPort, +): WorkflowApplicationRuntime = + createDefaultApplicationRuntime( + currentDirectory = root, + environment = + mapOf( + "WORKFLOW_REPO_ROOT" to root.toString(), + "WORKFLOW_STATE_FILE" to statePath.toString(), + "WORKFLOW_DEPLOYMENT_COMMAND" to "[\"deploy\"]", + "WORKFLOW_RELEASE_COMMAND" to "[\"release\"]", + ), + commandRunner = runner, + eventPort = eventPort, + ) + +private fun productionState( + includedSubTaskCount: Int = 1, + releaseState: ReleaseState? = null, + includeNextMerge: Boolean = false, + partialEventLog: Boolean = false, +): WorkflowState { + val currentSubTask = + (1..includedSubTaskCount).map { index -> + SubTask( + id = "sk-current-$index", + taskId = "task-1", + title = "현재 변경 $index", + state = SubTaskState.MERGED, + ) + } + val nextSubTask = + if (includeNextMerge) { + SubTask( + id = "sk-next", + taskId = "task-1", + title = "다음 변경", + state = SubTaskState.MERGED, + ) + } else { + null + } + val allSubTasks = currentSubTask + listOfNotNull(nextSubTask) + val task = + Task( + id = "task-1", + externalId = ExternalTaskId("TASK-1"), + title = "배포 작업", + subTaskIds = allSubTasks.map { it.id }, + ) + val candidate = + DeploymentCandidate( + id = "candidate-1", + mainRevision = "main-1", + includedSubTasks = currentSubTask.map { it.id }, + validations = listOf(Validation("validation-1", "검증", ValidationStatus.PASSED)), + state = DeploymentCandidateState.PRODUCTION, + ) + val release = releaseState?.let { + Release( + id = "release-1", + candidateId = candidate.id, + state = it, + productionReady = true, + internalValidationPassed = true, + ) + } + val merges = + listOf(MergeRecorded(currentSubTask.first().id, "main-1")) + + if (includeNextMerge && !partialEventLog) { + listOf(MergeRecorded("sk-next", "main-2")) + } else { + emptyList() + } + val integrations = + currentSubTask + .map { Integration(it.id, IntegrationState.MERGED, mainRevision = "main-1") } + .plus( + if (includeNextMerge) { + listOf(Integration("sk-next", IntegrationState.MERGED, mainRevision = "main-2")) + } else { + emptyList() + } + ) + return WorkflowState( + tasks = mapOf(task.id to task), + subTasks = allSubTasks.associateBy(SubTask::id), + deploymentCandidates = mapOf(candidate.id to candidate), + releases = release?.let { mapOf(it.id to it) }.orEmpty(), + integrations = integrations.associateBy(Integration::subTaskId), + eventLog = EventLog(events = merges), + ) +} + +private class RuntimeProviderCommandRunner(private val repositoryRoot: Path) : CommandRunner { + private data class CandidatePayload( + val id: String, + val mainRevision: String, + val includedSubTasks: String, + val risks: String, + ) + + private val commands = mutableListOf>() + private val candidates = mutableMapOf() + private val releases = mutableMapOf() + + fun operationCalls(operation: String): Int = commands.count { it.getOrNull(1) == operation } + + override fun run(command: List, workingDirectory: Path): CommandResult { + if (command == listOf("git", "rev-parse", "--show-toplevel")) { + return CommandResult(0, repositoryRoot.toString() + "\n", "") + } + commands += command + return when { + command.firstOrNull() == "deploy" && command.getOrNull(1) == "create-candidate" -> + createCandidate(command) + command.firstOrNull() == "deploy" && command.getOrNull(1) == "validate-candidate" -> + validateCandidate(command) + command.firstOrNull() == "release" && command.getOrNull(1) == "create-release" -> + release(command, ReleaseState.SAFE_DEFAULT) + command.firstOrNull() == "release" && command.getOrNull(1) == "validate-release" -> + release(command, ReleaseState.AWAITING_RELEASE_APPROVAL, true) + command.firstOrNull() == "release" && command.getOrNull(1) == "continue-rollout" -> + release(command, ReleaseState.RELEASED, true) + else -> CommandResult(0, "{}", "") + } + } + + private fun createCandidate(command: List): CommandResult { + val payload = Json.parseToJsonElement(command.last()).jsonObject + val revision = payload.getValue("main_revision").jsonPrimitive.content + val candidate = + CandidatePayload( + id = "candidate-$revision", + mainRevision = revision, + includedSubTasks = payload.getValue("included_subtasks").jsonArray.toString(), + risks = payload.getValue("risks").jsonObject.toString(), + ) + candidates[candidate.id] = candidate + return CommandResult(0, candidateResponse(candidate, DeploymentCandidateState.CANDIDATE), "") + } + + private fun validateCandidate(command: List): CommandResult { + val payload = Json.parseToJsonElement(command.last()).jsonObject + val id = payload.getValue("candidate_id").jsonPrimitive.content + return CommandResult( + 0, + candidateResponse(requireNotNull(candidates[id]), DeploymentCandidateState.CANDIDATE), + "", + ) + } + + private fun candidateResponse( + candidate: CandidatePayload, + state: DeploymentCandidateState, + ): String = + """{"candidate":{"id":"${candidate.id}","main_revision":"${candidate.mainRevision}","included_subtasks":${candidate.includedSubTasks},"risks":${candidate.risks},"state":"${state.name}"}}""" + + private fun release( + command: List, + state: ReleaseState, + validated: Boolean = false, + ): CommandResult { + val payload = Json.parseToJsonElement(command.last()).jsonObject + val releaseId = payload.getValue("release_id").jsonPrimitive.content + val candidateId = + if (command.getOrNull(1) == "create-release") { + payload.getValue("candidate_id").jsonPrimitive.content.also { + releases[releaseId] = it + } + } else { + releases[releaseId] ?: "candidate-1" + } + return CommandResult( + 0, + """{"release":{"release_id":"$releaseId","candidate_id":"$candidateId","state":"${state.name}","production_ready":$validated,"internal_validation_passed":$validated}}""", + "", + ) + } +} + +private const val MERGE_QUEUE_HEAD_SHA = "0123456789abcdef0123456789abcdef01234567" + +private object RuntimeCommandRunnerForComposition : CommandRunner { + override fun run(command: List, workingDirectory: Path): CommandResult = + if (command == listOf("git", "rev-parse", "--show-toplevel")) { + CommandResult(0, workingDirectory.toString() + "\n", "") + } else { + CommandResult(0, "", "") + } +} + +private class RecordingWorkflowEventPort : WorkflowEventPort { + var acknowledged: AcknowledgeEventRequest? = null + + override fun receive(request: ReceiveEventRequest): PortResult = + PortResult.Success(ReceiveEventResponse(request.event, accepted = true)) + + override fun acknowledge(request: AcknowledgeEventRequest): PortResult { + acknowledged = request + return PortResult.Success(AcknowledgeEventResponse(request.eventId)) + } +} + +private class MergeQueueEventCommandRunner(private val root: Path) : CommandRunner { + var mergeCalls: Int = 0 + private set + + private var pullRequestViewCalls: Int = 0 + + override fun run(command: List, workingDirectory: Path): CommandResult { + if (command == listOf("git", "rev-parse", "--show-toplevel")) { + return CommandResult(0, root.toString() + "\n", "") + } + if (command.take(4) == listOf("gh", "api", "--paginate", "--slurp")) { + return CommandResult(0, """[[{"type":"merge_queue"}]]""", "") + } + if (command.firstOrNull() == "gh" && command.getOrNull(1) == "pr") { + if (command.getOrNull(2) == "merge") { + mergeCalls += 1 + return CommandResult(0, "", "") + } + if (command.getOrNull(2) == "view") { + pullRequestViewCalls += 1 + return CommandResult( + 0, + if (pullRequestViewCalls >= 3) { + """ + {"number":17,"state":"MERGED","headRefName":"sk-101","headRefOid":"$MERGE_QUEUE_HEAD_SHA","mergeCommit":{"oid":"main-1"}} + """ + .trimIndent() + } else { + """ + {"number":17,"state":"OPEN","mergeStateStatus":"CLEAN","baseRefName":"main","headRefName":"sk-101","headRefOid":"$MERGE_QUEUE_HEAD_SHA","statusCheckRollup":[]} + """ + .trimIndent() + }, + "", + ) + } + } + if (command == listOf("git", "rev-parse", "refs/remotes/origin/main")) { + return CommandResult(0, "main-1\n", "") + } + return CommandResult(0, "", "") + } +} diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/WorkflowCommandGatewayAccountMismatchTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/WorkflowCommandGatewayAccountMismatchTest.kt new file mode 100644 index 00000000..1880627b --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/WorkflowCommandGatewayAccountMismatchTest.kt @@ -0,0 +1,207 @@ +package io.springkit.workflow.runtime + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContainExactly +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeTypeOf +import io.springkit.workflow.adapter.cli.WorkflowCommandRequest +import io.springkit.workflow.application.AuthorizeRequest +import io.springkit.workflow.application.AuthorizeResponse +import io.springkit.workflow.application.CurrentActorRequest +import io.springkit.workflow.application.CurrentActorResponse +import io.springkit.workflow.application.DeliveryGateUseCases +import io.springkit.workflow.application.IdentityPort +import io.springkit.workflow.application.PortError +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.ReviewGateUseCases +import io.springkit.workflow.application.ReviewLifecycleUseCases +import io.springkit.workflow.application.ReviewPort +import io.springkit.workflow.application.ReviewUseCases +import io.springkit.workflow.application.StackSyncUseCases +import io.springkit.workflow.application.StartCheckUseCases +import io.springkit.workflow.application.StatusUseCase +import io.springkit.workflow.application.WorkflowStorePort +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.FailureCode +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.WorkspacePath +import java.lang.reflect.Proxy + +class WorkflowCommandGatewayAccountMismatchTest : + FunSpec({ + test("기존 PR 변경 명령은 application 부작용 전에 계정 비교를 수행합니다") { + val checks = mutableListOf>() + val gateway = + accountMismatchGateway( + check = { subTaskId, allow -> + checks += subTaskId to allow + PortResult.Failure( + PortError( + "GITHUB_ACCOUNT_MISMATCH", + "현재 gh 사용자와 PR 작성자가 다릅니다.", + target = subTaskId, + ) + ) + } + ) + val requests = + listOf( + WorkflowCommandRequest.ReviewUpdate("rv-8", allowAccountMismatch = true), + WorkflowCommandRequest.ReviewComment( + "rv-8", + "R", + body = "의견", + allowAccountMismatch = true, + ), + WorkflowCommandRequest.ReviewReply( + "rv-8", + "thread-1", + body = "답변", + allowAccountMismatch = true, + ), + WorkflowCommandRequest.ReviewResolve( + "rv-8", + "thread-1", + allowAccountMismatch = true, + ), + WorkflowCommandRequest.Stack(requires = "sk-parent", allowAccountMismatch = true), + WorkflowCommandRequest.Sync(continueSync = true, allowAccountMismatch = true), + WorkflowCommandRequest.GateReady("sk-target", "rv-8", allowAccountMismatch = true), + WorkflowCommandRequest.GateApprove( + "sk-target", + "cr-4", + allowAccountMismatch = true, + ), + ) + + requests.forEach { request -> + gateway.execute(request).shouldBeTypeOf().data.code shouldBe + FailureCode.EXTERNAL_FAILURE + } + + checks shouldContainExactly + listOf( + "sk-current" to true, + "sk-current" to true, + "sk-current" to true, + "sk-current" to true, + "sk-current" to true, + "sk-current" to true, + "sk-target" to true, + "sk-target" to true, + ) + } + + test("override 불일치 경고를 보내고 기존 HUMAN gate는 계속 적용합니다") { + val warnings = mutableListOf() + val humanOnlyIdentity = + object : IdentityPort { + override fun currentActor( + request: CurrentActorRequest + ): PortResult = + PortResult.Success(CurrentActorResponse(Actor("agent-1", ActorKind.AGENT))) + + override fun authorize(request: AuthorizeRequest): PortResult = + PortResult.Success( + AuthorizeResponse( + allowed = false, + reason = "사람 Gate는 HUMAN principal만 수행할 수 있습니다.", + ) + ) + } + val warning = + "현재 gh 사용자 'agent-1'와 PR 작성자 'Jeong-Rae'가 다릅니다. " + + "--allow-account-mismatch로 해당 불일치를 허용해 PR 변경을 계속합니다." + val gateway = + accountMismatchGateway( + check = { _, _ -> PortResult.Success(warning) }, + warningSink = warnings::add, + identityPort = humanOnlyIdentity, + ) + + val result = + gateway + .execute( + WorkflowCommandRequest.GateReady( + "sk-target", + "rv-8", + allowAccountMismatch = true, + ) + ) + .shouldBeTypeOf() + + warnings shouldContainExactly listOf(warning) + result.data.code shouldBe FailureCode.HUMAN_REQUIRED + } + }) + +private fun accountMismatchGateway( + check: (String, Boolean) -> PortResult, + warningSink: (String) -> Unit = {}, + identityPort: IdentityPort = proxyPort(), +): WorkflowCommandGateway { + val reviewPort = proxyPort() + val storePort = proxyPort() + val context = + object : WorkflowRuntimeContextResolver { + override fun currentWorkspaceContext() = + PortResult.Success( + WorkflowWorkspaceContext("workspace-1", "sk-current", WorkspacePath("/workspace")) + ) + + override fun currentReview() = + PortResult.Success( + WorkflowReviewContext( + "pr-current", + "sk-current", + "변경", + "develop", + "feature/sk-current", + io.springkit.workflow.domain.ReviewRevision("rv-current", 1, "본문"), + io.springkit.workflow.domain.ChangeRevision( + "cr-current", + 1, + io.springkit.workflow.domain.Diff("diff-current"), + ), + ) + ) + + override fun currentActor(requestId: String) = + PortResult.Success(Actor("agent-1", ActorKind.AGENT)) + } + + return WorkflowCommandGateway( + startCheck = + StartCheckUseCases( + proxyPort(), + proxyPort(), + proxyPort(), + proxyPort(), + proxyPort(), + projectPrefix = "sk", + ), + review = ReviewUseCases(reviewPort, storePort = storePort), + reviewLifecycle = + ReviewLifecycleUseCases( + proxyPort(), + proxyPort(), + proxyPort(), + reviewPort, + proxyPort(), + storePort = storePort, + ), + stackSync = StackSyncUseCases(proxyPort(), reviewPort, storePort), + status = StatusUseCase(proxyPort()), + reviewGate = ReviewGateUseCases(identityPort, reviewPort, storePort), + deliveryGate = DeliveryGateUseCases(proxyPort(), proxyPort(), proxyPort(), proxyPort()), + context = context, + reviewAccountMismatchCheck = check, + reviewAccountMismatchWarning = warningSink, + ) +} + +private inline fun proxyPort(): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, _ -> + throw UnsupportedOperationException("Unexpected port call: ${method.name}") + } as T diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/WorkflowCommandGatewayJsonTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/WorkflowCommandGatewayJsonTest.kt new file mode 100644 index 00000000..beb92bff --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/WorkflowCommandGatewayJsonTest.kt @@ -0,0 +1,59 @@ +package io.springkit.workflow.runtime + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.springkit.workflow.application.StackResponse +import io.springkit.workflow.application.SyncResponse +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.Workspace +import io.springkit.workflow.domain.WorkspacePath + +class WorkflowCommandGatewayJsonTest : + FunSpec({ + context("stack 성공 결과를 JSON으로 변환할 때") { + test("실제 diff가 바뀌면, invalidated_state에 무효화된 상태를 반환합니다") { + val response = responseStack(invalidatedState = listOf("change_revision", "approval")) + + stackResponseJson(response)["invalidated_state"].toString() shouldBe + "[\"change_revision\",\"approval\"]" + } + } + + context("sync 성공 결과를 JSON으로 변환할 때") { + test("diff가 유지되면, 빈 invalidated_state를 반환합니다") { + val response = responseSync(invalidatedState = emptyList()) + + syncResponseJson(response)["invalidated_state"].toString() shouldBe "[]" + } + } + }) + +private fun responseStack(invalidatedState: List): StackResponse { + val subTask = responseSubTask() + return StackResponse( + subTask = subTask, + workspace = requireNotNull(subTask.workspace), + baseBranch = "main", + baseRevision = "main-1", + invalidatedState = invalidatedState, + ) +} + +private fun responseSync(invalidatedState: List): SyncResponse { + val subTask = responseSubTask() + return SyncResponse( + subTask = subTask, + workspace = requireNotNull(subTask.workspace), + baseBranch = "main", + baseRevision = "main-1", + invalidatedState = invalidatedState, + ) +} + +private fun responseSubTask(): SubTask = + SubTask( + id = "sk-101", + taskId = "task-1", + title = "변경", + workspace = Workspace("ws-101", "sk-101", WorkspacePath("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/repo/sk-101"), "sk-101"), + ) diff --git a/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/WorkflowCommandGatewayReplyTest.kt b/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/WorkflowCommandGatewayReplyTest.kt new file mode 100644 index 00000000..aea8c0b9 --- /dev/null +++ b/tools/workflow/src/test/kotlin/io/springkit/workflow/runtime/WorkflowCommandGatewayReplyTest.kt @@ -0,0 +1,333 @@ +package io.springkit.workflow.runtime + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import io.springkit.workflow.adapter.cli.WorkflowCommandRequest +import io.springkit.workflow.adapter.store.OkioWorkflowStoreAdapter +import io.springkit.workflow.application.AddReviewCommentRequest +import io.springkit.workflow.application.AddReviewCommentResponse +import io.springkit.workflow.application.ApproveReviewRequest +import io.springkit.workflow.application.ApproveReviewResponse +import io.springkit.workflow.application.ChangeReceipt +import io.springkit.workflow.application.DeliveryGateUseCases +import io.springkit.workflow.application.GetReviewRequest +import io.springkit.workflow.application.GetReviewResponse +import io.springkit.workflow.application.OpenReviewRequest +import io.springkit.workflow.application.OpenReviewResponse +import io.springkit.workflow.application.PortResult +import io.springkit.workflow.application.ReadyReviewRequest +import io.springkit.workflow.application.ReadyReviewResponse +import io.springkit.workflow.application.ReplyReviewThreadRequest +import io.springkit.workflow.application.ReplyReviewThreadResponse +import io.springkit.workflow.application.ResolveReviewThreadRequest +import io.springkit.workflow.application.ResolveReviewThreadResponse +import io.springkit.workflow.application.ReviewGateUseCases +import io.springkit.workflow.application.ReviewLifecycleUseCases +import io.springkit.workflow.application.ReviewPort +import io.springkit.workflow.application.ReviewUseCases +import io.springkit.workflow.application.StackSyncUseCases +import io.springkit.workflow.application.StartCheckUseCases +import io.springkit.workflow.application.StatusUseCase +import io.springkit.workflow.application.StoreScope +import io.springkit.workflow.application.StoreSnapshotRequest +import io.springkit.workflow.application.StoreSnapshotResponse +import io.springkit.workflow.application.StoreTransactionRequest +import io.springkit.workflow.application.StoreWriteRequest +import io.springkit.workflow.application.UpdateReviewRequest +import io.springkit.workflow.application.UpdateReviewResponse +import io.springkit.workflow.application.WorkflowStorePort +import io.springkit.workflow.application.WorkflowStoreSnapshot +import io.springkit.workflow.domain.Actor +import io.springkit.workflow.domain.ActorKind +import io.springkit.workflow.domain.ChangeRevision +import io.springkit.workflow.domain.Diff +import io.springkit.workflow.domain.PullRequest +import io.springkit.workflow.domain.ReviewComment +import io.springkit.workflow.domain.ReviewLevel +import io.springkit.workflow.domain.ReviewRevision +import io.springkit.workflow.domain.ReviewThread +import io.springkit.workflow.domain.SubTask +import io.springkit.workflow.domain.WorkflowResult +import io.springkit.workflow.domain.WorkspacePath +import java.lang.reflect.Proxy +import okio.Path.Companion.toPath +import okio.fakefilesystem.FakeFileSystem + +class WorkflowCommandGatewayReplyTest : + FunSpec({ + context("같은 review reply CLI 요청을 다시 실행하면") { + test("POST 후 조회가 실패해 Store가 롤백되어도, 재시도는 POST 없이 원격 답글을 복구합니다") { + val store = + OkioWorkflowStoreAdapter(FakeFileSystem(), "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/workflow/reply-retry.json".toPath()) + val fixture = replyGateway(retryAfterPostFailure = true, storePort = store) + val request = WorkflowCommandRequest.ReviewReply("rv-1", "thread-1", body = "답변입니다.") + + fixture.gateway.execute(request).shouldBeInstanceOf() + val beforeRetry = + (store.snapshot(StoreSnapshotRequest(StoreScope.ALL)) + as PortResult.Success) + .value + .snapshot + .pullRequests + .single() + beforeRetry.reviewRevision.id shouldBe "rv-1" + + fixture.gateway.execute(request).shouldBeInstanceOf>() + + val afterRetry = + (store.snapshot(StoreSnapshotRequest(StoreScope.ALL)) + as PortResult.Success) + .value + .snapshot + .pullRequests + .single() + afterRetry.reviewRevision.id shouldBe "rv-next" + fixture.replies shouldHaveSize 1 + fixture.recoverCalls shouldHaveSize 1 + } + + test( + "같은 pull request, revision, thread, 작성자와 본문에 같은 comment ID를 사용하고 Store ID를 발급하지 않습니다" + ) { + val fixture = replyGateway() + val request = + WorkflowCommandRequest.ReviewReply( + revision = "rv-1", + thread = "thread-1", + body = "답변입니다.", + ) + + fixture.gateway.execute(request).shouldBeInstanceOfSuccess() + fixture.gateway.execute(request).shouldBeInstanceOfSuccess() + + fixture.replies.map { it.comment.id } shouldHaveSize 2 + fixture.replies[0].comment.id shouldBe fixture.replies[1].comment.id + fixture.storeIdRequests.size shouldBe 0 + } + + test("본문, review revision 또는 작성자가 바뀌면 별도 comment ID를 사용합니다") { + val first = replyGateway() + first.gateway.execute( + WorkflowCommandRequest.ReviewReply("rv-1", "thread-1", body = "답변입니다.") + ) + first.gateway.execute( + WorkflowCommandRequest.ReviewReply("rv-1", "thread-1", body = "수정된 답변입니다.") + ) + val secondRevision = replyGateway(reviewRevisionId = "rv-2") + secondRevision.gateway.execute( + WorkflowCommandRequest.ReviewReply("rv-2", "thread-1", body = "답변입니다.") + ) + val secondActor = replyGateway(actorId = "agent-2") + secondActor.gateway.execute( + WorkflowCommandRequest.ReviewReply("rv-1", "thread-1", body = "답변입니다.") + ) + + val ids = + listOf( + first.replies[0].comment.id, + first.replies[1].comment.id, + secondRevision.replies.single().comment.id, + secondActor.replies.single().comment.id, + ) + ids.distinct() shouldHaveSize ids.size + listOf(first, secondRevision, secondActor).sumOf { it.storeIdRequests.size } shouldBe 0 + } + } + }) + +private data class ReplyGatewayFixture( + val gateway: WorkflowCommandGateway, + val replies: MutableList, + val storeIdRequests: MutableList, + val recoverCalls: MutableList, +) + +private fun replyGateway( + reviewRevisionId: String = "rv-1", + actorId: String = "agent-1", + retryAfterPostFailure: Boolean = false, + storePort: WorkflowStorePort? = null, +): ReplyGatewayFixture { + val author = Actor("reviewer", ActorKind.HUMAN) + val initialComment = ReviewComment("comment-1", author, "확인해 주세요.") + val thread = ReviewThread("thread-1", ReviewLevel.R, listOf(initialComment)) + val reviewRevision = ReviewRevision(reviewRevisionId, 1, "검토", listOf(thread)) + val pullRequest = + PullRequest( + id = "pr-15", + subTaskId = "sk-15", + title = "변경", + body = "설명", + base = "develop", + reviewRevision = reviewRevision, + changeRevision = ChangeRevision("cr-1", 1, Diff("diff-1")), + ) + val nextRevision = + reviewRevision.copy( + id = "rv-next", + number = reviewRevision.number + 1, + threads = + listOf( + thread.reply( + ReviewComment( + "stable-comment", + Actor(actorId, ActorKind.AGENT), + "[Agent] 답변입니다.", + ) + ) + ), + ) + if (storePort != null) { + val transaction = StoreTransactionRequest("seed-reply", "0", "seed-reply") + storePort.begin(transaction) + storePort.write( + StoreWriteRequest( + transactionId = transaction.transactionId, + expectedRevision = "0", + snapshot = + WorkflowStoreSnapshot( + "0", + subTasks = listOf(SubTask("sk-15", "task-15", "review")), + pullRequests = listOf(pullRequest), + ), + ) + ) + storePort.commit(transaction) + } + val replies = mutableListOf() + var getCalls = 0 + val recoverCalls = mutableListOf() + val reviewPort = + object : ReviewPort { + override fun open(request: OpenReviewRequest): PortResult = + unsupported() + + override fun get(request: GetReviewRequest): PortResult { + getCalls += 1 + return PortResult.Success( + GetReviewResponse( + if (retryAfterPostFailure && getCalls > 1) + pullRequest.copy(reviewRevision = nextRevision) + else pullRequest + ) + ) + } + + override fun update(request: UpdateReviewRequest): PortResult = + unsupported() + + override fun comment( + request: AddReviewCommentRequest + ): PortResult = unsupported() + + override fun reply( + request: ReplyReviewThreadRequest + ): PortResult { + replies += request + if (retryAfterPostFailure) { + return PortResult.Failure( + io.springkit.workflow.application.PortError( + "GITHUB_REPLY_FAILED", + "POST succeeded but follow-up fetch failed", + ) + ) + } + return PortResult.Success( + ReplyReviewThreadResponse( + reviewRevision = + reviewRevision.copy(id = "rv-next", number = reviewRevision.number + 1), + change = ChangeReceipt("change-${replies.size}", "reply"), + ) + ) + } + + override fun recoverReply( + request: ReplyReviewThreadRequest + ): PortResult { + recoverCalls += Unit + return PortResult.Success( + ReplyReviewThreadResponse(nextRevision, ChangeReceipt("change-recovered", "reply")) + ) + } + + override fun resolve( + request: ResolveReviewThreadRequest + ): PortResult = unsupported() + + override fun ready(request: ReadyReviewRequest): PortResult = + unsupported() + + override fun approve(request: ApproveReviewRequest): PortResult = + unsupported() + } + val context = + object : WorkflowRuntimeContextResolver { + override fun currentWorkspaceContext() = + PortResult.Success( + WorkflowWorkspaceContext("workspace-15", "sk-15", WorkspacePath("/workspace")) + ) + + override fun currentReview() = + PortResult.Success( + WorkflowReviewContext( + "pr-15", + "sk-15", + "변경", + "develop", + "feature/sk-15", + reviewRevision, + pullRequest.changeRevision, + ) + ) + + override fun currentActor(requestId: String) = + PortResult.Success(Actor(actorId, ActorKind.AGENT)) + } + val storeIdRequests = mutableListOf() + val gateway = + WorkflowCommandGateway( + startCheck = + StartCheckUseCases( + proxyPort(), + proxyPort(), + proxyPort(), + proxyPort(), + proxyPort(), + projectPrefix = "sk", + ), + review = ReviewUseCases(reviewPort, storePort = storePort), + reviewLifecycle = + ReviewLifecycleUseCases( + proxyPort(), + proxyPort(), + proxyPort(), + reviewPort, + proxyPort(), + storePort = proxyPort(), + ), + stackSync = StackSyncUseCases(proxyPort(), reviewPort, proxyPort()), + status = StatusUseCase(proxyPort()), + reviewGate = ReviewGateUseCases(proxyPort(), reviewPort, proxyPort()), + deliveryGate = DeliveryGateUseCases(proxyPort(), proxyPort(), proxyPort(), proxyPort()), + context = context, + commentId = { + storeIdRequests += Unit + PortResult.Success("store-comment-id") + }, + ) + return ReplyGatewayFixture(gateway, replies, storeIdRequests, recoverCalls) +} + +private inline fun proxyPort(): T = + Proxy.newProxyInstance(T::class.java.classLoader, arrayOf(T::class.java)) { _, method, _ -> + throw UnsupportedOperationException("Unexpected port call: ${method.name}") + } as T + +private fun unsupported(): T = + throw UnsupportedOperationException("Unexpected review port call") + +private fun WorkflowResult<*>.shouldBeInstanceOfSuccess() { + shouldBeInstanceOf>() +}