feat(tasks): markdown export projection

A disposable Markdown view of the board, grouped by status with checkboxes
and claimants — rendered from the event log, never a source of truth.
TaskMarkdown.render (pure) backs GET /tasks/export[?project=].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 07:17:06 +00:00
parent 8d84ccf92f
commit 8a6678f171
4 changed files with 111 additions and 0 deletions
@@ -11,14 +11,17 @@ import com.correx.core.events.types.TaskNoteAuthor
import com.correx.core.events.types.TaskTargetKind import com.correx.core.events.types.TaskTargetKind
import com.correx.core.tasks.Task import com.correx.core.tasks.Task
import com.correx.core.tasks.TaskContextAssembler import com.correx.core.tasks.TaskContextAssembler
import com.correx.core.tasks.TaskMarkdown
import com.correx.core.tasks.TaskSearch import com.correx.core.tasks.TaskSearch
import com.correx.core.tasks.TaskService import com.correx.core.tasks.TaskService
import com.correx.core.tasks.TaskStatus import com.correx.core.tasks.TaskStatus
import com.correx.core.tasks.TaskTargetKinds import com.correx.core.tasks.TaskTargetKinds
import com.correx.core.utils.TypeId import com.correx.core.utils.TypeId
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive import io.ktor.server.request.receive
import io.ktor.server.response.respond import io.ktor.server.response.respond
import io.ktor.server.response.respondText
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
import io.ktor.server.routing.RoutingContext import io.ktor.server.routing.RoutingContext
import io.ktor.server.routing.delete import io.ktor.server.routing.delete
@@ -150,6 +153,12 @@ private fun Route.taskCollectionRoutes(service: TaskService) {
) )
call.respond(HttpStatusCode.Created, task.toResponse()) 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 // Git-driven status: a commit mention advances a task to IN_PROGRESS, a closing keyword
@@ -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 @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 {
@@ -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<Task>): 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"
}
}
@@ -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._"))
}
}