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
@@ -7,6 +7,7 @@ import de.werkbaum.generated.model.TaigaSession
import de.werkbaum.generated.model.TaigaStoryCreateRequest
import de.werkbaum.generated.model.TaigaTaskCreateRequest
import de.werkbaum.generated.model.TaigaTicket
import de.werkbaum.generated.model.TaigaTicketDetail
import de.werkbaum.integration.taiga.TaigaClient
import de.werkbaum.integration.taiga.TaigaTicketData
import org.springframework.http.HttpStatus
@@ -72,6 +73,31 @@ class TaigaController(private val client: TaigaClient) : TaigaApi {
return created(ticket)
}
/* 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
Editor: Das Backend parst die Notation nicht (D14). */
override fun taigaStoryByRef(xTaigaToken: String, ref: Long, slug: String) =
ticket(xTaigaToken, slug, ref, task = false)
override fun taigaTaskByRef(xTaigaToken: String, ref: Long, slug: String) =
ticket(xTaigaToken, slug, ref, task = true)
private fun ticket(token: String, slug: String, ref: Long, task: Boolean):
ResponseEntity<TaigaTicketDetail> {
val d = client.ticket(token, slug, ref, task)
return ResponseEntity.ok(
TaigaTicketDetail(
id = d.id,
ref = d.ref,
subject = d.subject,
status = d.status,
statusClosed = d.statusClosed,
assignee = d.assignee,
)
)
}
private fun created(ticket: TaigaTicketData): ResponseEntity<TaigaTicket> =
ResponseEntity.status(HttpStatus.CREATED).body(
TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject)
@@ -7,7 +7,9 @@ import org.springframework.stereotype.Service
import org.springframework.web.client.ResourceAccessException
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientResponseException
import java.net.URLEncoder
import java.net.http.HttpClient
import java.nio.charset.StandardCharsets
import java.time.Duration
/** Keine Taiga-Instanz konfiguriert — der Proxy hat kein Ziel (503). */
@@ -36,6 +38,21 @@ data class TaigaProjectData(val id: Long, val name: String, val slug: String)
data class TaigaTicketData(val id: Long, val ref: Long, val subject: String)
/**
* Der gelesene Stand eines Tickets (D91-Nachtrag 6). Status und Zuständiger
* kommen aus Taigas `*_extra_info`-Blöcken und sind **nullbar**: Liefert die
* Instanz sie nicht mit, fehlt die Zeile im Knoten-Fenster — geraten wird
* nicht.
*/
data class TaigaTicketDetailData(
val id: Long,
val ref: Long,
val subject: String,
val status: String?,
val statusClosed: Boolean?,
val assignee: String?,
)
/**
* Schmaler, benannter Client zur konfigurierten Taiga-Instanz (D91) — kein
* Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht.
@@ -91,6 +108,53 @@ class TaigaClient(private val properties: TaigaProperties) {
// Taigas Feldname; unsere API sagt `userStory` (camelCase wie überall).
create(token, "/tasks", mapOf("project" to project, "subject" to subject, "user_story" to userStory))
/**
* Ein Ticket über seine **Ref** lesen (D91-Nachtrag 6). Zwei Schritte,
* weil eine Ref nur je Projekt eindeutig ist: erst der Projekt-Slug zur
* Id (`/projects/by_slug`), dann `by_ref` am passenden Endpunkt. Beide
* sind dokumentierte Taiga-Endpunkte — der eine gesparte Umlauf über
* `project__slug` wäre eine Wette auf eine Filter-Eigenheit gewesen.
*
* `task` unterscheidet die beiden Typen; welcher gemeint ist, weiß der
* 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)
val pfad = if (task) "/tasks/by_ref" else "/userstories/by_ref"
val map = exchange {
rest.get().uri(url("$pfad?project=$project&ref=$ref"))
.header("Authorization", "Bearer $token")
.retrieve().body(MAP)
} ?: throw TaigaUnavailableException("Leere Antwort von Taiga ($pfad)")
return TaigaTicketDetailData(
id = num(map, "id"),
ref = num(map, "ref"),
subject = str(map, "subject"),
status = extra(map, "status_extra_info")?.get("name") as? String,
statusClosed = extra(map, "status_extra_info")?.get("is_closed") as? Boolean,
assignee = extra(map, "assigned_to_extra_info")?.get("full_name_display") as? String,
)
}
/**
* Projekt-Slug -> Id; Taigas `by_ref` filtert über die Id. Der Slug kommt
* vom Client und wird deshalb **kodiert** in die Anfrage gesetzt — sonst
* hängte ein `&` daran einen weiteren Filter an.
*/
fun projectId(token: String, slug: String): Long {
val map = exchange {
rest.get().uri(url("/projects/by_slug?slug=" + enc(slug)))
.header("Authorization", "Bearer $token")
.retrieve().body(MAP)
} ?: throw TaigaUnavailableException("Leere Antwort von Taiga (/projects/by_slug)")
return num(map, "id")
}
@Suppress("UNCHECKED_CAST")
private fun extra(m: Map<String, Any?>, key: String): Map<String, Any?>? =
m[key] as? Map<String, Any?>
private fun create(token: String, path: String, body: Map<String, Any>): TaigaTicketData {
val map = exchange {
rest.post().uri(url(path))
@@ -102,6 +166,8 @@ class TaigaClient(private val properties: TaigaProperties) {
return TaigaTicketData(id = num(map, "id"), ref = num(map, "ref"), subject = str(map, "subject"))
}
private fun enc(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
private fun url(path: String): String {
if (!properties.configured) throw TaigaNotConfiguredException()
return properties.apiUrl.trimEnd('/') + path
+139
View File
@@ -524,6 +524,88 @@ paths:
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/userstories/{ref}:
get:
tags: [Taiga]
operationId: taigaStoryByRef
summary: User Story ueber ihre Ref lesen (Proxy)
description: >
Loest `#US-<ref>` auf (D91-Nachtrag 6): Betreff, Status und
Zustaendiger einer Story. Refs sind nur **je Projekt** eindeutig -
deshalb der `slug`, den der Editor aus dem Schlagwort
`&taiga.<slug>` des Teilbaums nimmt (SPEC par. 1). Der Proxy fragt
dafuer zuerst `GET <api-url>/projects/by_slug`, dann
`GET <api-url>/userstories/by_ref?project=<id>&ref=<ref>`.
Nur gelesen: Werkbaum schreibt hier nichts nach Taiga und nichts in
den Notationstext.
parameters:
- $ref: "#/components/parameters/TaigaToken"
- $ref: "#/components/parameters/TaigaRef"
- $ref: "#/components/parameters/TaigaSlug"
responses:
"200":
description: Die Story
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaTicketDetail"
"401":
description: Token fehlt oder ist abgelaufen
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"404":
description: >
Projekt oder Ref gibt es nicht - Taigas Status wird
durchgereicht (der Editor zeigt den Fehlertext im Knoten-Fenster).
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"502":
$ref: "#/components/responses/TaigaUnavailable"
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/tasks/{ref}:
get:
tags: [Taiga]
operationId: taigaTaskByRef
summary: Task ueber ihre Ref lesen (Proxy)
description: >
Wie `GET /taiga/userstories/{ref}`, nur ueber
`GET <api-url>/tasks/by_ref` - loest `#T-<ref>` auf. Zwei Endpunkte
statt eines mit Typ-Parameter, weil das Praefix der Ref den Typ
traegt und Taiga getrennte `by_ref`-Endpunkte hat (SPEC par. 11).
parameters:
- $ref: "#/components/parameters/TaigaToken"
- $ref: "#/components/parameters/TaigaRef"
- $ref: "#/components/parameters/TaigaSlug"
responses:
"200":
description: Die Task
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaTicketDetail"
"401":
description: Token fehlt oder ist abgelaufen
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"404":
description: Projekt oder Ref gibt es nicht
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"502":
$ref: "#/components/responses/TaigaUnavailable"
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/info:
get:
tags: [Documents]
@@ -559,6 +641,31 @@ components:
type: string
maxLength: 512
TaigaRef:
name: ref
in: path
required: true
description: >
Die nackte Taiga-Nummer, ohne das Werkbaum-Praefix: aus `#US-123`
bzw. `#T-1234` wird `123` bzw. `1234`. Den Typ traegt der Pfad.
schema:
type: integer
format: int64
minimum: 1
TaigaSlug:
name: slug
in: query
required: true
description: >
Projekt-Slug aus dem Schlagwort `&taiga.<slug>` (SPEC par. 1). Refs
laufen je Projekt fortlaufend - ohne das Projekt ist eine Ref nicht
eindeutig.
schema:
type: string
minLength: 1
maxLength: 255
responses:
TaigaNotConfigured:
description: >
@@ -975,6 +1082,38 @@ components:
subject:
type: string
TaigaTicketDetail:
type: object
description: >
Der gelesene Stand eines Tickets (D91-Nachtrag 6) - schmal wie alles
hier: genau die Felder, die das Knoten-Fenster zeigt.
required: [id, ref, subject]
properties:
id:
type: integer
format: int64
ref:
type: integer
format: int64
subject:
type: string
status:
type: string
description: >
Name des Taiga-Workflow-Status (`status_extra_info.name`), z. B.
"In progress". Die Abbildung auf die Statusbox der Notation macht
der Editor (SPEC par. 4/9) - das Backend parst die Notation nicht
(D14). Fehlt, wenn Taiga den Namen nicht mitliefert.
statusClosed:
type: boolean
description: Taigas eigene Aussage, ob der Status als erledigt gilt.
assignee:
type: string
description: >
Anzeigename des Zustaendigen
(`assigned_to_extra_info.full_name_display`); fehlt, wenn niemand
zugewiesen ist.
ProblemDetail:
type: object
description: Fehlerformat nach RFC 9457 (Problem Details)