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:
@@ -227,6 +227,8 @@ fun main() {
|
|||||||
com.correx.apps.server.tasks.EventStoreTaskArtifactResolver(eventStore, artifactStore)
|
com.correx.apps.server.tasks.EventStoreTaskArtifactResolver(eventStore, artifactStore)
|
||||||
val taskSessionResolver =
|
val taskSessionResolver =
|
||||||
com.correx.apps.server.tasks.SessionSummaryTaskSessionResolver(eventStore)
|
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 taskService = TaskService(eventStore)
|
||||||
val taskTools = TaskTools.forService(
|
val taskTools = TaskTools.forService(
|
||||||
taskService,
|
taskService,
|
||||||
@@ -535,6 +537,7 @@ fun main() {
|
|||||||
taskDocumentResolver = taskDocumentResolver,
|
taskDocumentResolver = taskDocumentResolver,
|
||||||
taskArtifactResolver = taskArtifactResolver,
|
taskArtifactResolver = taskArtifactResolver,
|
||||||
taskSessionResolver = taskSessionResolver,
|
taskSessionResolver = taskSessionResolver,
|
||||||
|
gitCommitReader = gitCommitReader,
|
||||||
)
|
)
|
||||||
// Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived
|
// 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.
|
// 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,
|
val taskArtifactResolver: com.correx.core.tasks.TaskArtifactResolver? = null,
|
||||||
// Resolves linked agent sessions inline in the task context bundle. Null leaves them as raw links.
|
// Resolves linked agent sessions inline in the task context bundle. Null leaves them as raw links.
|
||||||
val taskSessionResolver: com.correx.core.tasks.TaskSessionResolver? = null,
|
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(
|
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
|
||||||
orchestrator = orchestrator,
|
orchestrator = orchestrator,
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package com.correx.apps.server.routes
|
|||||||
|
|
||||||
import com.correx.apps.server.ServerModule
|
import com.correx.apps.server.ServerModule
|
||||||
import com.correx.core.events.types.ProjectId
|
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.TaskId
|
||||||
import com.correx.core.events.types.TaskLinkType
|
import com.correx.core.events.types.TaskLinkType
|
||||||
import com.correx.core.events.types.TaskNoteAuthor
|
import com.correx.core.events.types.TaskNoteAuthor
|
||||||
@@ -80,6 +83,16 @@ data class LinkRequest(val targetId: String, val type: String = "RELATES_TO", va
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class NoteRequest(val body: String, val author: String? = null)
|
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
|
* 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).
|
* 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") {
|
route("/tasks") {
|
||||||
taskCollectionRoutes(service)
|
taskCollectionRoutes(service)
|
||||||
|
taskSyncRoute(service, module.gitCommitReader)
|
||||||
route("/{id}") {
|
route("/{id}") {
|
||||||
taskCrudRoutes(service, assembler)
|
taskCrudRoutes(service, assembler)
|
||||||
taskTransitionRoutes(service)
|
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) {
|
private fun Route.taskCrudRoutes(service: TaskService, assembler: TaskContextAssembler) {
|
||||||
get {
|
get {
|
||||||
val id = taskId() ?: return@get call.respond(HttpStatusCode.BadRequest, "Missing task id")
|
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
|
@Test
|
||||||
fun `context bundle is served for a known task`(@TempDir tempDir: Path) {
|
fun `context bundle is served for a known task`(@TempDir tempDir: Path) {
|
||||||
testApplication {
|
testApplication {
|
||||||
|
|||||||
+30
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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