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 7af9c9ab..b690000b 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 @@ -11,14 +11,17 @@ import com.correx.core.events.types.TaskNoteAuthor import com.correx.core.events.types.TaskTargetKind import com.correx.core.tasks.Task import com.correx.core.tasks.TaskContextAssembler +import com.correx.core.tasks.TaskMarkdown import com.correx.core.tasks.TaskSearch import com.correx.core.tasks.TaskService import com.correx.core.tasks.TaskStatus import com.correx.core.tasks.TaskTargetKinds import com.correx.core.utils.TypeId +import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode import io.ktor.server.request.receive import io.ktor.server.response.respond +import io.ktor.server.response.respondText import io.ktor.server.routing.Route import io.ktor.server.routing.RoutingContext import io.ktor.server.routing.delete @@ -150,6 +153,12 @@ private fun Route.taskCollectionRoutes(service: TaskService) { ) call.respond(HttpStatusCode.Created, task.toResponse()) } + // Disposable Markdown projection of the board (whole board, or one project with ?project=). + get("/export") { + val project = call.request.queryParameters["project"] + val tasks = if (project != null) service.list(ProjectId(project)) else service.listAll() + call.respondText(TaskMarkdown.render(tasks), ContentType.Text.Plain) + } } // Git-driven status: a commit mention advances a task to IN_PROGRESS, a closing keyword 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 d886345a..beee6378 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 @@ -227,6 +227,21 @@ class TaskRoutesTest { } } + @Test + fun `GET tasks export renders the board as markdown`(@TempDir tempDir: Path) { + testApplication { + application { configureServer(buildModule(tempDir)) } + val client = createClient {} + client.createTask() // demo-1 + + val res = client.get("/tasks/export") + assertEquals(HttpStatusCode.OK, res.status) + val md = res.bodyAsText() + assertTrue(md.startsWith("# Tasks")) + assertTrue(md.contains("**demo-1**")) + } + } + @Test fun `context bundle is served for a known task`(@TempDir tempDir: Path) { testApplication { diff --git a/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskMarkdown.kt b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskMarkdown.kt new file mode 100644 index 00000000..d21c62d7 --- /dev/null +++ b/core/tasks/src/main/kotlin/com/correx/core/tasks/TaskMarkdown.kt @@ -0,0 +1,54 @@ +package com.correx.core.tasks + +/** + * Renders the task board to Markdown — a *disposable projection* of the event log, grouped by + * status. It is never a source of truth: regenerate it, don't hand-edit it. Pure and store-free + * so it can render either [TaskService.list] or [TaskService.listAll] and be unit tested directly. + */ +object TaskMarkdown { + + // Active work first, terminal states last. + private val ORDER = listOf( + TaskStatus.IN_PROGRESS, + TaskStatus.IN_REVIEW, + TaskStatus.BLOCKED, + TaskStatus.TODO, + TaskStatus.DONE, + TaskStatus.CANCELLED, + ) + + fun render(tasks: List): String = buildString { + appendLine("# Tasks") + appendLine() + appendLine("_Generated from the correx event log — disposable; regenerate, don't edit._") + appendLine() + if (tasks.isEmpty()) { + appendLine("_No tasks._") + return@buildString + } + val byStatus = tasks.groupBy { it.state.status } + for (status in ORDER) { + val group = byStatus[status]?.sortedBy { it.taskId.value }.orEmpty() + if (group.isEmpty()) continue + appendLine("## ${heading(status)} (${group.size})") + appendLine() + group.forEach { appendLine(item(it)) } + appendLine() + } + }.trimEnd() + "\n" + + private fun item(task: Task): String { + val check = if (task.state.status == TaskStatus.DONE) "x" else " " + val claim = task.state.claimant?.let { " _(@$it)_" }.orEmpty() + return "- [$check] **${task.taskId.value}** ${task.state.title.orEmpty()}$claim".trimEnd() + } + + private fun heading(status: TaskStatus): String = when (status) { + TaskStatus.TODO -> "To do" + TaskStatus.IN_PROGRESS -> "In progress" + TaskStatus.IN_REVIEW -> "In review" + TaskStatus.BLOCKED -> "Blocked" + TaskStatus.DONE -> "Done" + TaskStatus.CANCELLED -> "Cancelled" + } +} diff --git a/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskMarkdownTest.kt b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskMarkdownTest.kt new file mode 100644 index 00000000..dfb7e0da --- /dev/null +++ b/core/tasks/src/test/kotlin/com/correx/core/tasks/TaskMarkdownTest.kt @@ -0,0 +1,33 @@ +package com.correx.core.tasks + +import com.correx.core.events.types.TaskId +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class TaskMarkdownTest { + + private fun task(id: String, status: TaskStatus, title: String, claimant: String? = null) = + Task(TaskId(id), TaskState(key = id, status = status, title = title, claimant = claimant)) + + @Test + fun `renders grouped sections with checkboxes and claimants`() { + val md = TaskMarkdown.render( + listOf( + task("auth-1", TaskStatus.IN_PROGRESS, "JWT refresh", claimant = "claude-opus"), + task("auth-2", TaskStatus.DONE, "Login form"), + ), + ) + + assertTrue(md.startsWith("# Tasks")) + assertTrue(md.contains("disposable")) + assertTrue(md.contains("## In progress (1)")) + assertTrue(md.contains("- [ ] **auth-1** JWT refresh _(@claude-opus)_")) + assertTrue(md.contains("## Done (1)")) + assertTrue(md.contains("- [x] **auth-2** Login form")) + } + + @Test + fun `empty board renders a placeholder`() { + assertTrue(TaskMarkdown.render(emptyList()).contains("_No tasks._")) + } +}