diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 3659d831..428d0482 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -227,6 +227,8 @@ fun main() { com.correx.apps.server.tasks.EventStoreTaskArtifactResolver(eventStore, artifactStore) val taskSessionResolver = com.correx.apps.server.tasks.SessionSummaryTaskSessionResolver(eventStore) + // Backs POST /tasks/sync-git: reads recent commits so a git hook / CI step can drive task status. + val gitCommitReader = com.correx.apps.server.tasks.GitCommandCommitReader(workspaceRoot) val taskService = TaskService(eventStore) val taskTools = TaskTools.forService( taskService, @@ -535,6 +537,7 @@ fun main() { taskDocumentResolver = taskDocumentResolver, taskArtifactResolver = taskArtifactResolver, taskSessionResolver = taskSessionResolver, + gitCommitReader = gitCommitReader, ) // Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived // services. Built after the module so the rebuild hook can swap them in place. diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt index 9a95f434..c5a78882 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/ServerModule.kt @@ -123,6 +123,9 @@ class ServerModule( val taskArtifactResolver: com.correx.core.tasks.TaskArtifactResolver? = null, // Resolves linked agent sessions inline in the task context bundle. Null leaves them as raw links. val taskSessionResolver: com.correx.core.tasks.TaskSessionResolver? = null, + // Reads recent commits for POST /tasks/sync-git (git-driven status). Null disables the repo read + // (the endpoint then only acts on commits supplied in the request body). + val gitCommitReader: com.correx.core.tasks.GitCommitReader? = null, ) { val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator( orchestrator = orchestrator, diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt index 88e7a656..7af9c9ab 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/routes/TaskRoutes.kt @@ -2,6 +2,9 @@ package com.correx.apps.server.routes import com.correx.apps.server.ServerModule import com.correx.core.events.types.ProjectId +import com.correx.core.tasks.Commit +import com.correx.core.tasks.GitCommitReader +import com.correx.core.tasks.GitTaskSync import com.correx.core.events.types.TaskId import com.correx.core.events.types.TaskLinkType import com.correx.core.events.types.TaskNoteAuthor @@ -80,6 +83,16 @@ data class LinkRequest(val targetId: String, val type: String = "RELATES_TO", va @Serializable data class NoteRequest(val body: String, val author: String? = null) +@Serializable +data class CommitDto(val sha: String = "", val message: String) + +// When commits are supplied they are used verbatim; otherwise the server reads the last `limit` +// commits from the repo (when a GitCommitReader is wired). +@Serializable +data class SyncGitRequest(val commits: List = emptyList(), val limit: Int = DEFAULT_SYNC_LIMIT) + +private const val DEFAULT_SYNC_LIMIT = 20 + /** * Full REST surface for tasks. The write path mirrors the [TaskService] / `task_*` tools; both * append to the event log, which stays the single source of truth (nothing is stored separately). @@ -98,6 +111,7 @@ fun Route.taskRoutes(module: ServerModule) { ) route("/tasks") { taskCollectionRoutes(service) + taskSyncRoute(service, module.gitCommitReader) route("/{id}") { taskCrudRoutes(service, assembler) taskTransitionRoutes(service) @@ -138,6 +152,21 @@ private fun Route.taskCollectionRoutes(service: TaskService) { } } +// Git-driven status: a commit mention advances a task to IN_PROGRESS, a closing keyword +// ("fixes auth-12") advances it to DONE. Idempotent — wire this to a post-commit / post-merge hook +// or a CI step. Acts on commits in the body, or reads the repo's recent log when [reader] is set. +private fun Route.taskSyncRoute(service: TaskService, reader: GitCommitReader?) { + post("/sync-git") { + val body = runCatching { call.receive() }.getOrNull() ?: SyncGitRequest() + val commits = if (body.commits.isNotEmpty()) { + body.commits.map { Commit(it.sha, it.message) } + } else { + reader?.recent(body.limit) ?: emptyList() + } + call.respond(GitTaskSync(service).sync(commits)) + } +} + private fun Route.taskCrudRoutes(service: TaskService, assembler: TaskContextAssembler) { get { val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id") diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/tasks/GitCommandCommitReader.kt b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/GitCommandCommitReader.kt new file mode 100644 index 00000000..c75091a7 --- /dev/null +++ b/apps/server/src/main/kotlin/com/correx/apps/server/tasks/GitCommandCommitReader.kt @@ -0,0 +1,56 @@ +package com.correx.apps.server.tasks + +import com.correx.core.tasks.Commit +import com.correx.core.tasks.GitCommitReader +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.file.Path +import java.util.concurrent.TimeUnit + +private const val RS = "" // ASCII record separator, between commits +private const val US = "" // ASCII unit separator, between sha and body +private const val GIT_TIMEOUT_SEC = 10L + +/** + * Reads recent commits by shelling `git log` in the repo. Best-effort: any failure (git missing, + * not a repo, timeout, non-zero exit) yields an empty list so the sync endpoint degrades to a no-op + * rather than erroring. Parsing is split into the testable top-level [parseGitLog]. + */ +class GitCommandCommitReader(private val repoRoot: Path) : GitCommitReader { + + override suspend fun recent(limit: Int): List = withContext(Dispatchers.IO) { + runCatching { runGitLog(limit) }.getOrDefault(emptyList()) + } + + private fun runGitLog(limit: Int): List { + val process = ProcessBuilder( + "git", "-C", repoRoot.toString(), "log", "-n", limit.toString(), "--format=%H$US%B$RS", + ).redirectErrorStream(false).start() + val output = process.inputStream.bufferedReader().use { it.readText() } + val finished = process.waitFor(GIT_TIMEOUT_SEC, TimeUnit.SECONDS) + return if (finished && process.exitValue() == 0) { + parseGitLog(output) + } else { + if (!finished) process.destroy() + emptyList() + } + } +} + +/** + * Parses `git log --format=%H%B` output into commits: records split on the record + * separator, then each split once on the unit separator into sha + message. Top-level + internal + * so it is unit-testable without invoking git. + */ +internal fun parseGitLog(raw: String): List = + raw.split(RS) + .map { it.trim() } + .filter { it.isNotEmpty() } + .mapNotNull { record -> + val idx = record.indexOf(US) + if (idx < 0) { + null + } else { + Commit(sha = record.substring(0, idx).trim(), message = record.substring(idx + 1).trim()) + } + } diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt index 8a8823ae..d886345a 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/routes/TaskRoutesTest.kt @@ -206,6 +206,27 @@ class TaskRoutesTest { } } + @Test + fun `POST sync-git advances tasks from commit messages`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1, TODO + + val res = client.post("/tasks/sync-git") { + contentType(ContentType.Application.Json) + setBody("""{"commits":[{"sha":"abc1234567","message":"fixes demo-1"}]}""") + } + assertEquals(HttpStatusCode.OK, res.status) + val change = (res.json()["changes"] as JsonArray).single() as JsonObject + assertEquals("demo-1", change["key"]!!.jsonPrimitive.content) + assertEquals("TODO", change["from"]!!.jsonPrimitive.content) + assertEquals("DONE", change["to"]!!.jsonPrimitive.content) + + assertEquals("DONE", client.get("/tasks/demo-1").json()["status"]!!.jsonPrimitive.content) + } + } + @Test fun `context bundle is served for a known task`(@TempDir tempDir: Path) { testApplication { diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/tasks/GitCommandCommitReaderTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/GitCommandCommitReaderTest.kt new file mode 100644 index 00000000..7efb3708 --- /dev/null +++ b/apps/server/src/test/kotlin/com/correx/apps/server/tasks/GitCommandCommitReaderTest.kt @@ -0,0 +1,30 @@ +package com.correx.apps.server.tasks + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GitCommandCommitReaderTest { + + private val us = "" + private val rs = "" + + @Test + fun `parseGitLog splits records and sha from message`() { + val raw = "abc123${us}fix auth-1: rotate$rs\ndef456${us}wip on auth-2$rs\n" + + val commits = parseGitLog(raw) + + assertEquals(2, commits.size) + assertEquals("abc123", commits[0].sha) + assertEquals("fix auth-1: rotate", commits[0].message) + assertEquals("def456", commits[1].sha) + assertEquals("wip on auth-2", commits[1].message) + } + + @Test + fun `parseGitLog tolerates blank output and malformed records`() { + assertTrue(parseGitLog("").isEmpty()) + assertTrue(parseGitLog("no-separator-here$rs").isEmpty()) + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/CommitTaskParser.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/CommitTaskParser.kt new file mode 100644 index 00000000..7c1515f6 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/CommitTaskParser.kt @@ -0,0 +1,26 @@ +package com.correx.core.tasks + +/** + * A task reference parsed from a commit message: the task key, and whether it was named with a + * closing keyword (close/fix/resolve …) — which drives the task to DONE rather than IN_PROGRESS. + */ +data class CommitTaskRef(val key: String, val closing: Boolean) + +/** + * Extracts task references from a commit message. A closing keyword before a key ("fixes auth-12") + * marks it closing; any other key mention ("see auth-12") is a plain reference; the closing form + * wins when a key appears both ways. Keys are matched loosely (`-`), which is safe because + * [GitTaskSync] only acts on keys that resolve to a real task — unknown matches are ignored. + */ +object CommitTaskParser { + private const val KEY = "[A-Za-z][A-Za-z0-9_]*-\\d+" + private val CLOSING = Regex("(?i)\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\b\\s+#?($KEY)") + private val MENTION = Regex("\\b($KEY)\\b") + + fun parse(message: String): List { + val byKey = LinkedHashMap() + CLOSING.findAll(message).forEach { byKey[it.groupValues[1]] = true } + MENTION.findAll(message).forEach { byKey.putIfAbsent(it.groupValues[1], false) } + return byKey.map { (key, closing) -> CommitTaskRef(key, closing) } + } +} diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/GitTaskSync.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/GitTaskSync.kt new file mode 100644 index 00000000..f4819c35 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/GitTaskSync.kt @@ -0,0 +1,86 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskId +import com.correx.core.events.types.TaskNoteAuthor +import kotlinx.serialization.Serializable + +/** A single commit's identity + message — the unit [GitTaskSync] consumes. */ +data class Commit(val sha: String, val message: String) { + fun shortSha(): String = if (sha.length <= SHORT_SHA) sha else sha.substring(0, SHORT_SHA) + + private companion object { + const val SHORT_SHA = 7 + } +} + +/** + * Port for reading recent commits from the repo. Implemented in a higher layer (apps/server shells + * `git log`); core stays decoupled from process execution. + */ +interface GitCommitReader { + suspend fun recent(limit: Int): List +} + +@Serializable +data class TaskStatusChange(val key: String, val sha: String, val from: String, val to: String) + +@Serializable +data class GitSyncReport(val changes: List) + +/** + * Drives task status forward from git signals: a plain mention advances a task to IN_PROGRESS, a + * closing keyword advances it all the way to DONE (walking the legal claim → review → done path). + * It only ever advances — a task already at/past the target, or BLOCKED/CANCELLED, is left alone — + * so re-running over the same commits is idempotent. Each advance leaves a provenance note naming + * the commit. + */ +class GitTaskSync(private val service: TaskService) { + + suspend fun sync(commits: List): GitSyncReport { + val changes = mutableListOf() + for (commit in commits) { + for (ref in CommitTaskParser.parse(commit.message)) { + applyRef(ref, commit)?.let { changes += it } + } + } + return GitSyncReport(changes) + } + + private suspend fun applyRef(ref: CommitTaskRef, commit: Commit): TaskStatusChange? { + val taskId = TaskId(ref.key) + val from = service.getTask(taskId)?.state?.status ?: return null + val target = if (ref.closing) TaskStatus.DONE else TaskStatus.IN_PROGRESS + if (from !in ADVANCEABLE || rank(from) >= rank(target)) return null + + var current = from + while (current in ADVANCEABLE && rank(current) < rank(target)) { + advanceOne(taskId, current) + current = service.getTask(taskId)?.state?.status ?: break + } + if (current == from) return null + service.addNote(taskId, TaskNoteAuthor.AGENT, "[git ${commit.shortSha()}] ${from.name} → ${current.name}") + return TaskStatusChange(ref.key, commit.shortSha(), from.name, current.name) + } + + private suspend fun advanceOne(taskId: TaskId, current: TaskStatus) { + when (current) { + TaskStatus.TODO -> service.claim(taskId, GIT_CLAIMANT) + TaskStatus.IN_PROGRESS -> service.submitForReview(taskId) + TaskStatus.IN_REVIEW -> service.complete(taskId) + else -> Unit + } + } + + private fun rank(status: TaskStatus): Int = when (status) { + TaskStatus.TODO -> 0 + TaskStatus.IN_PROGRESS -> 1 + TaskStatus.IN_REVIEW -> 2 + TaskStatus.DONE -> 3 + else -> -1 // BLOCKED / CANCELLED sit off the forward path + } + + private companion object { + const val GIT_CLAIMANT = "git" + val ADVANCEABLE = setOf(TaskStatus.TODO, TaskStatus.IN_PROGRESS, TaskStatus.IN_REVIEW) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/CommitTaskParserTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/CommitTaskParserTest.kt new file mode 100644 index 00000000..f6fc1abf --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/CommitTaskParserTest.kt @@ -0,0 +1,29 @@ +package com.correx.core.tasks + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class CommitTaskParserTest { + + @Test + fun `closing keywords mark refs closing`() { + val refs = CommitTaskParser.parse("Fixes auth-12 and resolves billing-3") + assertEquals(setOf(CommitTaskRef("auth-12", true), CommitTaskRef("billing-3", true)), refs.toSet()) + } + + @Test + fun `plain mentions are non-closing`() { + assertEquals(listOf(CommitTaskRef("auth-12", false)), CommitTaskParser.parse("see auth-12 for context")) + } + + @Test + fun `the closing form wins over a later plain mention of the same key`() { + assertEquals(listOf(CommitTaskRef("auth-1", true)), CommitTaskParser.parse("fix auth-1\n\nfollow-up to auth-1")) + } + + @Test + fun `a message with no keys yields empty`() { + assertTrue(CommitTaskParser.parse("just a normal commit message").isEmpty()) + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/GitTaskSyncTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/GitTaskSyncTest.kt new file mode 100644 index 00000000..17044b8b --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/GitTaskSyncTest.kt @@ -0,0 +1,61 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.ProjectId +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class GitTaskSyncTest { + + private val store = InMemoryEventStore() + private val service = TaskService(store) + private val sync = GitTaskSync(service) + private val project = ProjectId("auth") + + @Test + fun `a closing keyword drives a task to DONE and re-running is idempotent`() = runBlocking { + val id = service.createTask(project, "JWT", "g").taskId // auth-1, TODO + + val report = sync.sync(listOf(Commit("abc1234567def", "fix auth-1: rotate tokens"))) + + assertEquals(TaskStatus.DONE, service.getTask(id)?.state?.status) + val change = report.changes.single() + assertEquals("auth-1", change.key) + assertEquals("TODO", change.from) + assertEquals("DONE", change.to) + assertEquals("abc1234", change.sha) + // The same commit applied again advances nothing. + assertTrue(sync.sync(listOf(Commit("abc1234567def", "fix auth-1"))).changes.isEmpty()) + } + + @Test + fun `a plain mention advances only to IN_PROGRESS`() = runBlocking { + val id = service.createTask(project, "JWT", "g").taskId + + sync.sync(listOf(Commit("def4567", "wip on auth-1"))) + + assertEquals(TaskStatus.IN_PROGRESS, service.getTask(id)?.state?.status) + } + + @Test + fun `blocked tasks and unknown keys are left alone`() = runBlocking { + val id = service.createTask(project, "JWT", "g").taskId + service.claim(id, "x") + service.block(id, "waiting on infra") + + val report = sync.sync(listOf(Commit("a1", "fix auth-1"), Commit("b2", "closes auth-999"))) + + assertEquals(TaskStatus.BLOCKED, service.getTask(id)?.state?.status) + assertTrue(report.changes.isEmpty()) + } + + @Test + fun `each advance leaves a git provenance note`() = runBlocking { + val id = service.createTask(project, "JWT", "g").taskId + + sync.sync(listOf(Commit("abc1234567", "closes auth-1"))) + + assertTrue(service.getTask(id)!!.state.notes.any { it.body.contains("git abc1234") }) + } +}