feat(taiga): Ticket-Stand im Knoten-Fenster — gelesen, nie geschrieben (D91-Nachtrag 6, SPEC §9)

Wo eine Ref steht und ein `&taiga.<slug>` gilt, zeigt das Knoten-Fenster
Betreff, Status und Zuständigen des Tickets; Taigas Statusname steht neben
der Statusbox der Notation (`In progress → [~]`).

- Proxy: zwei benannte Lese-Endpunkte (`GET /taiga/userstories/{ref}` und
  `…/tasks/{ref}`, je `?slug=`) — das Präfix der Ref trägt den Typ, Taiga
  hat getrennte `by_ref`-Endpunkte. Erst `/projects/by_slug`, dann `by_ref`
  (eine Ref ist nur je Projekt eindeutig); der Slug wird kodiert angehängt.
- Die Abbildung Status → Statusbox liegt im Editor (`mapTaigaStatus`,
  headless): Statuscodes sind Notation, das Backend parst sie nicht (D14).
  Unbekannte Namen bleiben unabgebildet — Raten hieße, dem Knoten eine
  Aussage zu geben, die niemand gemacht hat.
- Geholt wird erst nach 400 ms Verweilen und je Ticket einmal je Sitzung
  (↻ holt neu); ohne Anmeldung gar nicht — der Knopf meldet erst an.
- Nichts wird geschrieben: kein Text, keine Statusbox (das bleibt
  `#trk.write`).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-27 20:41:21 +02:00
co-authored by Claude Fable 5
parent e58f71bf90
commit 842e89795a
15 changed files with 776 additions and 33 deletions
@@ -115,6 +115,34 @@ class TaigaApiTest {
result.responseBody!! shouldContain "\"ref\":124"
}
@Test
fun `eine Story-Ref wird ueber Slug und by_ref aufgeloest`() {
val result = client.get()
.uri("/api/v1/taiga/userstories/123?slug=mi-kunde")
.header("X-Taiga-Token", "tok-abc123")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"subject\":\"Login bauen\""
// Der Status kommt als NAME an; abgebildet wird er im Editor (D14).
result.responseBody!! shouldContain "\"status\":\"In progress\""
result.responseBody!! shouldContain "\"assignee\":\"Anna Beispiel\""
}
@Test
fun `eine Task-Ref geht an den Task-Endpunkt und darf ohne Zustaendigen kommen`() {
val result = client.get()
.uri("/api/v1/taiga/tasks/1234?slug=mi-kunde")
.header("X-Taiga-Token", "tok-abc123")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"status\":\"Done\""
result.responseBody!! shouldContain "\"statusClosed\":true"
// Niemand zugewiesen: ausdrücklich null, nicht geraten.
result.responseBody!! shouldContain "\"assignee\":null"
}
companion object {
private lateinit var stub: HttpServer
@Volatile private var stubStatus = 200
@@ -129,6 +157,9 @@ class TaigaApiTest {
"/api/v1/projects" -> TaigaClientTestData.PROJECTS_OK
"/api/v1/userstories" -> TaigaClientTestData.STORY_OK
"/api/v1/tasks" -> TaigaClientTestData.TASK_OK
"/api/v1/projects/by_slug" -> TaigaClientTestData.PROJECT_OK
"/api/v1/userstories/by_ref" -> TaigaClientTestData.STORY_DETAIL
"/api/v1/tasks/by_ref" -> TaigaClientTestData.TASK_DETAIL
else -> "{}"
}
val status = if (stubBody != null) stubStatus
@@ -175,4 +206,13 @@ object TaigaClientTestData {
"""{"id": 1234, "ref": 123, "subject": "Backend bauen", "project": 7}"""
const val TASK_OK =
"""{"id": 5678, "ref": 124, "subject": "API-Teil", "project": 7, "user_story": 1234}"""
const val PROJECT_OK =
"""{"id": 7, "name": "Kunde", "slug": "mi-kunde"}"""
const val STORY_DETAIL =
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7,
"status_extra_info": {"name": "In progress", "is_closed": false},
"assigned_to_extra_info": {"full_name_display": "Anna Beispiel"}}"""
const val TASK_DETAIL =
"""{"id": 5678, "ref": 1234, "subject": "API-Teil", "project": 7,
"status_extra_info": {"name": "Done", "is_closed": true}}"""
}
@@ -22,6 +22,8 @@ class TaigaClientTest {
@BeforeEach
fun reset() {
recorded = null
requests.clear()
routes.clear()
responseStatus = 200
responseBody = "{}"
}
@@ -96,6 +98,70 @@ class TaigaClientTest {
recorded!!.body shouldContain "\"user_story\":1234"
}
/* ---- Lesen über die Ref (D91-Nachtrag 6) ---- */
@Test
fun `ticket loest erst den Slug zur Projekt-Id auf und liest dann per by_ref`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref"] = 200 to STORY_DETAIL
val d = client().ticket("tok-abc123", "mi-kunde", 123, task = false)
d.ref shouldBe 123L
d.id shouldBe 1234L
d.subject shouldBe "Login bauen"
d.status shouldBe "In progress"
d.statusClosed shouldBe false
d.assignee shouldBe "Anna Beispiel"
requests.map { it.path } shouldBe
listOf("/api/v1/projects/by_slug", "/api/v1/userstories/by_ref")
requests[0].query shouldBe "slug=mi-kunde"
requests[1].query shouldBe "project=7&ref=123"
requests.all { it.auth == "Bearer tok-abc123" } shouldBe true
}
@Test
fun `eine Task wird ueber ihren eigenen by_ref-Endpunkt gelesen`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/tasks/by_ref"] = 200 to TASK_DETAIL
val d = client().ticket("tok-abc123", "mi-kunde", 1234, task = true)
d.ref shouldBe 1234L
d.status shouldBe "Done"
requests[1].path shouldBe "/api/v1/tasks/by_ref"
requests[1].query shouldBe "project=7&ref=1234"
}
@Test
fun `ohne extra_info bleiben Status und Zustaendiger leer statt geraten`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref"] = 200 to STORY_BARE
val d = client().ticket("tok-abc123", "mi-kunde", 123, task = false)
d.subject shouldBe "Login bauen"
d.status shouldBe null
d.statusClosed shouldBe null
d.assignee shouldBe null
}
@Test
fun `ein unbekanntes Projekt wird als 404 durchgereicht, ohne zweiten Umlauf`() {
routes["/api/v1/projects/by_slug"] = 404 to NOT_FOUND
val ex = shouldThrow<TaigaUpstreamException> {
client().ticket("tok-abc123", "gibt-es-nicht", 123, task = false)
}
ex.status shouldBe 404
requests.map { it.path } shouldBe listOf("/api/v1/projects/by_slug")
}
@Test
fun `der Slug wird kodiert - ein & haengt keinen weiteren Filter an`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref"] = 200 to STORY_DETAIL
client().ticket("tok-abc123", "a&member=1", 123, task = false)
requests[0].query shouldBe "slug=a%26member%3D1"
}
@Test
fun `ohne konfigurierte Instanz gibt es kein Ziel`() {
val bare = TaigaClient(TaigaProperties(apiUrl = ""))
@@ -131,6 +197,12 @@ class TaigaClientTest {
@Volatile var responseStatus = 200
@Volatile var responseBody = "{}"
/* Das Lesen eines Tickets braucht ZWEI Umläufe (Slug -> Id, dann
by_ref) — deshalb Antworten je Pfad und alle Anfragen der Reihe
nach, statt nur der letzten. */
val routes = mutableMapOf<String, Pair<Int, String>>()
val requests = mutableListOf<Recorded>()
/* Aufgezeichnete Antwortformen (gekuerzt auf die gebrauchten Felder
plus typisches Beiwerk, damit der Client Unbekanntes ignoriert). */
const val AUTH_OK =
@@ -143,6 +215,23 @@ class TaigaClientTest {
"""{"id": 1234, "ref": 123, "subject": "Backend bauen", "project": 7, "status": 1}"""
const val TASK_OK =
"""{"id": 5678, "ref": 124, "subject": "API-Teil", "project": 7, "user_story": 1234}"""
const val PROJECT_OK =
"""{"id": 7, "name": "Kunde", "slug": "mi-kunde"}"""
/* Form der by_ref-Antwort: die Namen stehen in den
`*_extra_info`-Blöcken, die Ids daneben (Taiga-API). */
const val STORY_DETAIL =
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, "status": 3,
"status_extra_info": {"name": "In progress", "color": "#ff9900", "is_closed": false},
"assigned_to": 42,
"assigned_to_extra_info": {"username": "anna", "full_name_display": "Anna Beispiel"}}"""
const val TASK_DETAIL =
"""{"id": 5678, "ref": 1234, "subject": "API-Teil", "project": 7,
"status_extra_info": {"name": "Done", "is_closed": true},
"assigned_to_extra_info": null}"""
const val STORY_BARE =
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, "status": 3}"""
const val NOT_FOUND =
"""{"_error_message": "Not found.", "_error_type": "taiga.base.exceptions.NotFound"}"""
@JvmStatic
@BeforeAll
@@ -156,9 +245,12 @@ class TaigaClientTest {
noPagination = ex.requestHeaders.getFirst("x-disable-pagination"),
body = ex.requestBody.readBytes().decodeToString(),
)
val bytes = responseBody.encodeToByteArray()
requests += recorded!!
val route = routes[ex.requestURI.path]
val status = route?.first ?: responseStatus
val bytes = (route?.second ?: responseBody).encodeToByteArray()
ex.responseHeaders.set("Content-Type", "application/json")
ex.sendResponseHeaders(responseStatus, bytes.size.toLong())
ex.sendResponseHeaders(status, bytes.size.toLong())
ex.responseBody.use { it.write(bytes) }
}
server.start()