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:
co-authored by
Claude Opus 5
parent
e77a4d1ce4
commit
a1e4a3bbab
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user