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:
@@ -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 (`<word>-<n>`), 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<CommitTaskRef> {
|
||||
val byKey = LinkedHashMap<String, Boolean>()
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -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<Commit>
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TaskStatusChange(val key: String, val sha: String, val from: String, val to: String)
|
||||
|
||||
@Serializable
|
||||
data class GitSyncReport(val changes: List<TaskStatusChange>)
|
||||
|
||||
/**
|
||||
* 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<Commit>): GitSyncReport {
|
||||
val changes = mutableListOf<TaskStatusChange>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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") })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user