feat(taiga): Status zurückschreiben — zwei Knöpfe, niemand gewinnt von selbst (D91-Nachtrag 8, SPEC §9)

Weicht der Ticket-Status von der Statusbox ab, markiert das Knoten-Fenster
die Abweichung und bietet beide Richtungen ausdrücklich an.

- „nach Taiga schreiben": Spalte des Projekts suchen (Taiga schreibt nach Id,
  die Namen sind je Projekt frei) und mit der zuletzt GELESENEN `version`
  patchen — hat jemand dazwischen geändert, lehnt Taiga ab und der Text steht
  im Fenster, statt dass etwas überschrieben wird.
- „aus Taiga übernehmen": `setStatusBox()` schreibt die Box in die Textzeile,
  undo-fähig wie jede andere Änderung.
- Schreibbar sind nur die fünf abgebildeten Zustände; `[?]`, `[!]`, `[-]` und
  der neutrale Knoten lassen das Ticket unangetastet — mit Begründung im
  Fenster.
- Proxy: zwei Spaltenlisten (`/taiga/{userstory,task}-statuses?slug=`) und
  zwei Schreib-Endpunkte (`PATCH …/{ref}/status?slug=`); die Zielspalte wählt
  der Editor, das Backend parst die Notation nicht (D14).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
mhoennig
2026-08-27 22:18:52 +02:00
co-authored by Claude Fable 5
parent 4e6f98bbee
commit 323e8fe0ba
16 changed files with 826 additions and 75 deletions
@@ -6,9 +6,12 @@ import de.werkbaum.generated.model.TaigaProject
import de.werkbaum.generated.model.TaigaSession
import de.werkbaum.generated.model.TaigaStoryCreateRequest
import de.werkbaum.generated.model.TaigaTaskCreateRequest
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.TaigaClient
import de.werkbaum.integration.taiga.TaigaTicketDetailData
import de.werkbaum.integration.taiga.TaigaTicketData
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
@@ -84,19 +87,60 @@ class TaigaController(private val client: TaigaClient) : TaigaApi {
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,
)
ResponseEntity<TaigaTicketDetail> =
ResponseEntity.ok(client.ticket(token, slug, ref, task).toApi())
/* Schreiben (D91-Nachtrag 7/8): angestoßen wird es im Knoten-Fenster, von
selbst geschieht nichts. Die Zielspalte kommt als Id herein — welche es
ist, entscheidet der Editor über die Statusbox-Abbildung (D14). */
override fun taigaStoryStatuses(xTaigaToken: String, slug: String) =
statuses(xTaigaToken, slug, task = false)
override fun taigaTaskStatuses(xTaigaToken: String, slug: String) =
statuses(xTaigaToken, slug, task = true)
private fun statuses(token: String, slug: String, task: Boolean):
ResponseEntity<List<TaigaStatus>> =
ResponseEntity.ok(
client.statuses(token, slug, task).map {
TaigaStatus(id = it.id, name = it.name, closed = it.closed)
}
)
}
override fun taigaSetStoryStatus(
xTaigaToken: String,
ref: Long,
slug: String,
taigaStatusPatch: TaigaStatusPatch,
) = setStatus(xTaigaToken, slug, ref, task = false, patch = taigaStatusPatch)
override fun taigaSetTaskStatus(
xTaigaToken: String,
ref: Long,
slug: String,
taigaStatusPatch: TaigaStatusPatch,
) = setStatus(xTaigaToken, slug, ref, task = true, patch = taigaStatusPatch)
private fun setStatus(
token: String,
slug: String,
ref: Long,
task: Boolean,
patch: TaigaStatusPatch,
): ResponseEntity<TaigaTicketDetail> =
ResponseEntity.ok(
client.setStatus(token, slug, ref, task, patch.status, patch.version).toApi()
)
private fun TaigaTicketDetailData.toApi() = TaigaTicketDetail(
id = id,
ref = ref,
subject = subject,
status = status,
statusClosed = statusClosed,
assignee = assignee,
version = version,
)
private fun created(ticket: TaigaTicketData): ResponseEntity<TaigaTicket> =
ResponseEntity.status(HttpStatus.CREATED).body(
@@ -51,8 +51,13 @@ data class TaigaTicketDetailData(
val status: String?,
val statusClosed: Boolean?,
val assignee: String?,
/** Taigas optimistische Sperre; geht beim Schreiben unverändert zurück. */
val version: Long?,
)
/** Eine Spalte des Projekt-Workflows (D91-Nachtrag 8). */
data class TaigaStatusData(val id: Long, val name: String, val closed: Boolean?)
/**
* Schmaler, benannter Client zur konfigurierten Taiga-Instanz (D91) — kein
* Durchreich-Proxy: genau die vier Aufrufe, die die Ticket-Anlage braucht.
@@ -127,16 +132,65 @@ class TaigaClient(private val properties: TaigaProperties) {
.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,
)
return detail(map)
}
/**
* Die Spalten des Projekt-Workflows (D91-Nachtrag 8) — Taiga schreibt nach
* Status-**Id**, und die Namen sind je Projekt frei. Welche Spalte gemeint
* ist, entscheidet der Editor: Er kennt die Abbildung auf die Statusbox
* (SPEC §4), das Backend parst die Notation nicht (D14).
*/
fun statuses(token: String, slug: String, task: Boolean): List<TaigaStatusData> {
val project = projectId(token, slug)
val pfad = if (task) "/task-statuses" else "/userstory-statuses"
val list = exchange {
rest.get().uri(url("$pfad?project=$project"))
.header("Authorization", "Bearer $token")
.retrieve().body(LIST)
} ?: emptyList()
return list.map {
TaigaStatusData(num(it, "id"), str(it, "name"), it["is_closed"] as? Boolean)
}
}
/**
* Den Status eines Tickets setzen (D91-Nachtrag 8). Die `version` kommt
* vom Client — sie ist die, die er gelesen hat: Taigas optimistische
* Sperre lehnt das Schreiben ab, wenn jemand dazwischen geändert hat, und
* der Konflikt wird durchgereicht statt überschrieben (dieselbe Haltung
* wie beim Live-Editing, D76).
*/
fun setStatus(
token: String,
slug: String,
ref: Long,
task: Boolean,
status: Long,
version: Long,
): TaigaTicketDetailData {
val id = ticket(token, slug, ref, task).id
val pfad = if (task) "/tasks/$id" else "/userstories/$id"
val map = exchange {
rest.patch().uri(url(pfad))
.header("Authorization", "Bearer $token")
.contentType(MediaType.APPLICATION_JSON)
.body(mapOf("status" to status, "version" to version))
.retrieve().body(MAP)
} ?: throw TaigaUnavailableException("Leere Antwort von Taiga ($pfad)")
return detail(map)
}
private fun detail(map: Map<String, Any?>) = 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,
version = (map["version"] as? Number)?.toLong(),
)
/**
* Projekt-Slug -> Id; Taigas `by_ref` filtert über die Id. Der Slug kommt
* vom Client und wird deshalb **kodiert** in die Anfrage gesetzt — sonst
+203
View File
@@ -606,6 +606,173 @@ paths:
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/userstories/{ref}/status:
patch:
tags: [Taiga]
operationId: taigaSetStoryStatus
summary: Status einer User Story setzen (Proxy)
description: >
Schreibt den Status zurueck (D91-Nachtrag 7/8) - die eine Haelfte des
Abgleichs, die der Benutzer im Knoten-Fenster ausdruecklich anstoesst;
von selbst geschieht nichts. `status` ist die **Id** einer Spalte aus
`GET /taiga/userstory-statuses` (die Namen sind je Projekt frei),
`version` die zuletzt gelesene: Hat jemand dazwischen geaendert, lehnt
Taigas optimistische Sperre ab und der Konflikt wird durchgereicht,
statt ihn zu ueberschreiben.
parameters:
- $ref: "#/components/parameters/TaigaToken"
- $ref: "#/components/parameters/TaigaRef"
- $ref: "#/components/parameters/TaigaSlug"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaStatusPatch"
responses:
"200":
description: Der neue Stand des Tickets
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaTicketDetail"
"400":
$ref: "#/components/responses/BadRequest"
"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"
/taiga/tasks/{ref}/status:
patch:
tags: [Taiga]
operationId: taigaSetTaskStatus
summary: Status einer Task setzen (Proxy)
description: >
Wie `PATCH /taiga/userstories/{ref}/status`, nur fuer Tasks; die
Spalten kommen aus `GET /taiga/task-statuses`.
parameters:
- $ref: "#/components/parameters/TaigaToken"
- $ref: "#/components/parameters/TaigaRef"
- $ref: "#/components/parameters/TaigaSlug"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaStatusPatch"
responses:
"200":
description: Der neue Stand des Tickets
content:
application/json:
schema:
$ref: "#/components/schemas/TaigaTicketDetail"
"400":
$ref: "#/components/responses/BadRequest"
"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"
/taiga/userstory-statuses:
get:
tags: [Taiga]
operationId: taigaStoryStatuses
summary: Workflow-Spalten der Storys eines Projekts (Proxy)
description: >
`GET <api-url>/userstory-statuses?project=<id>`. Gebraucht zum
Schreiben: Taiga nimmt die Status-**Id**, und welche Spalte zu welcher
Statusbox gehoert, entscheidet der Editor (SPEC par. 4/9) - das
Backend parst die Notation nicht (D14).
parameters:
- $ref: "#/components/parameters/TaigaToken"
- $ref: "#/components/parameters/TaigaSlug"
responses:
"200":
description: Die Spalten des Projekts
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/TaigaStatus"
"401":
description: Token fehlt oder ist abgelaufen
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"404":
description: Projekt gibt es nicht
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"502":
$ref: "#/components/responses/TaigaUnavailable"
"503":
$ref: "#/components/responses/TaigaNotConfigured"
/taiga/task-statuses:
get:
tags: [Taiga]
operationId: taigaTaskStatuses
summary: Workflow-Spalten der Tasks eines Projekts (Proxy)
description: Wie `GET /taiga/userstory-statuses`, nur fuer Tasks.
parameters:
- $ref: "#/components/parameters/TaigaToken"
- $ref: "#/components/parameters/TaigaSlug"
responses:
"200":
description: Die Spalten des Projekts
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/TaigaStatus"
"401":
description: Token fehlt oder ist abgelaufen
content:
application/problem+json:
schema:
$ref: "#/components/schemas/ProblemDetail"
"404":
description: Projekt 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]
@@ -1113,6 +1280,42 @@ components:
Anzeigename des Zustaendigen
(`assigned_to_extra_info.full_name_display`); fehlt, wenn niemand
zugewiesen ist.
version:
type: integer
format: int64
description: >
Taigas optimistische Sperre. Sie geht beim Schreiben unveraendert
zurueck (`PATCH .../status`); passt sie nicht mehr, lehnt Taiga ab
und der Konflikt wird durchgereicht.
TaigaStatus:
type: object
description: Eine Spalte des Projekt-Workflows (D91-Nachtrag 8).
required: [id, name]
properties:
id:
type: integer
format: int64
name:
type: string
description: >
Frei benannt, je Projekt verschieden - die Zuordnung zur Statusbox
der Notation macht der Editor.
closed:
type: boolean
TaigaStatusPatch:
type: object
required: [status, version]
properties:
status:
type: integer
format: int64
description: Id der Zielspalte (aus den Status-Listen), nicht ihr Name.
version:
type: integer
format: int64
description: Die zuletzt gelesene Version des Tickets.
ProblemDetail:
type: object