feat(tasks): git-driven task status

POST /tasks/sync-git advances task status from commit messages: a mention
moves a task to IN_PROGRESS, a closing keyword (fixes/closes/resolves
auth-12) walks it the legal path to DONE. Only ever advances (never
regresses or touches BLOCKED/CANCELLED), so re-running is idempotent; each
advance leaves a [git <sha>] note. Acts on commits in the request body or
reads the repo's recent log (GitCommandCommitReader) — wire it to a git
hook or CI step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 06:37:34 +00:00
parent 99f781687f
commit 8d84ccf92f
10 changed files with 344 additions and 0 deletions
@@ -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.
@@ -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,
@@ -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<CommitDto> = 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<SyncGitRequest>() }.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")
@@ -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<Commit> = withContext(Dispatchers.IO) {
runCatching { runGitLog(limit) }.getOrDefault(emptyList())
}
private fun runGitLog(limit: Int): List<Commit> {
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<US>%B<RS>` 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<Commit> =
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())
}
}
@@ -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 {
@@ -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())
}
}