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:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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._"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user