feat(taiga): Bulk-Abfrage und Abweichungs-Marke — das Diagramm zeigt, wo Ticket und Plan auseinanderlaufen (D91-Nachtrag 10, SPEC §9)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-28 11:44:33 +02:00
co-authored by Claude Opus 5
parent e77a4d1ce4
commit a1e4a3bbab
17 changed files with 663 additions and 7 deletions
+9
View File
@@ -81,6 +81,15 @@ docs/SPEC.md §10 testen — niemals eine zweite, abweichende Grammatik pflegen.
deshalb den `slug` (aus `&taiga.<slug>`, SPEC §1) und fragen erst
`/projects/by_slug`, dann `by_ref` — der Slug kommt vom Client und wird
**kodiert** angehängt, sonst hängte ein `&` darin einen weiteren Filter an.
- **Bulk-Lesen (D91-Nachtrag 10):** `GET /taiga/tickets?slug=&refs=US-1,T-2`
fächert serverseitig in `by_ref`-Einzelabfragen auf — parallel (virtuelle
Threads) mit Semaphore-Deckel (6): Die Kosten skalieren mit den Refs im
Plan, nie mit der Projektgröße; eine Projekt-Volliste ist bei Tausenden
Tickets die falsche Bulk-Einheit. Eine 404-Ref fehlt still in der
Antwort-Map, jeder andere Fehler bricht die Anfrage ab; ungültige Refs
sind ein 400 (`TaigaBadRequestException`), Deckel 200, Dubletten werden
zusammengelegt. Messnotiz: Ob Taigas Listen-Endpunkte eine Ref-Liste als
Filter nehmen, ist offen — falls ja, tauscht nur das Innere des Proxys.
- **Schreiben (D91-Nachtrag 8):** `PATCH /taiga/{userstories|tasks}/{ref}/status`
nimmt die Status-**Id** (aus `GET /taiga/{userstory|task}-statuses?slug=`)
und die zuletzt gelesene `version` — Taigas optimistische Sperre; ein
@@ -8,6 +8,7 @@ import de.werkbaum.service.DocumentDeletedException
import de.werkbaum.service.DocumentNotFoundException
import de.werkbaum.service.InvalidPatchException
import de.werkbaum.service.StalePatchSequenceException
import de.werkbaum.integration.taiga.TaigaBadRequestException
import de.werkbaum.integration.taiga.TaigaNotConfiguredException
import de.werkbaum.integration.taiga.TaigaUnavailableException
import de.werkbaum.integration.taiga.TaigaUpstreamException
@@ -89,6 +90,14 @@ class GlobalExceptionHandler {
ex.message ?: "Taiga nicht konfiguriert",
).apply { title = "Taiga nicht konfiguriert" }
/** Eine Anfrage, die schon der Proxy ablehnt (Refs-Liste der Bulk-Abfrage). */
@ExceptionHandler(TaigaBadRequestException::class)
fun handleTaigaBadRequest(ex: TaigaBadRequestException): ProblemDetail =
ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST,
ex.message ?: "Ungültige Anfrage",
).apply { title = "Ungültige Anfrage" }
@ExceptionHandler(TaigaUnavailableException::class)
fun handleTaigaUnavailable(ex: TaigaUnavailableException): ProblemDetail =
ProblemDetail.forStatusAndDetail(
@@ -10,6 +10,8 @@ import de.werkbaum.generated.model.TaigaStatus
import de.werkbaum.generated.model.TaigaStatusPatch
import de.werkbaum.generated.model.TaigaTicket
import de.werkbaum.generated.model.TaigaTicketDetail
import de.werkbaum.integration.taiga.TaigaBadRequestException
import de.werkbaum.integration.taiga.TaigaBulkRefData
import de.werkbaum.integration.taiga.TaigaClient
import de.werkbaum.integration.taiga.TaigaTicketDetailData
import de.werkbaum.integration.taiga.TaigaTicketData
@@ -76,6 +78,33 @@ class TaigaController(private val client: TaigaClient) : TaigaApi {
return created(ticket)
}
/* Bulk-Lesen (D91-Nachtrag 10): eine Anfrage vom Editor, der Fächer läuft
im Client. Die Refs kommen MIT Werkbaum-Präfix (`US-`/`T-`) — es trägt
den Typ; geparst wird streng, Ungültiges ist ein 400 statt still
übersprungen (D59-Linie). Doppelte Refs werden vor dem Fächer
zusammengelegt. */
override fun taigaTickets(
xTaigaToken: String,
slug: String,
refs: String,
): ResponseEntity<Map<String, TaigaTicketDetail>> =
ResponseEntity.ok(
client.tickets(xTaigaToken, slug, parseBulkRefs(refs))
.mapValues { it.value.toApi() }
)
private fun parseBulkRefs(refs: String): List<TaigaBulkRefData> {
val teile = refs.split(',').distinct()
if (teile.size > MAX_BULK_REFS) {
throw TaigaBadRequestException("Höchstens $MAX_BULK_REFS Refs je Anfrage")
}
return teile.map { teil ->
val m = BULK_REF.matchEntire(teil)
?: throw TaigaBadRequestException("Ungültige Ref: '$teil'")
TaigaBulkRefData(key = teil, task = m.groupValues[1] == "T", nr = m.groupValues[2].toLong())
}
}
/* Lesen (D91-Nachtrag 6): zwei Endpunkte statt eines mit Typ-Parameter —
das Präfix der Ref trägt den Typ, und Taiga hat getrennte
`by_ref`-Endpunkte. Die Abbildung des Status auf die Notation macht der
@@ -146,4 +175,10 @@ class TaigaController(private val client: TaigaClient) : TaigaApi {
ResponseEntity.status(HttpStatus.CREATED).body(
TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject)
)
companion object {
/** Höchstens so viele Refs je Bulk-Anfrage — mehr referenziert kein Plan. */
private const val MAX_BULK_REFS = 200
private val BULK_REF = Regex("(US|T)-(\\d{1,10})")
}
}
@@ -11,6 +11,10 @@ import java.net.URLEncoder
import java.net.http.HttpClient
import java.nio.charset.StandardCharsets
import java.time.Duration
import java.util.concurrent.Callable
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
import java.util.concurrent.Semaphore
/** Keine Taiga-Instanz konfiguriert — der Proxy hat kein Ziel (503). */
class TaigaNotConfiguredException :
@@ -58,6 +62,17 @@ data class TaigaTicketDetailData(
/** Eine Spalte des Projekt-Workflows (D91-Nachtrag 8). */
data class TaigaStatusData(val id: Long, val name: String, val closed: Boolean?)
/**
* Eine Ref der Bulk-Abfrage (D91-Nachtrag 10): `key` ist die Werkbaum-Ref
* (`US-123`), unter der die Antwort zurueckgeht; `task` und `nr` sind das
* zerlegte Praefix — der Typ steht in der Ref, genau dafuer schreibt
* Werkbaum ihn (SPEC par. 11).
*/
data class TaigaBulkRefData(val key: String, val task: Boolean, val nr: Long)
/** Eine Anfrage, die schon der Proxy ablehnt (400) — kein Taiga-Fehler. */
class TaigaBadRequestException(message: String) : RuntimeException(message)
/**
* Schmaler, benannter Client zur konfigurierten Taiga-Instanz (D91) — kein
* Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht.
@@ -71,6 +86,11 @@ data class TaigaStatusData(val id: Long, val name: String, val closed: Boolean?)
@Service
class TaigaClient(private val properties: TaigaProperties) {
/* Der Faecher der Bulk-Abfrage: virtuelle Threads (praktisch kostenlos,
D76-Nachtrag 5), die Semaphore deckelt die GLEICHZEITIGEN Anfragen an
die fremde Instanz — Parallelitaet ja, Hammer nein. */
private val bulkPool = Executors.newVirtualThreadPerTaskExecutor()
private val rest: RestClient = RestClient.builder()
.requestFactory(
JdkClientHttpRequestFactory(
@@ -124,8 +144,10 @@ class TaigaClient(private val properties: TaigaProperties) {
* Aufrufer aus dem Präfix der Ref (`US-`/`T-`, SPEC §11) — hier steht
* nur, wohin gefragt wird.
*/
fun ticket(token: String, slug: String, ref: Long, task: Boolean): TaigaTicketDetailData {
val project = projectId(token, slug)
fun ticket(token: String, slug: String, ref: Long, task: Boolean): TaigaTicketDetailData =
detailByRef(token, projectId(token, slug), ref, task)
private fun detailByRef(token: String, project: Long, ref: Long, task: Boolean): TaigaTicketDetailData {
val pfad = if (task) "/tasks/by_ref" else "/userstories/by_ref"
val map = exchange {
rest.get().uri(url("$pfad?project=$project&ref=$ref"))
@@ -135,6 +157,47 @@ class TaigaClient(private val properties: TaigaProperties) {
return detail(map)
}
/**
* Viele Tickets auf einmal (D91-Nachtrag 10): EIN Aufruf vom Browser, der
* Proxy fächert in `by_ref`-Einzelabfragen auf — parallel, mit
* **gedeckelter** Nebenläufigkeit (Höflichkeit gegenüber der fremden
* Instanz; Backend und Taiga sitzen beim selben Hoster, die Einzelabfrage
* kostet dort Millisekunden). Die Kosten skalieren mit den Refs im Plan,
* nie mit der Projektgröße — eine Projekt-Volliste holte bei Tausenden
* Tickets Megabytes, um fast alles wegzuwerfen.
*
* Eine Ref, die es nicht (mehr) gibt (Taiga-404), **fehlt still** in der
* Antwort — der Rest kommt trotzdem; jeder andere Fehler (401, Projekt
* unbekannt, Instanz weg) bricht die ganze Anfrage ab, denn er beträfe
* ohnehin jede einzelne Ref.
*/
fun tickets(token: String, slug: String, refs: List<TaigaBulkRefData>): Map<String, TaigaTicketDetailData> {
val project = projectId(token, slug)
val sem = Semaphore(BULK_CONCURRENCY)
val futures = refs.map { r ->
bulkPool.submit(Callable {
sem.acquire()
try {
r.key to detailByRef(token, project, r.nr, r.task)
} finally {
sem.release()
}
})
}
val out = LinkedHashMap<String, TaigaTicketDetailData>()
for (f in futures) {
try {
val (key, wert) = f.get()
out[key] = wert
} catch (e: ExecutionException) {
val grund = e.cause
if (grund is TaigaUpstreamException && grund.status == 404) continue
throw grund ?: e
}
}
return out
}
/**
* Die Spalten des Projekt-Workflows (D91-Nachtrag 8) — Taiga schreibt nach
* Status-**Id**, und die Namen sind je Projekt frei. Welche Spalte gemeint
@@ -253,6 +316,8 @@ class TaigaClient(private val properties: TaigaProperties) {
?: throw TaigaUnavailableException("Unerwartete Taiga-Antwort: Feld '$key' fehlt")
companion object {
/** Hoechstens so viele gleichzeitige Upstream-Anfragen je Bulk-Aufruf. */
private const val BULK_CONCURRENCY = 6
private val MAP = object : ParameterizedTypeReference<Map<String, Any?>>() {}
private val LIST = object : ParameterizedTypeReference<List<Map<String, Any?>>>() {}
}
@@ -606,6 +606,73 @@ paths:
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/tickets:
get:
tags: [Taiga]
operationId: taigaTickets
summary: Den Stand vieler Tickets auf einmal lesen (Proxy)
description: >
Bulk-Lesen fuer die Abweichungs-Marken im Diagramm (D91-Nachtrag 10):
Der Editor stellt EINE Anfrage mit allen Refs eines Dokuments, der
Proxy faechert sie in `by_ref`-Einzelabfragen auf - parallel mit
gedeckelter Nebenlaeufigkeit, denn Backend und Taiga-Instanz sitzen
nah beieinander, der Browser nicht. Die Kosten skalieren mit den
Refs im Plan, nie mit der Groesse des Taiga-Projekts (dort koennen
Tausende Tickets liegen - eine Projekt-Volliste waere die falsche
Bulk-Einheit). Das Praefix jeder Ref traegt den Typ
(`US-`/`T-`, SPEC par. 11). Eine Ref, die es im Projekt nicht
(mehr) gibt, fehlt still in der Antwort - die uebrigen kommen
trotzdem; jeder andere Fehler (401, Projekt unbekannt, Taiga weg)
gilt der ganzen Anfrage.
parameters:
- $ref: "#/components/parameters/TaigaToken"
- $ref: "#/components/parameters/TaigaSlug"
- name: refs
in: query
required: true
description: >
Kommagetrennte Werkbaum-Refs (`US-123,T-1234`), hoechstens 200 -
mehr referenziert kein Plan, und der Deckel haelt den Faecher
endlich. Ungueltige Eintraege sind ein 400, nicht still
uebersprungen.
schema:
type: string
minLength: 1
maxLength: 4000
responses:
"200":
description: >
Map Werkbaum-Ref (`US-123`) auf den Ticket-Stand. Fehlende
Schluessel heissen: dieses Ticket gibt es im Projekt nicht (mehr).
content:
application/json:
schema:
type: object
additionalProperties:
$ref: "#/components/schemas/TaigaTicketDetail"
"400":
description: Refs-Liste ungueltig oder zu lang
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"401":
description: Token fehlt oder ist abgelaufen
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"404":
description: Das Projekt zum Slug gibt es nicht
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"502":
$ref: "#/components/responses/TaigaUnavailable"
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/userstories/{ref}/status:
patch:
tags: [Taiga]
@@ -154,6 +154,31 @@ class TaigaApiTest {
result.responseBody!! shouldContain """{"id":12,"name":"In progress","closed":false}"""
}
@Test
fun `die Bulk-Abfrage liefert eine Map unter den Werkbaum-Refs`() {
val result = client.get()
.uri("/api/v1/taiga/tickets?slug=mi-kunde&refs=US-123,T-1234")
.header("X-Taiga-Token", "tok-abc123")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"US-123\""
result.responseBody!! shouldContain "\"T-1234\""
result.responseBody!! shouldContain "\"status\":\"In progress\""
result.responseBody!! shouldContain "\"status\":\"Done\""
}
@Test
fun `eine ungueltige Ref in der Bulk-Liste ist ein 400, nicht still uebersprungen`() {
val result = client.get()
.uri("/api/v1/taiga/tickets?slug=mi-kunde&refs=US-123,kaputt")
.header("X-Taiga-Token", "tok-abc123")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 400
result.responseBody!! shouldContain "kaputt"
}
@Test
fun `ein Status wird per Ref gesetzt und antwortet mit dem neuen Stand`() {
val result = client.patch()
@@ -220,6 +220,49 @@ class TaigaClientTest {
client().ticket("tok-abc123", "mi-kunde", 123, task = false).version shouldBe 7L
}
/* ---- Bulk-Lesen (D91-Nachtrag 10) ---- */
@Test
fun `tickets faechert je Ref auf und liefert die Map unter den Werkbaum-Refs`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref?project=7&ref=123"] = 200 to STORY_DETAIL
routes["/api/v1/tasks/by_ref?project=7&ref=1234"] = 200 to TASK_DETAIL
val map = client().tickets("tok-abc123", "mi-kunde", listOf(
TaigaBulkRefData("US-123", task = false, nr = 123),
TaigaBulkRefData("T-1234", task = true, nr = 1234),
))
map.keys shouldBe setOf("US-123", "T-1234")
map["US-123"]!!.status shouldBe "In progress"
map["T-1234"]!!.status shouldBe "Done"
/* Die Projekt-Aufloesung laeuft EINMAL, nicht je Ref. */
requests.count { it.path == "/api/v1/projects/by_slug" } shouldBe 1
}
@Test
fun `eine Ref, die es nicht mehr gibt, fehlt still - die uebrigen kommen trotzdem`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref?project=7&ref=123"] = 200 to STORY_DETAIL
routes["/api/v1/userstories/by_ref?project=7&ref=999"] = 404 to NOT_FOUND
val map = client().tickets("tok-abc123", "mi-kunde", listOf(
TaigaBulkRefData("US-123", task = false, nr = 123),
TaigaBulkRefData("US-999", task = false, nr = 999),
))
map.keys shouldBe setOf("US-123")
}
@Test
fun `ein abgelaufenes Token bricht die ganze Bulk-Anfrage ab`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref?project=7&ref=123"] = 401 to NOT_FOUND
val ex = shouldThrow<TaigaUpstreamException> {
client().tickets("tok-abc123", "mi-kunde",
listOf(TaigaBulkRefData("US-123", task = false, nr = 123)))
}
ex.status shouldBe 401
}
@Test
fun `ohne konfigurierte Instanz gibt es kein Ziel`() {
val bare = TaigaClient(TaigaProperties(apiUrl = ""))
@@ -315,7 +358,10 @@ class TaigaClientTest {
body = ex.requestBody.readBytes().decodeToString(),
)
requests += recorded!!
val route = routes[ex.requestURI.path]
/* Query-genaue Route vor der Pfad-Route: Die Bulk-Abfrage
trifft denselben Pfad mit verschiedenen Refs. */
val route = routes[ex.requestURI.path + "?" + (ex.requestURI.query ?: "")]
?: routes[ex.requestURI.path]
val status = route?.first ?: responseStatus
val bytes = (route?.second ?: responseBody).encodeToByteArray()
ex.responseHeaders.set("Content-Type", "application/json")