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
@@ -143,6 +143,31 @@ class TaigaApiTest {
result.responseBody!! shouldContain "\"assignee\":null"
}
@Test
fun `die Workflow-Spalten eines Projekts kommen als Id und Name`() {
val result = client.get()
.uri("/api/v1/taiga/userstory-statuses?slug=mi-kunde")
.header("X-Taiga-Token", "tok-abc123")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain """{"id":12,"name":"In progress","closed":false}"""
}
@Test
fun `ein Status wird per Ref gesetzt und antwortet mit dem neuen Stand`() {
val result = client.patch()
.uri("/api/v1/taiga/userstories/123/status?slug=mi-kunde")
.header("X-Taiga-Token", "tok-abc123")
.header("Content-Type", "application/json")
.body("""{"status":13,"version":7}""")
.exchange()
.returnResult(String::class.java)
result.status.value() shouldBe 200
result.responseBody!! shouldContain "\"status\":\"Done\""
result.responseBody!! shouldContain "\"version\":8"
}
companion object {
private lateinit var stub: HttpServer
@Volatile private var stubStatus = 200
@@ -160,6 +185,9 @@ class TaigaApiTest {
"/api/v1/projects/by_slug" -> TaigaClientTestData.PROJECT_OK
"/api/v1/userstories/by_ref" -> TaigaClientTestData.STORY_DETAIL
"/api/v1/tasks/by_ref" -> TaigaClientTestData.TASK_DETAIL
"/api/v1/userstory-statuses", "/api/v1/task-statuses" ->
TaigaClientTestData.STATUSES_OK
"/api/v1/userstories/1234" -> TaigaClientTestData.STORY_PATCHED
else -> "{}"
}
val status = if (stubBody != null) stubStatus
@@ -209,9 +237,16 @@ object TaigaClientTestData {
const val PROJECT_OK =
"""{"id": 7, "name": "Kunde", "slug": "mi-kunde"}"""
const val STORY_DETAIL =
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7,
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, "version": 7,
"status_extra_info": {"name": "In progress", "is_closed": false},
"assigned_to_extra_info": {"full_name_display": "Anna Beispiel"}}"""
const val STATUSES_OK =
"""[{"id": 11, "name": "New", "is_closed": false},
{"id": 12, "name": "In progress", "is_closed": false},
{"id": 13, "name": "Done", "is_closed": true}]"""
const val STORY_PATCHED =
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "version": 8,
"status_extra_info": {"name": "Done", "is_closed": true}}"""
const val TASK_DETAIL =
"""{"id": 5678, "ref": 1234, "subject": "API-Teil", "project": 7,
"status_extra_info": {"name": "Done", "is_closed": true}}"""
@@ -162,6 +162,64 @@ class TaigaClientTest {
requests[0].query shouldBe "slug=a%26member%3D1"
}
/* ---- Status zurückschreiben (D91-Nachtrag 7/8) ---- */
@Test
fun `statuses liefert die Spalten des Projekt-Workflows`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstory-statuses"] = 200 to STATUSES_OK
val s = client().statuses("tok-abc123", "mi-kunde", task = false)
s.map { it.name } shouldBe listOf("New", "In progress", "Done")
s[0].id shouldBe 11L
s[2].closed shouldBe true
requests[1].query shouldBe "project=7"
}
@Test
fun `Task-Spalten kommen vom eigenen Endpunkt`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/task-statuses"] = 200 to STATUSES_OK
client().statuses("tok-abc123", "mi-kunde", task = true)
requests[1].path shouldBe "/api/v1/task-statuses"
}
@Test
fun `setStatus loest die Ref auf und patcht mit Status-Id und Version`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref"] = 200 to STORY_DETAIL
routes["/api/v1/userstories/1234"] = 200 to STORY_PATCHED
val d = client().setStatus("tok-abc123", "mi-kunde", 123, task = false, status = 13, version = 7)
d.status shouldBe "Done"
d.version shouldBe 8L
val patch = requests.last()
patch.path shouldBe "/api/v1/userstories/1234" /* per Id, nicht per Ref */
patch.method shouldBe "PATCH"
patch.body shouldContain "\"status\":13"
patch.body shouldContain "\"version\":7"
}
@Test
fun `eine veraltete Version wird als Konflikt durchgereicht`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref"] = 200 to STORY_DETAIL
routes["/api/v1/userstories/1234"] = 400 to STALE
val ex = shouldThrow<TaigaUpstreamException> {
client().setStatus("tok-abc123", "mi-kunde", 123, task = false, status = 13, version = 1)
}
ex.status shouldBe 400
ex.message shouldContain "modified"
}
@Test
fun `die gelesene Version kommt mit - sie ist die Sperre fuers Schreiben`() {
routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK
routes["/api/v1/userstories/by_ref"] = 200 to STORY_DETAIL
client().ticket("tok-abc123", "mi-kunde", 123, task = false).version shouldBe 7L
}
@Test
fun `ohne konfigurierte Instanz gibt es kein Ziel`() {
val bare = TaigaClient(TaigaProperties(apiUrl = ""))
@@ -183,6 +241,7 @@ class TaigaClientTest {
data class Recorded(
val path: String,
val method: String,
val query: String?,
val auth: String?,
val noPagination: String?,
@@ -220,7 +279,7 @@ class TaigaClientTest {
/* 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,
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, "status": 3, "version": 7,
"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"}}"""
@@ -230,6 +289,15 @@ class TaigaClientTest {
"assigned_to_extra_info": null}"""
const val STORY_BARE =
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, "status": 3}"""
const val STATUSES_OK =
"""[{"id": 11, "name": "New", "is_closed": false, "order": 1},
{"id": 12, "name": "In progress", "is_closed": false},
{"id": 13, "name": "Done", "is_closed": true}]"""
const val STORY_PATCHED =
"""{"id": 1234, "ref": 123, "subject": "Login bauen", "version": 8,
"status_extra_info": {"name": "Done", "is_closed": true}}"""
const val STALE =
"""{"_error_message": "The version doesn't match the data base version. The object was modified.", "_error_type": "taiga.base.exceptions.WrongArguments"}"""
const val NOT_FOUND =
"""{"_error_message": "Not found.", "_error_type": "taiga.base.exceptions.NotFound"}"""
@@ -240,6 +308,7 @@ class TaigaClientTest {
server.createContext("/") { ex ->
recorded = Recorded(
path = ex.requestURI.path,
method = ex.requestMethod,
query = ex.requestURI.query,
auth = ex.requestHeaders.getFirst("Authorization"),
noPagination = ex.requestHeaders.getFirst("x-disable-pagination"),