From 842e89795a25cf4ccfd3fd42290772b4e77514b2 Mon Sep 17 00:00:00 2001 From: mhoennig Date: Thu, 27 Aug 2026 20:41:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(taiga):=20Ticket-Stand=20im=20Knoten-Fenst?= =?UTF-8?q?er=20=E2=80=94=20gelesen,=20nie=20geschrieben=20(D91-Nachtrag?= =?UTF-8?q?=206,=20SPEC=20=C2=A79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wo eine Ref steht und ein `&taiga.` 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 --- backend/CLAUDE.md | 12 +- .../kotlin/de/werkbaum/api/TaigaController.kt | 26 +++ .../werkbaum/integration/taiga/TaigaClient.kt | 66 +++++++ backend/src/main/resources/openapi/api.yaml | 139 +++++++++++++++ .../kotlin/de/werkbaum/api/TaigaApiTest.kt | 40 +++++ .../integration/taiga/TaigaClientTest.kt | 96 +++++++++- docs/CHANGELOG.md | 5 + docs/DECISIONS.md | 87 +++++++++ docs/SPEC.md | 27 +++ docs/examples/werkbaum.werkbaum | 12 +- frontend/CLAUDE.md | 14 ++ frontend/src/app.js | 166 +++++++++++++++--- frontend/src/style.css | 12 ++ frontend/src/taiga.js | 46 ++++- frontend/tests/taiga.test.js | 61 ++++++- 15 files changed, 776 insertions(+), 33 deletions(-) diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index a2a0e72..c30a81c 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -18,7 +18,8 @@ schneiden, dass die Berechtigungsprüfung dazukommen kann, ohne die Signatur zu brechen. **Taiga-Proxy (D91):** schmale, benannte Endpunkte unter `/api/v1/taiga/*` -(auth, projects, userstories, tasks) in `de.werkbaum.integration.taiga` +(auth, projects, userstories, tasks — je POST zum Anlegen und GET +`…/{ref}?slug=` zum **Lesen**, D91-Nachtrag 6) in `de.werkbaum.integration.taiga` (`TaigaClient` + `TaigaProperties`), Controller in `api`. Die Basis-URL der Taiga-**API** ist Server-Konfiguration (`werkbaum.taiga.api-url` bzw. `WERKBAUM_TAIGA_API_URL`), **nie** Request-Parameter — die SSRF-Falle @@ -71,3 +72,12 @@ docs/SPEC.md §10 testen — niemals eine zweite, abweichende Grammatik pflegen. URL, Status. Status-Mapping Taiga-Workflow → Notation konfigurierbar (Default: „New"→`[ ]`, „In progress"→`[~]`, „Ready for test"→`[/]`, „Done"→`[x]`, „Archived"→`[^]`). +- **Abgebildet wird im Frontend, nicht hier** (D91-Nachtrag 6): Der Proxy + reicht Taigas Status-**Namen** durch (`status_extra_info.name`), die + Statuscodes sind Notations-Vokabular und das Backend parst die Notation + nicht (D14). Die Tabelle steht headless in `frontend/src/taiga.js` + (`mapTaigaStatus`); konfigurierbar ist sie noch nicht. +- **Eine Ref ist nur je Projekt eindeutig:** Die Lese-Endpunkte nehmen + deshalb den `slug` (aus `&taiga.`, 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. diff --git a/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt b/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt index d5dd1d9..dc7d66a 100644 --- a/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt +++ b/backend/src/main/kotlin/de/werkbaum/api/TaigaController.kt @@ -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 { + 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 = ResponseEntity.status(HttpStatus.CREATED).body( TaigaTicket(id = ticket.id, ref = ticket.ref, subject = ticket.subject) diff --git a/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt b/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt index 47e5268..9d94731 100644 --- a/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt +++ b/backend/src/main/kotlin/de/werkbaum/integration/taiga/TaigaClient.kt @@ -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, key: String): Map? = + m[key] as? Map + private fun create(token: String, path: String, body: Map): 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 diff --git a/backend/src/main/resources/openapi/api.yaml b/backend/src/main/resources/openapi/api.yaml index 6e22f1a..7ed28d6 100644 --- a/backend/src/main/resources/openapi/api.yaml +++ b/backend/src/main/resources/openapi/api.yaml @@ -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-` 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.` des Teilbaums nimmt (SPEC par. 1). Der Proxy fragt + dafuer zuerst `GET /projects/by_slug`, dann + `GET /userstories/by_ref?project=&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 /tasks/by_ref` - loest `#T-` 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.` (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) diff --git a/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt b/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt index 6a168db..63d27c3 100644 --- a/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt +++ b/backend/src/test/kotlin/de/werkbaum/api/TaigaApiTest.kt @@ -115,6 +115,34 @@ class TaigaApiTest { result.responseBody!! shouldContain "\"ref\":124" } + @Test + fun `eine Story-Ref wird ueber Slug und by_ref aufgeloest`() { + val result = client.get() + .uri("/api/v1/taiga/userstories/123?slug=mi-kunde") + .header("X-Taiga-Token", "tok-abc123") + .exchange() + .returnResult(String::class.java) + result.status.value() shouldBe 200 + result.responseBody!! shouldContain "\"subject\":\"Login bauen\"" + // Der Status kommt als NAME an; abgebildet wird er im Editor (D14). + result.responseBody!! shouldContain "\"status\":\"In progress\"" + result.responseBody!! shouldContain "\"assignee\":\"Anna Beispiel\"" + } + + @Test + fun `eine Task-Ref geht an den Task-Endpunkt und darf ohne Zustaendigen kommen`() { + val result = client.get() + .uri("/api/v1/taiga/tasks/1234?slug=mi-kunde") + .header("X-Taiga-Token", "tok-abc123") + .exchange() + .returnResult(String::class.java) + result.status.value() shouldBe 200 + result.responseBody!! shouldContain "\"status\":\"Done\"" + result.responseBody!! shouldContain "\"statusClosed\":true" + // Niemand zugewiesen: ausdrücklich null, nicht geraten. + result.responseBody!! shouldContain "\"assignee\":null" + } + companion object { private lateinit var stub: HttpServer @Volatile private var stubStatus = 200 @@ -129,6 +157,9 @@ class TaigaApiTest { "/api/v1/projects" -> TaigaClientTestData.PROJECTS_OK "/api/v1/userstories" -> TaigaClientTestData.STORY_OK "/api/v1/tasks" -> TaigaClientTestData.TASK_OK + "/api/v1/projects/by_slug" -> TaigaClientTestData.PROJECT_OK + "/api/v1/userstories/by_ref" -> TaigaClientTestData.STORY_DETAIL + "/api/v1/tasks/by_ref" -> TaigaClientTestData.TASK_DETAIL else -> "{}" } val status = if (stubBody != null) stubStatus @@ -175,4 +206,13 @@ object TaigaClientTestData { """{"id": 1234, "ref": 123, "subject": "Backend bauen", "project": 7}""" const val TASK_OK = """{"id": 5678, "ref": 124, "subject": "API-Teil", "project": 7, "user_story": 1234}""" + const val PROJECT_OK = + """{"id": 7, "name": "Kunde", "slug": "mi-kunde"}""" + const val STORY_DETAIL = + """{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, + "status_extra_info": {"name": "In progress", "is_closed": false}, + "assigned_to_extra_info": {"full_name_display": "Anna Beispiel"}}""" + const val TASK_DETAIL = + """{"id": 5678, "ref": 1234, "subject": "API-Teil", "project": 7, + "status_extra_info": {"name": "Done", "is_closed": true}}""" } diff --git a/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt b/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt index d53ec17..4458ccc 100644 --- a/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt +++ b/backend/src/test/kotlin/de/werkbaum/integration/taiga/TaigaClientTest.kt @@ -22,6 +22,8 @@ class TaigaClientTest { @BeforeEach fun reset() { recorded = null + requests.clear() + routes.clear() responseStatus = 200 responseBody = "{}" } @@ -96,6 +98,70 @@ class TaigaClientTest { recorded!!.body shouldContain "\"user_story\":1234" } + /* ---- Lesen über die Ref (D91-Nachtrag 6) ---- */ + + @Test + fun `ticket loest erst den Slug zur Projekt-Id auf und liest dann per by_ref`() { + routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK + routes["/api/v1/userstories/by_ref"] = 200 to STORY_DETAIL + val d = client().ticket("tok-abc123", "mi-kunde", 123, task = false) + + d.ref shouldBe 123L + d.id shouldBe 1234L + d.subject shouldBe "Login bauen" + d.status shouldBe "In progress" + d.statusClosed shouldBe false + d.assignee shouldBe "Anna Beispiel" + + requests.map { it.path } shouldBe + listOf("/api/v1/projects/by_slug", "/api/v1/userstories/by_ref") + requests[0].query shouldBe "slug=mi-kunde" + requests[1].query shouldBe "project=7&ref=123" + requests.all { it.auth == "Bearer tok-abc123" } shouldBe true + } + + @Test + fun `eine Task wird ueber ihren eigenen by_ref-Endpunkt gelesen`() { + routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK + routes["/api/v1/tasks/by_ref"] = 200 to TASK_DETAIL + val d = client().ticket("tok-abc123", "mi-kunde", 1234, task = true) + + d.ref shouldBe 1234L + d.status shouldBe "Done" + requests[1].path shouldBe "/api/v1/tasks/by_ref" + requests[1].query shouldBe "project=7&ref=1234" + } + + @Test + fun `ohne extra_info bleiben Status und Zustaendiger leer statt geraten`() { + routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK + routes["/api/v1/userstories/by_ref"] = 200 to STORY_BARE + val d = client().ticket("tok-abc123", "mi-kunde", 123, task = false) + + d.subject shouldBe "Login bauen" + d.status shouldBe null + d.statusClosed shouldBe null + d.assignee shouldBe null + } + + @Test + fun `ein unbekanntes Projekt wird als 404 durchgereicht, ohne zweiten Umlauf`() { + routes["/api/v1/projects/by_slug"] = 404 to NOT_FOUND + val ex = shouldThrow { + client().ticket("tok-abc123", "gibt-es-nicht", 123, task = false) + } + ex.status shouldBe 404 + requests.map { it.path } shouldBe listOf("/api/v1/projects/by_slug") + } + + @Test + fun `der Slug wird kodiert - ein & haengt keinen weiteren Filter an`() { + routes["/api/v1/projects/by_slug"] = 200 to PROJECT_OK + routes["/api/v1/userstories/by_ref"] = 200 to STORY_DETAIL + client().ticket("tok-abc123", "a&member=1", 123, task = false) + requests[0].query shouldBe "slug=a%26member%3D1" + } + @Test fun `ohne konfigurierte Instanz gibt es kein Ziel`() { val bare = TaigaClient(TaigaProperties(apiUrl = "")) @@ -131,6 +197,12 @@ class TaigaClientTest { @Volatile var responseStatus = 200 @Volatile var responseBody = "{}" + /* Das Lesen eines Tickets braucht ZWEI Umläufe (Slug -> Id, dann + by_ref) — deshalb Antworten je Pfad und alle Anfragen der Reihe + nach, statt nur der letzten. */ + val routes = mutableMapOf>() + val requests = mutableListOf() + /* Aufgezeichnete Antwortformen (gekuerzt auf die gebrauchten Felder plus typisches Beiwerk, damit der Client Unbekanntes ignoriert). */ const val AUTH_OK = @@ -143,6 +215,23 @@ class TaigaClientTest { """{"id": 1234, "ref": 123, "subject": "Backend bauen", "project": 7, "status": 1}""" const val TASK_OK = """{"id": 5678, "ref": 124, "subject": "API-Teil", "project": 7, "user_story": 1234}""" + const val PROJECT_OK = + """{"id": 7, "name": "Kunde", "slug": "mi-kunde"}""" + /* 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, + "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"}}""" + const val TASK_DETAIL = + """{"id": 5678, "ref": 1234, "subject": "API-Teil", "project": 7, + "status_extra_info": {"name": "Done", "is_closed": true}, + "assigned_to_extra_info": null}""" + const val STORY_BARE = + """{"id": 1234, "ref": 123, "subject": "Login bauen", "project": 7, "status": 3}""" + const val NOT_FOUND = + """{"_error_message": "Not found.", "_error_type": "taiga.base.exceptions.NotFound"}""" @JvmStatic @BeforeAll @@ -156,9 +245,12 @@ class TaigaClientTest { noPagination = ex.requestHeaders.getFirst("x-disable-pagination"), body = ex.requestBody.readBytes().decodeToString(), ) - val bytes = responseBody.encodeToByteArray() + requests += recorded!! + val route = routes[ex.requestURI.path] + val status = route?.first ?: responseStatus + val bytes = (route?.second ?: responseBody).encodeToByteArray() ex.responseHeaders.set("Content-Type", "application/json") - ex.sendResponseHeaders(responseStatus, bytes.size.toLong()) + ex.sendResponseHeaders(status, bytes.size.toLong()) ex.responseBody.use { it.write(bytes) } } server.start() diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 372fb83..75a2de6 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -19,6 +19,11 @@ reverse. ## 2026-08-27 +- The node window shows the state of a ticket now: subject, status and assignee, read from Taiga through the backend proxy +- Taiga's status is shown next to the notation's own box (`In progress → [~]`) — a column name outside the five known ones stays unmapped and is shown as text +- Reading a ticket never changes the plan: no status is written back, and the text stays untouched +- The state is fetched once per ticket and session, and only when the node window stays open for a moment — a ↻ button fetches again +- Without being logged in nothing is fetched: the window offers a "fetch state" button that asks for the login first - Ctrl+click a ticket ref like `#US-123` — in the text or on its node — opens the ticket in Taiga's web UI; the node window carries an open button, which is the way on touch - The backend names the Taiga web address now (`WERKBAUM_TAIGA_WEB_URL`, reported by `/api/v1/info`) — without it, creating tickets still works and opening is simply absent - A ticket ref like `#US-123` in a node title no longer breaks across two lines at its hyphen diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 9aa43e0..2962056 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -8123,3 +8123,90 @@ gestapelten Prüfskript setzte der Sprung aus dem Vor-Schritt die Auswahl asynchron neu und ließ den Tastaturweg scheinbar die falsche URL öffnen — isoliert wiederholt stimmt sie; dieselbe Zustandsvermischung wie beim Nachtrag-4-Bau. + +**Nachtrag 6 — Ticket-Stand im Knoten-Fenster: gelesen, nie geschrieben +(2026-08-27).** `#trk.resolve` gebaut: Wo eine Ref steht und ein +`&taiga.` gilt, zeigt das Fenster Betreff, Status und Zuständigen des +Tickets (SPEC §9). Die Entscheidungen: + +**Zwei benannte Lese-Endpunkte statt eines mit Typ-Parameter** — +`GET /taiga/userstories/{ref}` und `GET /taiga/tasks/{ref}`, je mit `?slug=`. +Das Präfix der Ref **trägt** den Typ (D91-Nachtrag 2), und Taiga hat für die +beiden Typen getrennte `by_ref`-Endpunkte; ein Enum-Parameter hätte dieselbe +Verzweigung nur einen Schritt später gemacht — und passt schlecht zu den +vorhandenen Namen (`/taiga/userstories` POST legt an, GET liest). + +**Zwei Umläufe, nicht einer auf Verdacht.** Eine Ref ist nur **je Projekt** +eindeutig, `by_ref` filtert über die Projekt-**Id**. Der Proxy fragt deshalb +erst `/projects/by_slug`, dann `by_ref`. Der eine gesparte Umlauf über +`?project__slug=` wäre eine Wette auf eine Filter-Eigenheit gewesen, die +niemand hier gemessen hat — die beiden genommenen Endpunkte sind +dokumentiert. **Der Slug wird kodiert** in die Anfrage gesetzt: Er kommt vom +Client, und ein `&` darin hängte sonst einen weiteren Filter an (die kleine +Schwester der SSRF-Falle, wegen der die Basis-URL Server-Konfiguration ist). + +**Die Abbildung Taiga-Status → Statusbox liegt im FRONTEND**, obwohl +`backend/CLAUDE.md` sie unter „Taiga-Mapping" führt: Die Statuscodes sind +Notations-Vokabular (SPEC §4), und das Backend parst die Notation nicht +(D14). Der Proxy reicht den **Namen** durch (`status_extra_info.name`), der +Editor bildet ab — headless in `taiga.js`, damit die Regel eine Zusicherung +hat. Vorgabe wie dort notiert: „New" `[ ]`, „In progress" `[~]`, „Ready for +test" `[/]`, „Done" `[x]`, „Archived" `[^]`; Groß-/Kleinschreibung und +Leerraum egal. **Unbekannte Namen bleiben unabgebildet** und stehen nur als +Text — Taiga-Workflows sind je Projekt frei benannt, und Raten wäre hier +besonders teuer: Der Knoten bekäme eine Statusaussage, die niemand gemacht +hat. Die in der Roadmap versprochene **Konfigurierbarkeit** ist damit +ausdrücklich noch offen; der Plan-Knoten `#trk.resolve.map` steht deshalb auf +`[/]`, nicht auf `[x]`. + +**Gezeigt wird beides: Taigas Name UND die Box** (`In progress → [~]`). Die +Abbildung ist die Aussage — nur die Box zu zeigen verlöre, woher sie kommt, +nur den Namen zu zeigen verlöre den Bezug zur Notation. + +**Gelesen, nie geschrieben.** Kein Zeichen wandert in den Text, keine +Statusbox ändert sich. Das Zurückschreiben ist ein eigener Knoten +(`#trk.write`) und braucht eigene Entscheidungen (wer gewinnt bei +Abweichung?). Wo Ticket und Knoten auseinanderlaufen, sieht man es jetzt — +die Frage stellt das Fenster, beantworten muss sie ein Mensch. + +**Zwei Sparsamkeiten gegenüber der fremden Instanz.** Das Fenster öffnet beim +**Überfahren** (D57) und beim Tabben — ein Abruf je gestreiftem Knoten wäre +unhöflich. Also: geholt wird erst, wenn es **400 ms** stehen bleibt, und je +Ticket **einmal je Sitzung** (Cache); ein ↻-Knopf holt neu. Ohne Anmeldung +wird **gar nicht** automatisch geholt — dort steht der Knopf „Stand holen", +und der meldet bei Bedarf an: Ein Klick ist die ausdrückliche Absicht, ein +Zeiger über einem Knoten nicht. + +**Der Anmelde-Dialog darf das Fenster nicht zumachen.** Er gehört zu einer +Aktion **aus** dem Fenster; schlösse der `pointerdown`-Wächter es (D52), fiele +die Antwort ins Leere und man müsste den Knoten erneut aufsuchen. Ausgenommen +ist deshalb `.tabmodal-overlay` — die Anlage-Aktion (D91-Nachtrag 4) schließt +das Fenster weiterhin selbst, dort ist es gewollt. + +**Ohne Slug geschieht still nichts**, wie beim Öffnen (D91-Nachtrag 5) und +beim Abhängigkeits-Sprung (D67); ein Fehler dagegen steht als Zeile im +Fenster — ein Abruf, den jemand angefordert hat, darf nicht stumm scheitern. +Nicht im Grafikexport und nicht im Druck: Das Fenster ist Bedienhilfe. + +**Nachgemessen** Ende-zu-Ende im Browser gegen das lokal laufende Backend mit +Taiga-Stub: Ohne Sitzung steht „Stand holen" und **kein** Abruf geht hinaus; +der Knopf meldet an (Dialog, Fenster bleibt offen) und zeigt danach +`In progress → [~]` mit Taigas eigenem Betreff und „Zuständig: Anna +Beispiel"; die Task zeigt `Ready for test → [/]`; ein Knoten ohne Ref zeigt +unverändert die Anlage-Knöpfe, eine Ref **ohne** Projekt-Zuordnung gar +nichts. Im Mitschnitt des Stubs: genau **zwei** Anfragen je Ticket +(`by_slug` + `by_ref`), **keine** beim kurzen Streifen eines Knotens, +**keine** beim zweiten Ansehen (Cache), und genau eine neue Runde auf ↻. +Die Farben der Box stammen aus §4 (gemessen: `#FADDE4`/`#D897A8` für +`arbeit`). Backend: 5 neue Client-Tests (zweistufiger Weg, eigener +Task-Endpunkt, fehlendes `extra_info` → leer statt geraten, 404 ohne zweiten +Umlauf, kodierter Slug) und 2 Ende-zu-Ende-Tests; Frontend 585 Tests +(9 neue). Gegenproben: Kodierung entfernt → genau die eine danach benannte +Zusicherung fällt, Normalisierung des Statusnamens entfernt → genau die drei. + +**Werkzeuggrenze, benannt:** Die Browser-Fläche wurde in dieser Sitzung nicht +dargestellt (keine Frames, keine Screenshots, `getBoundingClientRect()` +durchweg 0) — geprüft ist deshalb über Ereignisse und **berechnete** Stile, +nicht am Bild. Dieselbe Sorte Grenze wie D57 (`focus()` ohne Fensterfokus +feuert keine Fokus-Ereignisse): Der Tastaturweg musste synthetisch angestoßen +werden. diff --git a/docs/SPEC.md b/docs/SPEC.md index a684a5c..1e93180 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -489,6 +489,30 @@ durch eine echte Linie abgesetzt. Siehe D57. Siehe D52. +**Ticket-Stand im Knoten-Fenster (§11).** Trägt die Zeile eine +Ticket-Referenz (`#US-123` / `#T-1234`) und ist ihr Teilbaum per +`&taiga.` (§1) einem Projekt zugeordnet, holt das Fenster den Stand des +Tickets und zeigt ihn über dem Öffnen-Knopf: **Betreff**, **Status** und, wenn +es einen gibt, den **Zuständigen**. + +- Der Taiga-Status wird auf die Statusbox der Notation (§4) abgebildet und + **neben** seinem eigenen Namen gezeigt (`In progress → [~]`): „New“ `[ ]`, + „In progress“ `[~]`, „Ready for test“ `[/]`, „Done“ `[x]`, „Archived“ `[^]`; + Groß-/Kleinschreibung und Leerraum sind egal. Ein Name außerhalb dieser + Liste bleibt **unabgebildet** und steht nur als Text — geraten wird nicht. +- **Gelesen, nie geschrieben.** Der Notationstext bleibt unangetastet; die + Statusbox des Knotens ändert sich nicht, und die Abbildung sagt nichts über + Fortschritt (§4) oder Kosten (§5). Das Zurückschreiben ist reserviert (§11). +- Geholt wird erst, wenn das Fenster **kurz stehen bleibt** (nicht im + Vorüberfahren), und je Ticket **einmal je Sitzung** — ein ↻-Knopf im Fenster + holt neu. Ohne Anmeldung an der Instanz, ohne Projekt-Zuordnung oder ohne + konfiguriertes Backend geschieht still nichts; ein Fehler steht als Zeile im + Fenster. +- Reine Bedienhilfe wie das Fenster selbst: nicht im Grafikexport, nicht im + Druck. + +Siehe D91-Nachtrag 6. + **Die Knotenfarbe zeigt den effektiven Status (§4)**, nicht den intrinsischen — das Diagramm beantwortet „wie weit ist das wirklich?“. Wo der eigene Status **weiter** ist als der effektive (der Knoten wird von Abhängigkeiten @@ -1360,6 +1384,9 @@ wird sie von selbst die ID. Erstreckt sich ein Plan über **mehrere** Taiga-Projekte, benennt das Schlagwort `&taiga.` (unten) das Projekt je Teilbaum — eine Ref wird gegen das Projekt des nächsten Vorfahren mit so einem Tag aufgelöst. Siehe D91-Nachträge 2 und 3. +Der **Stand** eines so bezeichneten Tickets (Betreff, Status, Zuständiger) +wird im Knoten-Fenster gezeigt (§9) — gelesen, nie geschrieben; das +**Zurückschreiben** des Status bleibt reserviert (unten). Freie Schlagworte liegen **nicht** mehr auf `#` — siehe `&tag` unten; damit ist die frühere Dreifach-Rolle von `#` aufgelöst (D34). diff --git a/docs/examples/werkbaum.werkbaum b/docs/examples/werkbaum.werkbaum index 1f54195..9f34c40 100644 --- a/docs/examples/werkbaum.werkbaum +++ b/docs/examples/werkbaum.werkbaum @@ -201,9 +201,9 @@ | [?] #idea.drift.js: Run the one JS parser inside the IDE (M) - [~] #trk: Tracker integration (XL) %% exactly one of these, hence = = [~] #trk.taiga: Taiga (XL) https://taiga.io - - [ ] #trk.resolve: Resolve "#US-123" over the REST API (M) - - [ ] #trk.resolve.read: Read title, link and status (S) - - [ ] #trk.resolve.map: Map the workflow onto the states (S) + - [/] #trk.resolve: Resolve "#US-123" over the REST API (M) + - [x] #trk.resolve.read: Read title, link and status (S) + - [/] #trk.resolve.map: Map the workflow onto the states (S) - [?] #trk.write: Write the status back (M) :#trk.resolve - [^] #trk.create: Create tickets from nodes (XL) - [^] #trk.create.proxy: Backend proxy with named endpoints (M) @@ -1149,11 +1149,13 @@ state. Read-only, and already useful on its own. #trk.resolve.read - Fetching the ticket and showing what it says. + Fetching the ticket and showing what it says: subject, status and assignee + in the node window, read through the backend proxy. #trk.resolve.map Translating the tracker's workflow onto the eight states of the notation. - Configurable, because no two projects use the same column names. + The default five are mapped, an unknown column name stays unmapped and is + shown as plain text; making the mapping configurable is still open. #trk.write The other direction: change a status in the plan and the ticket follows. diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index bb39690..5ca60fd 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -667,6 +667,20 @@ verworfene Elemente. Quelle sind ES-Module unter `src/`; `index.html` ist der `werkbaum-taiga` im localStorage (nur Token, nie ein Passwort); ein 401 löscht es und fragt neu. Fehler erscheinen IM Dialog, nicht als `window.alert` (in manchen Kontexten unterdrückt, D22-Lehre). +- **Ticket-Stand lesen (D91-Nachtrag 6):** `ticketApiPath()` baut den + Proxy-Pfad (getrennte by_ref-Endpunkte je Typ, Slug kodiert), + `mapTaigaStatus()` bildet Taigas Statusnamen auf `STATUS_BY_CODE` ab — + beides headless in `taiga.js`, denn die Statuscodes sind Notation und das + Backend parst sie nicht (D14); ein unbekannter Name bleibt **null**. + In app.js: `ticketBox()`/`paintTicket()`/`loadTicket()` samt Cache + `taigaTickets` (je Ticket EIN Abruf je Sitzung, ↻ holt neu) und + `TICKET_DELAY` (400 ms Verweilen, bevor überhaupt gefragt wird — das + Fenster öffnet beim Überfahren und beim Tabben). **Ohne Sitzung wird nichts + automatisch geholt**, der Knopf meldet erst an. `tipTicket`/`ticketTimer` + stehen oben bei `tipNode`, weil `closeNodeTip()` sie mit wegräumt (das + läuft schon beim Aufbau — sonst temporale Todeszone). Der + `pointerdown`-Wächter lässt `.tabmodal-overlay` durch: Der Anmelde-Dialog + gehört zu einer Aktion AUS dem Fenster und darf es nicht zumachen. - **Nie `src.value = …` während des Bearbeitens (D53).** Es löscht die Undo-Historie des Textfelds **komplett** — nicht nur den eigenen Schritt, sondern alles davor Getippte. Gemessen: nach so einem Schreiben ändert das diff --git a/frontend/src/app.js b/frontend/src/app.js index f367b62..71220b1 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,7 +1,7 @@ import './style.css'; import { parse, setFoldMark, expandShortIds, shortIdClosed } from './parser.js'; import { computeCheapPlan, overloadedAssignee, assigneeLoads, freshProdSet, initialCollapsed, nodeKeys, effectiveStatus, presetFoldSet, personFoldSet, allTags, lineTargets, taigaSlugs } from './model.js'; -import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt } from './taiga.js'; +import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, ticketApiPath, mapTaigaStatus } from './taiga.js'; import { esc, renderTreeHtml, TIP_RULE } from './render.js'; import { formatWarning, warningText } from './warnings.js'; import * as live from './live.js'; @@ -1585,11 +1585,16 @@ out.addEventListener('contextmenu', e => { if(pressTimer || armedEl) e.preventDe const nodeTip = document.getElementById('nodeTip'); const nodeTipBody = document.getElementById('nodeTipBody'); let tipNode = null; +/* Der Ticket-Stand darin (D91-Nachtrag 6) steht hier oben bei `tipNode`: + `closeNodeTip()` räumt ihn mit weg, und das läuft schon beim Aufbau. */ +let tipTicket = null, ticketTimer = null; function closeNodeTip(){ if(!tipNode) return; tipNode.classList.remove('tipped'); tipNode = null; + tipTicket = null; /* nichts mehr zu bemalen (D91-Nachtrag 6) */ + clearTimeout(ticketTimer); /* ein noch nicht gestarteter Abruf entfällt */ nodeTip.hidden = true; } @@ -1701,6 +1706,10 @@ document.getElementById('nodeTipClose').addEventListener('click', closeNodeTip); document.addEventListener('pointerdown', e => { if(!tipNode || nodeTip.contains(e.target)) return; if(e.target && e.target.closest && e.target.closest('.node') === tipNode) return; + /* Ein modaler Dialog gehört zu einer Aktion AUS dem Fenster (Anmelden für + den Ticket-Stand, D91-Nachtrag 6) — er darf es nicht zumachen, sonst + fiele die Antwort ins Leere. Er liegt ohnehin darüber (z-index). */ + if(e.target && e.target.closest && e.target.closest('.tabmodal-overlay')) return; closeNodeTip(); }, true); document.addEventListener('keydown', e => { if(e.key === 'Escape'){ closeNodeTip(); closeNewsMenu(); } }); @@ -1808,17 +1817,23 @@ function appendTaigaActions(el){ const ref = ticketRefOf(node); if(ref){ /* Der Knoten IST schon angelegt (Idempotenz-Marker) — statt der - Anlage-Knöpfe gibt es den Weg zum Ticket. Als Knopf im Fenster ist er - auch auf Touch erreichbar (dort gibt es kein Strg) und macht die - Strg+Klick-Geste nebenbei auffindbar (D25-Lehre). */ - const url = ticketUrl(taigaWeb, taigaSlugs(roots).get(node), ref); - if(!url) return; - const b = document.createElement('button'); - b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1; - b.textContent = '↗ ' + t('taigaOpenBtn', {ref: '#' + ref}); - b.addEventListener('click', () => { closeNodeTip(); window.open(url, '_blank', 'noopener'); }); - wrap.appendChild(b); - nodeTipBody.appendChild(wrap); + Anlage-Knöpfe gibt es den Stand des Tickets und den Weg dorthin. Der + Slug (SPEC §1) trägt das Lesen, die Web-Basis das Öffnen: Fehlt eines, + entfällt nur dessen Hälfte. */ + const slug = taigaSlugs(roots).get(node); + const url = ticketUrl(taigaWeb, slug, ref); + if(slug) nodeTipBody.appendChild(ticketBox(el, slug, ref)); + /* Als Knopf im Fenster ist der Link auch auf Touch erreichbar (dort gibt + es kein Strg) und macht die Strg+Klick-Geste nebenbei auffindbar + (D25-Lehre). */ + if(url){ + const b = document.createElement('button'); + b.type = 'button'; b.className = 'nodetip-taigabtn'; b.tabIndex = -1; + b.textContent = '↗ ' + t('taigaOpenBtn', {ref: '#' + ref}); + b.addEventListener('click', () => { closeNodeTip(); window.open(url, '_blank', 'noopener'); }); + wrap.appendChild(b); + nodeTipBody.appendChild(wrap); + } return; } const mk = (label, mitTasks) => { @@ -1833,6 +1848,115 @@ function appendTaigaActions(el){ nodeTipBody.appendChild(wrap); } +/* ---------- Ticket-Stand im Knoten-Fenster (D91-Nachtrag 6) ---------- + Trägt die Zeile eine Ref und ist der Teilbaum per `&taiga.` zugeordnet, + zeigt das Fenster Betreff, Status und Zuständigen des Tickets. Gelesen, nie + geschrieben: Der Notationstext bleibt unangetastet, die Abbildung des + Taiga-Status auf die Statusbox (SPEC §4) ist reine Anzeige. + + Zwei Sparsamkeiten, beide der fremden Instanz zuliebe: Geholt wird je Ticket + EINMAL je Sitzung (Cache; der ↻-Knopf holt neu), und erst, wenn das Fenster + kurz stehen bleibt — es öffnet schon beim Überfahren (D57) und beim Tabben, + ein Abruf je gestreiftem Knoten wäre unhöflich. */ +const taigaTickets = new Map(); /* '/' -> {kind:'load'|'ok'|'err', data, msg} */ +const TICKET_DELAY = 400; /* `tipTicket`/`ticketTimer`: oben bei `tipNode` */ + +function ticketBox(el, slug, ref){ + const box = document.createElement('div'); + box.className = 'nodetip-ticket'; + const key = slug + '/' + ref; + tipTicket = {key, slug, ref, box}; + paintTicket(box, key, slug, ref); + clearTimeout(ticketTimer); + if(!taigaTickets.has(key) && taigaSession()){ + ticketTimer = setTimeout(() => { + if(tipNode === el) loadTicket(key, slug, ref, false); + }, TICKET_DELAY); + } + return box; +} + +function paintTicket(box, key, slug, ref){ + const st = taigaTickets.get(key) || {kind: 'idle'}; + box.textContent = ''; + const line = (cls, text) => { + const d = document.createElement('div'); + d.className = cls; + d.textContent = text; + box.appendChild(d); + return d; + }; + if(st.kind === 'load'){ line('tk-line', t('taigaTicketLoading')); return; } + if(st.kind === 'ok'){ + const head = document.createElement('div'); + head.className = 'tk-head'; + if(st.data.status){ + const s = document.createElement('span'); + s.className = 'tk-status'; + s.textContent = st.data.status; + head.appendChild(s); + /* Taigas Name UND die Statusbox der Notation — die Abbildung ist die + Aussage, deshalb stehen beide da (SPEC §9). Ein unbekannter Name + bleibt für sich stehen. */ + const m = mapTaigaStatus(st.data.status); + if(m){ + const a = document.createElement('span'); + a.className = 'tk-arrow'; + a.textContent = '→'; + const c = document.createElement('span'); + c.className = 'chip st-' + m.key; + c.textContent = '[' + m.code + ']'; + head.append(a, c); + } + } + head.appendChild(reloadBtn(key, slug, ref, true)); + box.appendChild(head); + if(st.data.subject) line('tk-line', st.data.subject); + if(st.data.assignee) line('tk-line', t('taigaTicketAssignee', {name: st.data.assignee})); + return; + } + if(st.kind === 'err') line('tk-err', st.msg); + box.appendChild(reloadBtn(key, slug, ref, st.kind === 'err')); +} + +/* Der Knopf holt den Stand — und meldet dafür bei Bedarf an (`interactive`): + Ein Klick ist die ausdrückliche Absicht, das Überfahren eines Knotens nicht. */ +function reloadBtn(key, slug, ref, kurz){ + const b = document.createElement('button'); + b.type = 'button'; b.tabIndex = -1; + b.className = 'nodetip-taigabtn' + (kurz ? ' tk-mini' : ''); + b.textContent = kurz ? '↻' : t('taigaTicketFetch'); + if(kurz) b.title = t('taigaTicketReload'); + b.addEventListener('click', () => loadTicket(key, slug, ref, true)); + return b; +} + +async function loadTicket(key, slug, ref, interactive){ + const session = interactive ? await taigaEnsureSession() : taigaSession(); + if(!session || !session.token) return; /* nicht angemeldet: der Knopf bleibt stehen */ + taigaTickets.set(key, {kind: 'load'}); + repaintTicket(key); + try{ + const d = await taigaFetch(ticketApiPath(ref, slug), null, session.token); + taigaTickets.set(key, {kind: 'ok', data: d}); + }catch(err){ + /* Abgelaufenes Token: wegräumen, damit der nächste Knopfdruck die + Anmeldung anbietet (wie bei der Projektliste). */ + if(err && err.status === 401) storeTaigaSession(null); + taigaTickets.set(key, {kind: 'err', msg: taigaErrText(err)}); + } + repaintTicket(key); +} + +/* Gemalt wird nur, wenn genau dieses Ticket noch im offenen Fenster steht — + die Antwort kann kommen, wenn längst ein anderer Knoten dran ist. Das + Fenster wächst dabei, also neu setzen. */ +function repaintTicket(key){ + if(!tipTicket || tipTicket.key !== key || !tipTicket.box.isConnected) return; + paintTicket(tipTicket.box, key, tipTicket.slug, tipTicket.ref); + if(tipNode) placeNodeTip(tipNode); +} + /* Strg+Klick (macOS auch Cmd) auf einen Knoten mit Ticket-Referenz öffnet das Ticket im Taiga-Frontend (D91-Nachtrag 5) — dieselbe Geste wie im Text. Der einfache Klick bleibt der Link (§6), Alt der Sprung (D25); ohne @@ -2768,7 +2892,7 @@ const I18N = { acHint:"{n} ID-Vorschläge – ↑/↓ wählt, Enter übernimmt", tipClose:"Schließen", tipOpenLink:"Link öffnen", - taigaStoryBtn:"Taiga: Story anlegen", taigaTasksBtn:"Taiga: Story + Tasks anlegen", taigaLoginTitle:"Bei Taiga anmelden", taigaUser:"Benutzername", taigaPass:"Passwort", taigaLoginDo:"Anmelden", taigaCancel:"Abbrechen", taigaProjectTitle:"Taiga-Ticket anlegen", taigaProject:"Projekt", taigaTasksPick:"Teilpakete als Tasks anlegen:", taigaCreateDo:"Anlegen", taigaError:"Fehlgeschlagen: {error}", taigaOpenBtn:"{ref} in Taiga öffnen", + taigaStoryBtn:"Taiga: Story anlegen", taigaTasksBtn:"Taiga: Story + Tasks anlegen", taigaLoginTitle:"Bei Taiga anmelden", taigaUser:"Benutzername", taigaPass:"Passwort", taigaLoginDo:"Anmelden", taigaCancel:"Abbrechen", taigaProjectTitle:"Taiga-Ticket anlegen", taigaProject:"Projekt", taigaTasksPick:"Teilpakete als Tasks anlegen:", taigaCreateDo:"Anlegen", taigaError:"Fehlgeschlagen: {error}", taigaOpenBtn:"{ref} in Taiga öffnen", taigaTicketFetch:"Stand holen", taigaTicketReload:"Stand neu holen", taigaTicketLoading:"Stand wird geholt …", taigaTicketAssignee:"Zuständig: {name}", liveLoadWarn:"Server-Dokument nicht geladen: {url} ({error}). Läuft das Backend, und ist die Adresse eine Dokument-Adresse (…/documents/<uuid>)?", liveStaleWarn:"Deine Änderung war nicht mehr anwendbar ({error}) — der Stand wurde einmal frisch geholt.", liveConflictText:"Jemand hat dieselben Zeilen geändert. Wessen Fassung soll gelten?", @@ -2905,7 +3029,7 @@ const I18N = { acHint:"{n} id suggestions – ↑/↓ to choose, Enter to insert", tipClose:"Close", tipOpenLink:"Open link", - taigaStoryBtn:"Taiga: create story", taigaTasksBtn:"Taiga: create story + tasks", taigaLoginTitle:"Log in to Taiga", taigaUser:"Username", taigaPass:"Password", taigaLoginDo:"Log in", taigaCancel:"Cancel", taigaProjectTitle:"Create Taiga ticket", taigaProject:"Project", taigaTasksPick:"Create sub-packages as tasks:", taigaCreateDo:"Create", taigaError:"Failed: {error}", taigaOpenBtn:"Open {ref} in Taiga", + taigaStoryBtn:"Taiga: create story", taigaTasksBtn:"Taiga: create story + tasks", taigaLoginTitle:"Log in to Taiga", taigaUser:"Username", taigaPass:"Password", taigaLoginDo:"Log in", taigaCancel:"Cancel", taigaProjectTitle:"Create Taiga ticket", taigaProject:"Project", taigaTasksPick:"Create sub-packages as tasks:", taigaCreateDo:"Create", taigaError:"Failed: {error}", taigaOpenBtn:"Open {ref} in Taiga", taigaTicketFetch:"Fetch state", taigaTicketReload:"Fetch again", taigaTicketLoading:"Fetching state …", taigaTicketAssignee:"Assigned to: {name}", liveLoadWarn:"Server document not loaded: {url} ({error}). Is the backend running, and is the address a document address (…/documents/<uuid>)?", liveStaleWarn:"Your change no longer applied ({error}) — the document was fetched afresh once.", liveConflictText:"Someone changed the same lines. Whose version should win?", @@ -3041,7 +3165,7 @@ const I18N = { acHint:"{n} sugerencias de ID – ↑/↓ elige, Intro inserta", tipClose:"Cerrar", tipOpenLink:"Abrir enlace", - taigaStoryBtn:"Taiga: crear historia", taigaTasksBtn:"Taiga: crear historia + tareas", taigaLoginTitle:"Iniciar sesión en Taiga", taigaUser:"Usuario", taigaPass:"Contraseña", taigaLoginDo:"Iniciar sesión", taigaCancel:"Cancelar", taigaProjectTitle:"Crear ticket de Taiga", taigaProject:"Proyecto", taigaTasksPick:"Crear subpaquetes como tareas:", taigaCreateDo:"Crear", taigaError:"Error: {error}", taigaOpenBtn:"Abrir {ref} en Taiga", + taigaStoryBtn:"Taiga: crear historia", taigaTasksBtn:"Taiga: crear historia + tareas", taigaLoginTitle:"Iniciar sesión en Taiga", taigaUser:"Usuario", taigaPass:"Contraseña", taigaLoginDo:"Iniciar sesión", taigaCancel:"Cancelar", taigaProjectTitle:"Crear ticket de Taiga", taigaProject:"Proyecto", taigaTasksPick:"Crear subpaquetes como tareas:", taigaCreateDo:"Crear", taigaError:"Error: {error}", taigaOpenBtn:"Abrir {ref} en Taiga", taigaTicketFetch:"Consultar estado", taigaTicketReload:"Volver a consultar", taigaTicketLoading:"Consultando estado …", taigaTicketAssignee:"Asignado a: {name}", liveLoadWarn:"Documento del servidor no cargado: {url} ({error}). ¿Está el backend en marcha y es la dirección la de un documento (…/documents/<uuid>)?", liveStaleWarn:"Tu cambio ya no era aplicable ({error}): se volvió a cargar el estado una vez.", liveConflictText:"Alguien cambió las mismas líneas. ¿Qué versión debe prevalecer?", @@ -3177,7 +3301,7 @@ const I18N = { acHint:"{n} suggestions d'ID – ↑/↓ pour choisir, Entrée pour insérer", tipClose:"Fermer", tipOpenLink:"Ouvrir le lien", - taigaStoryBtn:"Taiga : créer la story", taigaTasksBtn:"Taiga : créer story + tâches", taigaLoginTitle:"Se connecter à Taiga", taigaUser:"Nom d'utilisateur", taigaPass:"Mot de passe", taigaLoginDo:"Se connecter", taigaCancel:"Annuler", taigaProjectTitle:"Créer un ticket Taiga", taigaProject:"Projet", taigaTasksPick:"Créer les sous-lots comme tâches :", taigaCreateDo:"Créer", taigaError:"Échec : {error}", taigaOpenBtn:"Ouvrir {ref} dans Taiga", + taigaStoryBtn:"Taiga : créer la story", taigaTasksBtn:"Taiga : créer story + tâches", taigaLoginTitle:"Se connecter à Taiga", taigaUser:"Nom d'utilisateur", taigaPass:"Mot de passe", taigaLoginDo:"Se connecter", taigaCancel:"Annuler", taigaProjectTitle:"Créer un ticket Taiga", taigaProject:"Projet", taigaTasksPick:"Créer les sous-lots comme tâches :", taigaCreateDo:"Créer", taigaError:"Échec : {error}", taigaOpenBtn:"Ouvrir {ref} dans Taiga", taigaTicketFetch:"Relever l'état", taigaTicketReload:"Relever à nouveau", taigaTicketLoading:"Relevé de l'état …", taigaTicketAssignee:"Assigné à : {name}", liveLoadWarn:"Document du serveur non chargé : {url} ({error}). Le backend tourne-t-il, et l'adresse est-elle celle d'un document (…/documents/<uuid>) ?", liveStaleWarn:"Ta modification n'était plus applicable ({error}) — l'état a été rechargé une fois.", liveConflictText:"Quelqu'un a modifié les mêmes lignes. Quelle version doit l'emporter ?", @@ -3313,7 +3437,7 @@ const I18N = { acHint:"{n} podpowiedzi ID – ↑/↓ wybiera, Enter wstawia", tipClose:"Zamknij", tipOpenLink:"Otwórz link", - taigaStoryBtn:"Taiga: utwórz historyjkę", taigaTasksBtn:"Taiga: historyjka + zadania", taigaLoginTitle:"Zaloguj się do Taigi", taigaUser:"Nazwa użytkownika", taigaPass:"Hasło", taigaLoginDo:"Zaloguj", taigaCancel:"Anuluj", taigaProjectTitle:"Utwórz zgłoszenie w Taidze", taigaProject:"Projekt", taigaTasksPick:"Utwórz podpakiety jako zadania:", taigaCreateDo:"Utwórz", taigaError:"Niepowodzenie: {error}", taigaOpenBtn:"Otwórz {ref} w Taidze", + taigaStoryBtn:"Taiga: utwórz historyjkę", taigaTasksBtn:"Taiga: historyjka + zadania", taigaLoginTitle:"Zaloguj się do Taigi", taigaUser:"Nazwa użytkownika", taigaPass:"Hasło", taigaLoginDo:"Zaloguj", taigaCancel:"Anuluj", taigaProjectTitle:"Utwórz zgłoszenie w Taidze", taigaProject:"Projekt", taigaTasksPick:"Utwórz podpakiety jako zadania:", taigaCreateDo:"Utwórz", taigaError:"Niepowodzenie: {error}", taigaOpenBtn:"Otwórz {ref} w Taidze", taigaTicketFetch:"Pobierz stan", taigaTicketReload:"Pobierz ponownie", taigaTicketLoading:"Pobieranie stanu …", taigaTicketAssignee:"Przypisane do: {name}", liveLoadWarn:"Nie wczytano dokumentu z serwera: {url} ({error}). Czy backend działa i czy adres wskazuje dokument (…/documents/<uuid>)?", liveStaleWarn:"Twoja zmiana nie dała się już zastosować ({error}) — stan pobrano raz od nowa.", liveConflictText:"Ktoś zmienił te same wiersze. Która wersja ma obowiązywać?", @@ -3449,7 +3573,7 @@ const I18N = { acHint:"{n} подсказок ID – ↑/↓ выбирает, Enter вставляет", tipClose:"Закрыть", tipOpenLink:"Открыть ссылку", - taigaStoryBtn:"Taiga: создать историю", taigaTasksBtn:"Taiga: история + задачи", taigaLoginTitle:"Вход в Taiga", taigaUser:"Имя пользователя", taigaPass:"Пароль", taigaLoginDo:"Войти", taigaCancel:"Отмена", taigaProjectTitle:"Создать тикет в Taiga", taigaProject:"Проект", taigaTasksPick:"Создать подпакеты как задачи:", taigaCreateDo:"Создать", taigaError:"Не удалось: {error}", taigaOpenBtn:"Открыть {ref} в Taiga", + taigaStoryBtn:"Taiga: создать историю", taigaTasksBtn:"Taiga: история + задачи", taigaLoginTitle:"Вход в Taiga", taigaUser:"Имя пользователя", taigaPass:"Пароль", taigaLoginDo:"Войти", taigaCancel:"Отмена", taigaProjectTitle:"Создать тикет в Taiga", taigaProject:"Проект", taigaTasksPick:"Создать подпакеты как задачи:", taigaCreateDo:"Создать", taigaError:"Не удалось: {error}", taigaOpenBtn:"Открыть {ref} в Taiga", taigaTicketFetch:"Получить статус", taigaTicketReload:"Обновить статус", taigaTicketLoading:"Получение статуса …", taigaTicketAssignee:"Назначено: {name}", liveLoadWarn:"Документ с сервера не загружен: {url} ({error}). Запущен ли бэкенд и является ли адрес адресом документа (…/documents/<uuid>)?", liveStaleWarn:"Ваше изменение больше не применялось ({error}) — состояние загружено заново.", liveConflictText:"Кто-то изменил те же строки. Чья версия должна остаться?", @@ -3585,7 +3709,7 @@ const I18N = { acHint:"{n} आईडी सुझाव – ↑/↓ से चुनें, Enter से डालें", tipClose:"बंद करें", tipOpenLink:"लिंक खोलें", - taigaStoryBtn:"Taiga: स्टोरी बनाएँ", taigaTasksBtn:"Taiga: स्टोरी + टास्क बनाएँ", taigaLoginTitle:"Taiga में साइन इन करें", taigaUser:"उपयोगकर्ता नाम", taigaPass:"पासवर्ड", taigaLoginDo:"साइन इन", taigaCancel:"रद्द करें", taigaProjectTitle:"Taiga टिकट बनाएँ", taigaProject:"प्रोजेक्ट", taigaTasksPick:"उप-पैकेज टास्क के रूप में बनाएँ:", taigaCreateDo:"बनाएँ", taigaError:"विफल: {error}", taigaOpenBtn:"Taiga में {ref} खोलें", + taigaStoryBtn:"Taiga: स्टोरी बनाएँ", taigaTasksBtn:"Taiga: स्टोरी + टास्क बनाएँ", taigaLoginTitle:"Taiga में साइन इन करें", taigaUser:"उपयोगकर्ता नाम", taigaPass:"पासवर्ड", taigaLoginDo:"साइन इन", taigaCancel:"रद्द करें", taigaProjectTitle:"Taiga टिकट बनाएँ", taigaProject:"प्रोजेक्ट", taigaTasksPick:"उप-पैकेज टास्क के रूप में बनाएँ:", taigaCreateDo:"बनाएँ", taigaError:"विफल: {error}", taigaOpenBtn:"Taiga में {ref} खोलें", taigaTicketFetch:"स्थिति लाएँ", taigaTicketReload:"फिर से लाएँ", taigaTicketLoading:"स्थिति लाई जा रही है …", taigaTicketAssignee:"ज़िम्मेदार: {name}", liveLoadWarn:"सर्वर दस्तावेज़ लोड नहीं हुआ: {url} ({error})। क्या बैकएंड चल रहा है और क्या पता दस्तावेज़ का पता है (…/documents/<uuid>)?", liveStaleWarn:"आपका बदलाव अब लागू नहीं हो सका ({error}) — स्थिति एक बार नए सिरे से ली गई।", liveConflictText:"किसी और ने वही पंक्तियाँ बदली हैं। किसका संस्करण रहे?", @@ -3732,7 +3856,7 @@ const I18N = { acHint:"{n} 个 ID 建议 – ↑/↓ 选择,Enter 插入", tipClose:"关闭", tipOpenLink:"打开链接", - taigaStoryBtn:"Taiga:创建故事", taigaTasksBtn:"Taiga:创建故事和任务", taigaLoginTitle:"登录 Taiga", taigaUser:"用户名", taigaPass:"密码", taigaLoginDo:"登录", taigaCancel:"取消", taigaProjectTitle:"创建 Taiga 工单", taigaProject:"项目", taigaTasksPick:"将子包创建为任务:", taigaCreateDo:"创建", taigaError:"失败:{error}", taigaOpenBtn:"在 Taiga 中打开 {ref}", + taigaStoryBtn:"Taiga:创建故事", taigaTasksBtn:"Taiga:创建故事和任务", taigaLoginTitle:"登录 Taiga", taigaUser:"用户名", taigaPass:"密码", taigaLoginDo:"登录", taigaCancel:"取消", taigaProjectTitle:"创建 Taiga 工单", taigaProject:"项目", taigaTasksPick:"将子包创建为任务:", taigaCreateDo:"创建", taigaError:"失败:{error}", taigaOpenBtn:"在 Taiga 中打开 {ref}", taigaTicketFetch:"获取状态", taigaTicketReload:"重新获取", taigaTicketLoading:"正在获取状态 …", taigaTicketAssignee:"负责人:{name}", liveLoadWarn:"未能加载服务器文档:{url}({error})。后端在运行吗?该地址是文档地址(…/documents/<uuid>)吗?", liveStaleWarn:"你的更改已无法应用({error})——已重新获取一次当前状态。", liveConflictText:"有人改动了同样的行。以谁的版本为准?", @@ -3868,7 +3992,7 @@ const I18N = { acHint:"ID候補 {n} 件 – ↑/↓で選択、Enterで挿入", tipClose:"閉じる", tipOpenLink:"リンクを開く", - taigaStoryBtn:"Taiga: ストーリーを作成", taigaTasksBtn:"Taiga: ストーリー+タスクを作成", taigaLoginTitle:"Taiga にログイン", taigaUser:"ユーザー名", taigaPass:"パスワード", taigaLoginDo:"ログイン", taigaCancel:"キャンセル", taigaProjectTitle:"Taiga チケットを作成", taigaProject:"プロジェクト", taigaTasksPick:"サブパッケージをタスクとして作成:", taigaCreateDo:"作成", taigaError:"失敗: {error}", taigaOpenBtn:"Taiga で {ref} を開く", + taigaStoryBtn:"Taiga: ストーリーを作成", taigaTasksBtn:"Taiga: ストーリー+タスクを作成", taigaLoginTitle:"Taiga にログイン", taigaUser:"ユーザー名", taigaPass:"パスワード", taigaLoginDo:"ログイン", taigaCancel:"キャンセル", taigaProjectTitle:"Taiga チケットを作成", taigaProject:"プロジェクト", taigaTasksPick:"サブパッケージをタスクとして作成:", taigaCreateDo:"作成", taigaError:"失敗: {error}", taigaOpenBtn:"Taiga で {ref} を開く", taigaTicketFetch:"状態を取得", taigaTicketReload:"再取得", taigaTicketLoading:"状態を取得中 …", taigaTicketAssignee:"担当: {name}", liveLoadWarn:"サーバー文書を読み込めませんでした: {url}({error})。バックエンドは動いていますか。アドレスは文書のアドレス(…/documents/<uuid>)ですか。", liveStaleWarn:"あなたの変更はもう適用できませんでした({error})。状態を一度取り直しました。", liveConflictText:"同じ行が他の人にも変更されました。どちらの版を採りますか。", diff --git a/frontend/src/style.css b/frontend/src/style.css index aef0b50..066db88 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1083,6 +1083,18 @@ font:inherit;font-size:.74rem;font-weight:500; } .nodetip-taigabtn:hover{background:rgba(15,118,110,.08)} + /* Der gelesene Ticket-Stand (D91-Nachtrag 6): abgesetzt wie die Kurz-Fakten, + die Statusbox als gewöhnlicher .chip in den §4-Farben. */ + .nodetip-ticket{ + margin-top:9px;padding-top:8px; + border-top:1px solid rgba(36,52,71,.15);font-size:.74rem; + } + .nodetip-ticket .tk-head{display:flex;align-items:center;gap:6px;flex-wrap:wrap} + .nodetip-ticket .tk-status{font-weight:500} + .nodetip-ticket .tk-arrow{color:var(--muted)} + .nodetip-ticket .tk-line{margin-top:4px;color:var(--muted)} + .nodetip-ticket .tk-err{margin-top:2px;margin-bottom:6px;color:var(--warn)} + .nodetip-ticket .tk-mini{margin-left:auto;padding:2px 7px;font-size:.8rem;line-height:1.1} /* Die Spitze zeigt auf den Knoten — ohne sie wäre bei dicht stehenden Knoten nicht zu sehen, welcher gemeint ist. Ein gedrehtes Quadrat mit zwei Kanten: so erbt es Rahmen UND Schatten des Fensters. JS setzt --tipx (Spitze) und diff --git a/frontend/src/taiga.js b/frontend/src/taiga.js index a1f5e11..6a24b95 100644 --- a/frontend/src/taiga.js +++ b/frontend/src/taiga.js @@ -9,10 +9,20 @@ zeigt nur die Nummer. */ import { gateOf, isDone, isRealized } from './model.js'; +import { STATUS_BY_CODE } from './parser.js'; /* Das Tracker-Muster (SPEC §11): `US-\d+` (Story) und `T-\d+` (Task). */ export const TICKET_ID_RE = /^(?:US|T)-\d+$/; +/* Zerlegt eine Ref in Typ und nackte Nummer: `US-123` -> {kind:'US', nr:123}. + Das Präfix trägt den Typ (SPEC §11) — daran hängen der Frontend-Pfad + (unten), Taigas getrennte `by_ref`-Endpunkte und damit auch der Proxy-Pfad. + Ungültiges ergibt null; geraten wird nirgends. */ +export function refParts(ref){ + const m = /^(US|T)-(\d+)$/.exec(ref || ''); + return m ? {kind: m[1], nr: m[2]} : null; +} + /* Trägt die Zeile eines Knotens schon eine Ticket-Referenz? Das ist der Idempotenz-Marker (D91-Nachtrag 2): So ein Knoten wird nicht erneut angelegt. Die Ref ist entweder die Knoten-ID selbst (erstes `#`-Token, @@ -29,9 +39,39 @@ export function ticketRefOf(n){ genau dafür schreibt Werkbaum es (SPEC §11). Ohne Web-Basis, Projekt-Slug oder gültige Ref gibt es keine Adresse (null). */ export function ticketUrl(web, slug, ref){ - const m = /^(US|T)-(\d+)$/.exec(ref || ''); - if(!m || !web || !slug) return null; - return web + '/project/' + slug + '/' + (m[1] === 'US' ? 'us' : 'task') + '/' + m[2]; + const p = refParts(ref); + if(!p || !web || !slug) return null; + return web + '/project/' + slug + '/' + (p.kind === 'US' ? 'us' : 'task') + '/' + p.nr; +} + +/* Der Pfad am Backend-Proxy zum LESEN eines Tickets (D91-Nachtrag 6), + relativ zu `/api/v1/taiga`: zwei benannte Endpunkte statt eines mit + Typ-Parameter, weil Taiga getrennte `by_ref`-Endpunkte hat. Der Slug + kommt als Query dazu (eine Ref ist nur je Projekt eindeutig). */ +export function ticketApiPath(ref, slug){ + const p = refParts(ref); + if(!p || !slug) return null; + return '/' + (p.kind === 'US' ? 'userstories' : 'tasks') + '/' + p.nr + + '?slug=' + encodeURIComponent(slug); +} + +/* Taiga-Workflow -> Statusbox der Notation (SPEC §4/§9, D91-Nachtrag 6). + Die Vorgabe steht in backend/CLAUDE.md; abgebildet wird im **Editor**, denn + die Statuscodes sind Notations-Vokabular und das Backend parst die Notation + nicht (D14). Groß-/Kleinschreibung und Leerraum sind egal; ein Name + außerhalb der Liste bleibt unabgebildet (null) — geraten wird nicht, und + Taigas Workflows sind je Projekt frei benennbar. */ +export const TAIGA_STATUS_CODE = { + 'new': ' ', + 'in progress': '~', + 'ready for test': '/', + 'done': 'x', + 'archived': '^', +}; +export function mapTaigaStatus(name){ + if(typeof name !== 'string') return null; + const code = TAIGA_STATUS_CODE[name.trim().toLowerCase().replace(/\s+/g, ' ')]; + return code ? STATUS_BY_CODE[code] : null; } /* Die Ticket-Referenz unter der Schreibmarke (Strg+Klick im Text, diff --git a/frontend/tests/taiga.test.js b/frontend/tests/taiga.test.js index d70b1f5..1054c47 100644 --- a/frontend/tests/taiga.test.js +++ b/frontend/tests/taiga.test.js @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { parse } from '../src/parser.js'; import { taigaSlugs } from '../src/model.js'; -import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt } from '../src/taiga.js'; +import { ticketRefOf, taskCandidates, appendToken, refToken, slugToken, ticketUrl, ticketRefAt, refParts, ticketApiPath, mapTaigaStatus } from '../src/taiga.js'; /* Schlagworte `&tag` (SPEC §1, D91): Extraktion im Parser und die `taiga.*`-Vererbung in model.js — der erste Konsument der reservierten @@ -242,3 +242,62 @@ describe('ticketRefAt — die Ref unter der Schreibmarke', () => { expect(ticketRefAt(text, at(text, 'US'))).toBe(null); }); }); + +/* Ticket-Stand lesen (D91-Nachtrag 6): Ref zerlegen, Proxy-Pfad bauen, + Taiga-Workflow auf die Statusbox der Notation abbilden. */ + +describe('refParts / ticketApiPath — das Präfix trägt den Typ', () => { + it('zerlegt Story- und Task-Refs', () => { + expect(refParts('US-123')).toEqual({kind: 'US', nr: '123'}); + expect(refParts('T-9')).toEqual({kind: 'T', nr: '9'}); + }); + + it('alles andere ergibt null — geraten wird nicht', () => { + expect(refParts('ABC-1')).toBe(null); + expect(refParts('US-1x')).toBe(null); + expect(refParts('123')).toBe(null); + expect(refParts(null)).toBe(null); + }); + + it('baut die getrennten by_ref-Pfade des Proxys samt Slug', () => { + expect(ticketApiPath('US-123', 'mi-kunde')).toBe('/userstories/123?slug=mi-kunde'); + expect(ticketApiPath('T-1234', 'mi-kunde')).toBe('/tasks/1234?slug=mi-kunde'); + }); + + it('kodiert den Slug — er hängt keinen zweiten Parameter an', () => { + expect(ticketApiPath('US-1', 'a&b=2')).toBe('/userstories/1?slug=a%26b%3D2'); + }); + + it('ohne Slug oder mit unbrauchbarer Ref gibt es keinen Pfad', () => { + expect(ticketApiPath('US-1', null)).toBe(null); + expect(ticketApiPath('ABC-1', 'mi-kunde')).toBe(null); + }); +}); + +describe('mapTaigaStatus — Workflow auf die Statusbox (SPEC §4/§9)', () => { + const code = name => (mapTaigaStatus(name) || {}).code; + + it('bildet die fünf vorgegebenen Zustände ab', () => { + expect(code('New')).toBe(' '); + expect(code('In progress')).toBe('~'); + expect(code('Ready for test')).toBe('/'); + expect(code('Done')).toBe('x'); + expect(code('Archived')).toBe('^'); + }); + + it('liefert den vollen Status samt Schlüssel für die Anzeige', () => { + expect(mapTaigaStatus('in progress').key).toBe('arbeit'); + expect(mapTaigaStatus('DONE').key).toBe('fertig'); + }); + + it('Groß-/Kleinschreibung und Leerraum sind egal', () => { + expect(code(' ready FOR test ')).toBe('/'); + }); + + it('ein unbekannter Name bleibt unabgebildet', () => { + expect(mapTaigaStatus('Blocked')).toBe(null); + expect(mapTaigaStatus('')).toBe(null); + expect(mapTaigaStatus(null)).toBe(null); + expect(mapTaigaStatus(undefined)).toBe(null); + }); +});